- 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>
169 lines
5.6 KiB
PHP
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;
|
|
}
|
|
}
|