- 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>
227 lines
7.2 KiB
PHP
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'];
|
|
}
|
|
}
|