Přeskočit na obsah

PUT handler implicitně maže záznam při změně stavu (skrytý delete)

import { Aside } from ‘@astrojs/starlight/components’;

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.

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).

  • Bez details v auditu nelze obnovit entitu z auditu samotného. Lze obnovit z paralelních záznamů (např. Activity log obsahuje clientId, 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č.

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í archiv

Toto je vratné (soft-delete), audit trail kompletní, lze obnovit nastavením archivedAt = null.

3) Hard-delete jen v DELETE endpointu s explicit permission check:

/api/leads/[id]/route.ts
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" audit
JOIN "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 DELETEdetails: serializeEntity(beforeDelete).

  • [[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]].
Přidal aiarchitekt.cz · 1. 6. 2026 2:00
Provozuje aiarchitekt.cz