Line edit / verification
Trust model + proposal queue subsystém pro editaci lajn (LineEdit). Otevřené pro autenticated usery, ale s curation gate-em na verified lajny.
Trust model
| Stav lajny | Owner edit | Cizí user edit | Admin edit | Owner delete | Admin delete |
|---|---|---|---|---|---|
unverified (čerstvě přidaná + všech 254 legacy) |
direct ✓ | proposal ⇒ queue¹ | direct ✓ | direct ✓ | direct ✓ |
verified (jen admin-schválené) |
proposal ⇒ queue | proposal ⇒ queue | direct ✓ | ✗ | direct ✓ |
¹ Edit (návrh) může poslat kdokoli přihlášený — jde do admin fronty; direct edit jen owner/admin. Mazání je přísnější: cizí user nemaže (jen owner unverified / admin).
Vzkaz: kdokoliv logged-in si může vytvořit „svou lajnu" a libovolně na ní dělat změny. Admin pak rozhodne, jestli ji verifikuje (= povýší do shared sady, kde každá změna jde přes review) nebo ji nechá osobní.
⚠️ Legacy 254 jsou
unverified(záměrně). MigraceVersion20260510113004sice obsahujeUPDATE highline SET is_verified = TRUE WHERE legacy_id IS NOT NULL, ale je to no-op: migrace běží nad prázdnou DB před importem (make migrate→make legacyImport), takže UPDATE potká 0 řádků a import pak lajny vytvoří s defaultemfalse. Ponecháno tak schválně — admin musí legacy data postupně pročistit (= verifikovat). Důsledek: legacy lajny majícreatedBy = null(nikoho), takže je napřímo edituje/maže jen admin. Běžný přihlášený uživatel ale může poslat návrh úpravy (jde do admin fronty) — to platí pro každého, kdo není owner/admin, na jakékoli lajně. Direct edit/delete je gated na owner/admin (vizedit()—canEditDirect = isAdmin || (isOwner && !isVerified)).
„navrženo" štítek na detailu lajny (not isVerified) se renderuje jen adminovi (is_granted('ROLE_ADMIN')) — je to signál pro verifikační workflow (de facto adminův cleanup checklist), ne info pro běžného návštěvníka. Jinak by visel na všech neověřených lajnách včetně 254 legacy.
Entity
Line (rozšířená, viz src/Entity/Line.php)
+ isVerified bool DEFAULT false — gate flag
+ createdBy ?User ON DELETE SET NULL — autor submisse, NULL pro 254 legacy řádků
Helper: Line::isOwnedBy(?User): bool — null createdBy → vždy false (legacy nejsou ničí).
LineEdit (src/Entity/LineEdit.php)
Unified record změn. Slouží zároveň jako audit log i jako pending queue.
id, line FK, proposedBy ?User, snapshot json, status enum, createdAt, reviewedBy ?User, reviewedAt ?datetime
status (enum App\Enum\LineEditStatus):
applied— změna už je na lajně. Vytváří se při direct edit (owner of unverified, admin) a při schválení proposalu.pending— návrh ve frontě, čeká na admina.rejected— admin návrh zamítl. Řádek se nemaže (audit).
Index idx_line_edit_status — admin queue často filtruje WHERE status='pending'.
snapshot = celá množina form-bound polí jako JSON. Pro applied rows = post-change stav, pro pending/rejected = navrhované hodnoty (které se ještě neaplikovaly). Derived sloupec length (haversine z point1/point2) je v snapshotu taky uložený. (Lajna nemá žádné separátní latitude/longitude — lokace = oba kotvící body; viz docs/architecture.md § Mapa.)
Routes
| Path | Name | Auth | Co dělá |
|---|---|---|---|
GET|POST /lajna/pridat |
app_line_new |
ROLE_USER |
nová lajna; submit ⇒ unverified + createdBy=user + audit APPLIED |
GET|POST /lajna/{slug}/uprava |
app_line_edit |
ROLE_USER |
direct edit (owner of unverified / admin) NEBO proposal (verified + non-admin) |
POST /lajna/{slug}/smazat |
app_line_delete |
ROLE_USER |
owner-of-unverified nebo admin |
POST /lajna/{slug}/overeni |
app_line_verify |
ROLE_ADMIN |
flag isVerified = true + sloučí dosavadní edity do jediné creation revize |
GET /admin/navrhy |
app_admin_proposals |
ROLE_ADMIN |
queue pending proposals + diff tabulky |
POST /admin/navrhy/{id}/schvalit |
app_admin_proposal_approve |
ROLE_ADMIN |
apply snapshot + flag APPLIED |
POST /admin/navrhy/{id}/zamitnout |
app_admin_proposal_reject |
ROLE_ADMIN |
flag REJECTED |
GET /lajna/{slug}/historie |
app_line_history |
public | audit log (APPLIED + REJECTED) chronologicky |
POST /lajna/{slug}/historie/{editId}/smazat |
app_line_history_delete |
ROLE_ADMIN |
smaže jeden záznam historie (kromě prvního, chronologicky); stav lajny se nemění |
/lajna/pridat má priority: 10 aby se nepřevrstvila s /lajna/{slug} (slug regex [a-z0-9-]+ matchne i „pridat").
Form (src/Form/LineForm.php)
19 polí. Required: name, type, height, point1Lat/Lng, point2Lat/Lng. Slug se generuje automaticky z name přes AsciiSlugger (collision = numerický suffix).
Verified lajny mají name zamčený — disabled: true (server-side ignoruje POSTed value) + 🔒 ikonka u labelu s tooltipem ukazujícím URL a důvod. Slug se nikdy nepřegenerovává po vytvoření, takže zámek na názvu chrání URL stabilitu (existující odkazy / bookmarky / tisk nepřestanou fungovat). Nezamčené názvy jsou jen u unverified lajn (autor je ladí před verifikací).
Length NENÍ ve formu — odvozuje se v LineCrudController::deriveGeometry() z point1/point2 přes haversine (v metrech, zaokrouhleno). Lajna nemá žádné separátní latitude/longitude (zahozené v migraci Version20260609120000) — lokace je daná výhradně dvěma kotvícími body; reprezentativní jeden bod = point1. Formulář má od 2026-07-09 read-only <output> s live-updated délkou vedle výšky (podrobnosti v docs/architecture.md § Line form — GPS picker), ale samo pole length se pořád nikde neodesílá.
GPS picker je Stimulus line_form_map_controller.js — button-driven (ne alternující klik): „Přidat bod 1/2"/„Přidat parkování" každé vyzbrojí konkrétní mód, další klik do mapy umístí ten bod, tlačítko se přepne na „Smazat …". Markery draggable, polyline + live distance overlay (od 2026-07-09 i live při tažení, ne jen po puštění — viz docs/architecture.md). Sync se 6 inputs (2 body + parkování) oboustranně.
rating — hodnocení lajny (2026-07-09)
Na rozdíl od zbytku formuláře je rating (1–5★, HiddenType + klikací rating-picker widget, viz docs/architecture.md § Hotové features) plnohodnotně součástí audit systému — je v LineCrudController::FIELDS, snapshot() i applySnapshot(), takže se chová stejně jako height/description/atd.: u verified lajny jde přes proposal queue, diff se ukazuje v historii. Pozor na duplicitu s SyncLineFromAuditCommand::applySnapshot() — je to záměrně samostatná zrcadlová kopie (viz komentář v souboru), takže při přidání nového pole do FIELDS/snapshot()/applySnapshot() v controlleru je potřeba stejnou trojici polí doplnit i tam (chytilo se to 2026-07-09, kdy rating v mirror commandu chyběl).
coverPhotoFile — titulní fotka (2026-07-09), MIMO audit systém
coverPhotoFile je unmapped ('mapped' => false) — netýká se ho FIELDS/snapshot/applySnapshot vůbec. Cover foto obchází celý trust/proposal model a aplikuje se vždy okamžitě, bez ohledu na queueProposal — stejně jako existující samostatný upload flow (LinePhotoController::new(), /lajna/{slug}/fotky/pridat), kde taky může fotku přidat/nahradit kdokoli přihlášený bez ohledu na vlastnictví lajny. V edit() se cover foto zpracuje a flushne ve vlastní transakci, před deriveGeometry()/proposal větví, takže přežije následný $em->refresh($line) v proposal cestě.
Gotcha, na kterou je snadné nabalit TypeError/500: VichUploader directory namer pro line_photo mapping (config/packages/vich_uploader.yaml) je PropertyDirectoryNamer s property: 'line.id' — potřebuje, aby lajna už měla ID (byla flushnutá), než se k ní připojí LinePhoto. V new() (vytvoření lajny) proto musí být $em->flush() na lajnu samotnou před persist+flush fotky (dva sekvenční flushe, ne jeden společný) — jinak Directory name could not be generated: property line.id is empty. V edit() už lajna ID má, takže tam se to netýká.
Direct edit vs proposal — implementace
$queueProposal = !$isAdmin && $line->isVerified();
if ($form->isValid()) {
$this->deriveGeometry($line);
if ($queueProposal) {
$snapshot = $this->snapshot($line);
$em->refresh($line); // discard in-memory changes
$proposal = new LineEdit(...PENDING, snapshot=$snapshot);
$em->persist($proposal); $em->flush();
} else {
$em->flush(); // direct apply
$audit = $this->buildEdit(...APPLIED, snapshot=$this->snapshot($line));
$em->persist($audit); $em->flush();
}
}
Klíč: EntityManager::refresh() po extrakci snapshotu. Form-bound entita má v paměti změny, které DOCHCEŇ v proposal flow nepersistovat. refresh() znovu načte řádek z DB a tím změny zahodí.
Admin queue UI (/admin/navrhy)
Pro každý pending edit se přepočítá diff(currentSnapshot, proposedSnapshot) — pole, kde se hodnoty liší. Render tabulkou (Pole / Před / Po), strikethrough na before, magenta highlight na after. Schválit / Zamítnout = POST formy s CSRF tokeny proposal-approve-{id} / proposal-reject-{id}.
Audit log
Každá APPLIED row v line_edit je trvalý záznam změny. Sjednoceno tak, aby:
- direct edit (owner of unverified, admin) → 1 APPLIED row
- approved proposal → 1 APPLIED row (původní PENDING se updatne na APPLIED, ne nová row)
- rejected proposal → 1 REJECTED row (zachovaná pro historii)
To dává uniform findForLine(Line) query která vrátí kompletní timeline změn bez ohledu na trasu jakou prošly.
History view (/lajna/{slug}/historie)
Veřejný read-only audit log + admin stack-pop kurátoring.
Každý LineEdit row si u sebe nese vlastní beforeSnapshot (stav lajny PŘED tím, co tahle úprava udělala) i snapshot (po). Diff se počítá render-time přímo z těchto dvou polí, ne ze sousedního řádku, takže každá řádka přesně reflektuje co tahle úprava změnila nezávisle na tom, co s historií dělá admin.
- První APPLIED v pořadí má
beforeSnapshot = NULL→ render „Vytvořeno", bez diffu - Ostatní APPLIED rows mají vlastní before+after diff
- PENDING / REJECTED rows mají
beforeSnapshotzachycený v okamžiku vytvoření návrhu
Mazání = stack pop: admin smaže jen úplně poslední (chronologicky) revizi, ne libovolnou ze středu. Po pop-u se zavolá applySnapshot(newLatestApplied) na entitu — lajna se vrátí do stavu, který byl před smazaným editem. Audit log = zdroj pravdy pro stav lajny.
Sloučení historie při verifikaci: když admin lajnu verifikuje, audit se vyčistí — všechny dosavadní revize (typicky rename-y z unverified éry) se zahodí a místo nich se zapíše jediná APPLIED creation s aktuálním stavem. Důvod: po verifikaci je name + slug zamknutý, takže by stack-pop přes původní rename revize jinak vrátil lajnu do stavu se starým názvem, ale slugem už nesynchronizovaným. Sloučením začíná verified éra s čistým historickým seedem.
Proč stack-pop a ne free-deletion: smazání prostředního řádku by zanechalo nekonzistenci — sousední rows nemůžou „pohltit" změny smazaného (díky vlastnímu beforeSnapshot), ale stav lajny by se nemohl jednoznačně přepočítat. Stack-pop tu nejednoznačnost odstraňuje úplně.
Tlačítko „Smazat poslední revizi" se zobrazí jen u nejnovější rows v listu, a jen když existuje víc než 1 záznam (creation samotný se smazat nedá).
ROLE_ADMIN
Granted přes console:
docker compose exec -T php bin/console app:admin:grant <email>
docker compose exec -T php bin/console app:admin:grant <email> --revoke
Aktuální admin: p***@***l.com (ID 251).
ROLE_ADMIN automaticky implikuje ROLE_USER (Symfony role hierarchy default). V User::getRoles() se ROLE_USER připíše vždy navíc.
Naming gotcha — isVerified
User.isVerified (email confirmation, dělá symfonycasts/verify-email-bundle) vs Line.isVerified (admin-curated quality stamp) jsou dvě nesouvisející věci se stejným názvem v jiných entitách. Doctrine je nemíchá (jiný namespace), ale při review může mást — zejména pokud se v jednom souboru objeví $user->isVerified() i $line->isVerified(). Při čtení kódu zkontroluj na čem se to volá.