Backend: Medien-Typen, Sprachnotizen, Event-Felder und Sync-Härtung
- Event-Medien tragen Typ (image/video/audio), Dauer und Notiz; Videos bekommen ein Poster, Audio wird per ffmpeg nach AAC (audio/mp4) gewandelt, Originale werden mit Range-Unterstützung gestreamt. - Upload nimmt eine Notiz (caption) direkt mit, PATCH ändert sie später. - Neue Event-Felder: Zeitraum (end_date), Kategorie, Personen, Meilenstein. Regeln zentral in EventFields, damit CRUD und Sync gleich prüfen. - Titel darf leer sein: ein Event entsteht mit dem ersten Eintrag. - Artisan-Befehl media:transcode-voice-notes wandelt alte WebM/Ogg-Notizen, die Safari nicht abspielen kann. - Login-Throttling, Settings-Validierung, Sync-Fehlercodes ohne Rohtext. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
f79dcb9928
commit
b262a6771f
59 changed files with 11879 additions and 197 deletions
|
|
@ -6,31 +6,47 @@ use App\Http\Controllers\Controller;
|
|||
use App\Http\Requests\StoreEventRequest;
|
||||
use App\Http\Requests\UpdateEventRequest;
|
||||
use App\Http\Resources\EventResource;
|
||||
use App\Models\User;
|
||||
use App\Services\MediaCleaner;
|
||||
use App\Support\EventFields;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class EventController extends Controller
|
||||
{
|
||||
public function __construct(private readonly MediaCleaner $mediaCleaner) {}
|
||||
|
||||
/**
|
||||
* GET /api/events
|
||||
* Cursor-based pagination by date. Supports ?since=ISO for delta sync.
|
||||
*/
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$query = $request->user()->events()->with('media')->orderBy('date');
|
||||
$validated = $request->validate([
|
||||
'limit' => ['sometimes', 'integer', 'min:1', 'max:200'],
|
||||
'since' => ['sometimes', 'date'],
|
||||
'cursor' => ['sometimes', 'string', 'max:500'],
|
||||
]);
|
||||
|
||||
// `date` is not unique, so it cannot carry a cursor on its own: a page
|
||||
// boundary inside one day would skip the rest of that day. `id` makes
|
||||
// the ordering total.
|
||||
$query = $request->user()->events()
|
||||
->with('media')
|
||||
->orderBy('date')
|
||||
->orderBy('id');
|
||||
|
||||
// Delta sync: only events updated since a given timestamp
|
||||
if ($request->has('since')) {
|
||||
$since = $request->date('since');
|
||||
$query->where('updated_at', '>', $since);
|
||||
if (array_key_exists('since', $validated)) {
|
||||
$query->where('updated_at', '>', $request->date('since'));
|
||||
}
|
||||
|
||||
// Cursor-based pagination (default 50, max 200)
|
||||
$limit = min((int) $request->input('limit', 50), 200);
|
||||
|
||||
return EventResource::collection(
|
||||
$query->cursorPaginate($limit)
|
||||
$query->cursorPaginate($validated['limit'] ?? 50)->withQueryString()
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -41,13 +57,7 @@ class EventController extends Controller
|
|||
{
|
||||
$event = $request->user()->events()->create([
|
||||
'client_id' => $request->validated('id'),
|
||||
'title' => $request->validated('title'),
|
||||
'date' => $request->validated('date'),
|
||||
'emotion' => $request->validated('emotion'),
|
||||
'custom_color' => $request->validated('customColor'),
|
||||
'gradient_preset' => $request->validated('gradientPreset'),
|
||||
'image' => $request->validated('image'),
|
||||
'note' => $request->validated('note'),
|
||||
...EventFields::toColumns($request->validated()),
|
||||
]);
|
||||
|
||||
return (new EventResource($event->load('media')))
|
||||
|
|
@ -76,32 +86,7 @@ class EventController extends Controller
|
|||
->where('client_id', $clientId)
|
||||
->firstOrFail();
|
||||
|
||||
$data = [];
|
||||
$validated = $request->validated();
|
||||
|
||||
if (isset($validated['title'])) {
|
||||
$data['title'] = $validated['title'];
|
||||
}
|
||||
if (isset($validated['date'])) {
|
||||
$data['date'] = $validated['date'];
|
||||
}
|
||||
if (isset($validated['emotion'])) {
|
||||
$data['emotion'] = $validated['emotion'];
|
||||
}
|
||||
if (array_key_exists('customColor', $validated)) {
|
||||
$data['custom_color'] = $validated['customColor'];
|
||||
}
|
||||
if (array_key_exists('gradientPreset', $validated)) {
|
||||
$data['gradient_preset'] = $validated['gradientPreset'];
|
||||
}
|
||||
if (array_key_exists('image', $validated)) {
|
||||
$data['image'] = $validated['image'];
|
||||
}
|
||||
if (array_key_exists('note', $validated)) {
|
||||
$data['note'] = $validated['note'];
|
||||
}
|
||||
|
||||
$event->update($data);
|
||||
$event->update(EventFields::toColumns($request->validated()));
|
||||
|
||||
return new EventResource($event->fresh()->load('media'));
|
||||
}
|
||||
|
|
@ -115,6 +100,7 @@ class EventController extends Controller
|
|||
->where('client_id', $clientId)
|
||||
->firstOrFail();
|
||||
|
||||
$this->mediaCleaner->purgeEventMedia($event);
|
||||
$event->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
|
|
@ -123,6 +109,9 @@ class EventController extends Controller
|
|||
/**
|
||||
* POST /api/events/sync
|
||||
* Batch sync: process multiple mutations in one request.
|
||||
*
|
||||
* Every payload runs through the same field rules as the CRUD endpoints,
|
||||
* so this endpoint cannot be used to write values plain CRUD rejects.
|
||||
*/
|
||||
public function sync(Request $request): JsonResponse
|
||||
{
|
||||
|
|
@ -142,58 +131,97 @@ class EventController extends Controller
|
|||
$payload = $mutation['payload'] ?? [];
|
||||
|
||||
try {
|
||||
if ($action === 'create') {
|
||||
$event = $user->events()->where('client_id', $clientId)->first();
|
||||
if (! $event) {
|
||||
$event = $user->events()->create([
|
||||
'client_id' => $clientId,
|
||||
'title' => $payload['title'] ?? 'Untitled',
|
||||
'date' => $payload['date'] ?? now()->format('Y-m-d'),
|
||||
'emotion' => $payload['emotion'] ?? 0,
|
||||
'custom_color' => $payload['customColor'] ?? null,
|
||||
'gradient_preset' => $payload['gradientPreset'] ?? null,
|
||||
'image' => $payload['image'] ?? null,
|
||||
'note' => $payload['note'] ?? null,
|
||||
]);
|
||||
}
|
||||
$results[] = ['eventId' => $clientId, 'status' => 'ok'];
|
||||
} elseif ($action === 'update') {
|
||||
$event = $user->events()->where('client_id', $clientId)->first();
|
||||
if ($event) {
|
||||
$data = [];
|
||||
if (isset($payload['title'])) {
|
||||
$data['title'] = $payload['title'];
|
||||
}
|
||||
if (isset($payload['date'])) {
|
||||
$data['date'] = $payload['date'];
|
||||
}
|
||||
if (isset($payload['emotion'])) {
|
||||
$data['emotion'] = $payload['emotion'];
|
||||
}
|
||||
if (array_key_exists('customColor', $payload)) {
|
||||
$data['custom_color'] = $payload['customColor'];
|
||||
}
|
||||
if (array_key_exists('gradientPreset', $payload)) {
|
||||
$data['gradient_preset'] = $payload['gradientPreset'];
|
||||
}
|
||||
if (array_key_exists('image', $payload)) {
|
||||
$data['image'] = $payload['image'];
|
||||
}
|
||||
if (array_key_exists('note', $payload)) {
|
||||
$data['note'] = $payload['note'];
|
||||
}
|
||||
$event->update($data);
|
||||
}
|
||||
$results[] = ['eventId' => $clientId, 'status' => 'ok'];
|
||||
} elseif ($action === 'delete') {
|
||||
$user->events()->where('client_id', $clientId)->delete();
|
||||
$results[] = ['eventId' => $clientId, 'status' => 'ok'];
|
||||
}
|
||||
$results[] = match ($action) {
|
||||
'create' => $this->syncCreate($user, $clientId, $payload),
|
||||
'update' => $this->syncUpdate($user, $clientId, $payload),
|
||||
'delete' => $this->syncDelete($user, $clientId),
|
||||
};
|
||||
} catch (ValidationException $e) {
|
||||
$results[] = [
|
||||
'eventId' => $clientId,
|
||||
'status' => 'invalid',
|
||||
'errors' => $e->errors(),
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
$results[] = ['eventId' => $clientId, 'status' => 'error', 'message' => $e->getMessage()];
|
||||
// Technical detail stays in the log; the client gets a stable
|
||||
// code instead of a raw SQL/schema message.
|
||||
Log::error('Event sync mutation failed', [
|
||||
'userId' => $user->id,
|
||||
'action' => $action,
|
||||
'eventId' => $clientId,
|
||||
'exception' => $e,
|
||||
]);
|
||||
|
||||
$results[] = [
|
||||
'eventId' => $clientId,
|
||||
'status' => 'error',
|
||||
'code' => 'sync_failed',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json(['results' => $results]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function syncCreate(User $user, string $clientId, array $payload): array
|
||||
{
|
||||
$existing = $user->events()->where('client_id', $clientId)->first();
|
||||
|
||||
if ($existing) {
|
||||
// Create is idempotent: replaying a queued mutation must not
|
||||
// duplicate or silently overwrite the stored event.
|
||||
return ['eventId' => $clientId, 'status' => 'exists'];
|
||||
}
|
||||
|
||||
$validated = Validator::make($payload, EventFields::storeRules())->validate();
|
||||
|
||||
$user->events()->create([
|
||||
'client_id' => $clientId,
|
||||
...EventFields::toColumns($validated),
|
||||
]);
|
||||
|
||||
return ['eventId' => $clientId, 'status' => 'ok'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function syncUpdate(User $user, string $clientId, array $payload): array
|
||||
{
|
||||
$event = $user->events()->where('client_id', $clientId)->first();
|
||||
|
||||
if (! $event) {
|
||||
// Previously reported as 'ok', which let the client drop a
|
||||
// mutation that never reached the database.
|
||||
return ['eventId' => $clientId, 'status' => 'not_found'];
|
||||
}
|
||||
|
||||
$validated = Validator::make($payload, EventFields::updateRules())->validate();
|
||||
|
||||
$event->update(EventFields::toColumns($validated));
|
||||
|
||||
return ['eventId' => $clientId, 'status' => 'ok'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function syncDelete(User $user, string $clientId): array
|
||||
{
|
||||
$event = $user->events()->where('client_id', $clientId)->first();
|
||||
|
||||
if (! $event) {
|
||||
return ['eventId' => $clientId, 'status' => 'not_found'];
|
||||
}
|
||||
|
||||
$this->mediaCleaner->purgeEventMedia($event);
|
||||
$event->delete();
|
||||
|
||||
return ['eventId' => $clientId, 'status' => 'ok'];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue