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 (array_key_exists('since', $validated)) { $query->where('updated_at', '>', $request->date('since')); } return EventResource::collection( $query->cursorPaginate($validated['limit'] ?? 50)->withQueryString() ); } /** * POST /api/events */ public function store(StoreEventRequest $request): JsonResponse { $event = $request->user()->events()->create([ 'client_id' => $request->validated('id'), ...EventFields::toColumns($request->validated()), ]); return (new EventResource($event->load('media'))) ->response() ->setStatusCode(201); } /** * GET /api/events/{clientId} */ public function show(Request $request, string $clientId): EventResource { $event = $request->user()->events() ->where('client_id', $clientId) ->firstOrFail(); return new EventResource($event->load('media')); } /** * PUT /api/events/{clientId} */ public function update(UpdateEventRequest $request, string $clientId): EventResource { $event = $request->user()->events() ->where('client_id', $clientId) ->firstOrFail(); $event->update(EventFields::toColumns($request->validated())); return new EventResource($event->fresh()->load('media')); } /** * DELETE /api/events/{clientId} */ public function destroy(Request $request, string $clientId): JsonResponse { $event = $request->user()->events() ->where('client_id', $clientId) ->firstOrFail(); $this->mediaCleaner->purgeEventMedia($event); $event->delete(); return response()->json(null, 204); } /** * 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 { $request->validate([ 'mutations' => ['required', 'array', 'max:100'], 'mutations.*.action' => ['required', 'in:create,update,delete'], 'mutations.*.eventId' => ['required', 'uuid'], 'mutations.*.payload' => ['nullable', 'array'], ]); $user = $request->user(); $results = []; foreach ($request->input('mutations') as $mutation) { $action = $mutation['action']; $clientId = $mutation['eventId']; $payload = $mutation['payload'] ?? []; try { $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) { // 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 $payload * @return array */ 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 $payload * @return array */ 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 */ 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']; } }