thats-me/backend/app/Support/EventFields.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

169 lines
5.6 KiB
PHP

<?php
namespace App\Support;
/**
* Single source of truth for event field validation and column mapping.
*
* Both the CRUD endpoints and the batch sync endpoint build their rules from
* here, so a field can never be validated on one path and waved through on
* the other.
*/
final class EventFields
{
/**
* Category keys the client offers. Kept as keys, not labels, so the
* wording can change without touching stored data.
*/
public const CATEGORIES = [
'family', 'friends', 'love', 'work', 'education', 'health',
'travel', 'home', 'hobby', 'loss', 'other',
];
/**
* Validation rules per API field, without any presence rule.
*
* @return array<string, array<int, string>>
*/
public static function rules(): array
{
return [
// Empty is allowed: an event is created with its first entry,
// which may well be a photo or a note rather than a title.
'title' => ['nullable', 'string', 'max:255'],
'date' => ['date_format:Y-m-d'],
// A span ends on or after the day it starts; null means one day.
'endDate' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:date'],
'emotion' => ['numeric', 'min:-1', 'max:1'],
'milestone' => ['boolean'],
'location' => ['nullable', 'string', 'max:255'],
'category' => ['nullable', 'string', 'in:'.implode(',', self::CATEGORIES)],
'people' => ['nullable', 'array', 'max:30'],
// The request middleware turns '' into null; blanks are dropped in toColumns.
'people.*' => ['nullable', 'string', 'max:80'],
'customColor' => ['nullable', 'string', 'max:20'],
'gradientPreset' => ['nullable', 'integer', 'min:0', 'max:9'],
'image' => ['nullable', 'string', 'max:500'],
'keyImageTitle' => ['nullable', 'string', 'max:255'],
'note' => ['nullable', 'string', 'max:5000'],
];
}
/**
* API fields that must be present when an event is created.
*
* @return array<int, string>
*/
public static function required(): array
{
return ['date', 'emotion'];
}
/**
* API fields that must be sent on create, but may be empty.
*
* @return array<int, string>
*/
public static function present(): array
{
return ['title'];
}
/**
* Rules for creating an event: required fields are mandatory.
*
* @return array<string, array<int, string>>
*/
public static function storeRules(string $prefix = ''): array
{
return self::withPresence($prefix, fn (string $field): array => match (true) {
in_array($field, self::required(), true) => ['required'],
in_array($field, self::present(), true) => ['present'],
default => ['sometimes'],
});
}
/**
* Rules for updating an event: every field is optional, but a field that
* *is* sent must still be valid and required fields may not be nulled.
*
* @return array<string, array<int, string>>
*/
public static function updateRules(string $prefix = ''): array
{
return self::withPresence($prefix, fn (string $field): array => in_array($field, self::required(), true)
? ['sometimes', 'required']
: ['sometimes']);
}
/**
* Map validated API fields onto their database columns.
*
* Only keys actually present in the payload are returned, so a partial
* update never clears a column the client did not mention.
*
* @param array<string, mixed> $validated
* @return array<string, mixed>
*/
public static function toColumns(array $validated): array
{
$columns = [
'title' => 'title',
'date' => 'date',
'endDate' => 'end_date',
'emotion' => 'emotion',
'milestone' => 'milestone',
'location' => 'location',
'category' => 'category',
'people' => 'people',
'customColor' => 'custom_color',
'gradientPreset' => 'gradient_preset',
'image' => 'image',
'keyImageTitle' => 'key_image_title',
'note' => 'note',
];
$data = [];
foreach ($columns as $field => $column) {
if (array_key_exists($field, $validated)) {
$data[$column] = $validated[$field];
}
}
// The column is NOT NULL; the request middleware turns '' into null.
if (array_key_exists('title', $data) && $data['title'] === null) {
$data['title'] = '';
}
if (array_key_exists('people', $data)) {
// Trimmed, without blanks and duplicates; an empty list is null.
$people = array_values(array_unique(array_filter(
array_map(fn ($name) => trim((string) $name), $data['people'] ?? []),
fn (string $name) => $name !== ''
)));
$data['people'] = $people === [] ? null : $people;
}
if (array_key_exists('milestone', $data)) {
$data['milestone'] = (bool) $data['milestone'];
}
return $data;
}
/**
* @param callable(string): array<int, string> $presence
* @return array<string, array<int, string>>
*/
private static function withPresence(string $prefix, callable $presence): array
{
$rules = [];
foreach (self::rules() as $field => $fieldRules) {
$rules[$prefix.$field] = [...$presence($field), ...$fieldRules];
}
return $rules;
}
}