PUT handler implicitně maže záznam při změně stavu (skrytý delete)
import { Aside } from ‘@astrojs/starlight/components’;
Symptom
Sekce “Symptom”Klient hlásí: „leady se nezapisují, mizí mi z přehledu”. Webhook log ale ukazuje, že leady se vytvářejí korektně (DB INSERT proběhl). Klient v listu nevidí leady, které tam viděl ráno, jiné chybí, ale související záznam (klient, faktura, …) v jiné sekci existuje a má pokročilý stav.
V auditu jsou DELETE záznamy entity = lead, action = DELETE. Mazatelé jsou uživatelé s rolí, která nemá LEADS_DELETE permission — mají jen LEADS_UPDATE.
Root cause
Sekce “Root cause”PUT handler (/api/leads/[id]/route.ts) má v sobě business logic „konverze”:
const auth = await requirePermission(PERMISSIONS.LEADS_UPDATE); // ✅ jen UPDATE// ...const newStatus = body.status;const shouldConvert = newStatus && CONVERSION_STATUSES.has(newStatus) // CONTACTED, INTERESTED, NEGOTIATION, WON, LOST && EARLY_STATUSES.has(existing.status); // NEW, ASSIGNED
if (shouldConvert) { // ... aktualizovat související záznam (Client), vytvořit aktivitu/poznámku ...
await prisma.lead.delete({ where: { id: params.id } }); // ❌ HARD DELETE return NextResponse.json({ converted: true });}Uživatel se scope-correct přístupem k svému leadu (LEADS_UPDATE) ho posune ze stavu NEW → CONTACTED. PUT handler interně vyhodnotí „to je konverze” a fyzicky lead smaže. Žádná kontrola DELETE permission. Žádné soft-delete. Záznam je pryč.
Pro audit: zapíše se AuditLog.action=DELETE, ale to je log POST-FACT, ne kontrola PŘED akcí. Detaily v AuditLog.details jsou prázdné (DELETE záznamy obvykle uchovávají jen ID).
Dopad
Sekce “Dopad”- Bez
detailsv auditu nelze obnovit entitu z auditu samotného. Lze obnovit z paralelních záznamů (např. Activity log obsahujeclientId, jméno), ale ne všechny atributy. - Pokud běží konverze masově (každý OZ posune lead → konverze), za týden jsou stovky leadů pryč.
- Uživatelům to vypadá jako bug („leady se nezapisují”), přitom logika dělá přesně, co dělat má — jen špatně designed.
V postiženém systému: ~340 leadů smazaných za 5 týdnů, klienti všichni ještě existují, ale tracking entity pryč.
Fix
Sekce “Fix”1) Odstranit prisma.entity.delete z UPDATE handleru. Konverze už dělá business akce (sync client, activity, note) — to nemusí mazat lead.
if (shouldConvert) { // sync client status + ownership, vytvořit activity + note await syncClientFromLead(lead, newStatus);
// ❌ NEDĚLAT: await prisma.lead.delete(...) // Lead zůstává v DB se stavem CONTACTED/WON/LOST/atd. — viditelný v listu, // jen UI ho může označit jako "konverzovaný" / "uzavřený".
return NextResponse.json({ converted: true, clientId: lead.client.id });}2) Pokud business chce „lead po konverzi schovat z aktivního listu”, použij archivedAt:
await prisma.lead.update({ where: { id }, data: { archivedAt: new Date() } });// + GET endpoint filtruje `archivedAt: null` defaultně, `?archived=true` zobrazí archivToto je vratné (soft-delete), audit trail kompletní, lze obnovit nastavením archivedAt = null.
3) Hard-delete jen v DELETE endpointu s explicit permission check:
export async function DELETE(...) { const auth = await requirePermission(PERMISSIONS.LEADS_DELETE); // ✅ await prisma.lead.delete({ where: { id } });}Recovery existujících smazaných
Sekce “Recovery existujících smazaných”Pokud audit logování má alespoň entityId + paralelní záznamy (Activity, Note) mají vazbu na entitu, jde 95 % záznamů obnovit:
INSERT INTO "Lead" (id, "clientId", status, source, "assignedToId", "createdAt", "updatedAt", description)SELECT audit."entityId", activity."clientId", -- map status z aktuálního stavu klienta: CASE client.status WHEN 'contacted' THEN 'CONTACTED' WHEN 'won' THEN 'WON' -- ... END, 'IMPORT'::"LeadSource", audit."userId", audit."createdAt", audit."createdAt", 'Obnoveno z auditu — původně smazáno při konverzi.'FROM "AuditLog" auditJOIN "Activity" activity ON activity.title ILIKE 'Lead automaticky převeden%' AND activity.description LIKE '%' || audit."entityId" || '%'JOIN "Client" client ON client.id = activity."clientId"WHERE audit.entity = 'lead' AND audit.action = 'DELETE' AND NOT EXISTS (SELECT 1 FROM "Lead" WHERE id = audit."entityId");Pokud audit má details JSON s původním záznamem (lepší design), recovery je 100% bez ztráty atributů. Doporučení: do auditu zapisovat snapshot entity před DELETE — details: serializeEntity(beforeDelete).
Související
Sekce “Související”- [[soft-delete-preserve-history]] — proč soft-delete vyhrává nad hard-delete.
- Audit logy potřebují snapshot, ne jen ID — viz [[trigger-double-counts-on-restore]].