thats-me/backend/app/Http/Controllers/Api/EventController.php
Kevin Adametz b262a6771f 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>
2026-09-25 07:00:35 +00:00

227 lines
7.2 KiB
PHP

<?php
namespace App\Http\Controllers\Api;
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
{
$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 (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<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'];
}
}