feat: company cascade delete + user management + settings focus fix + bulk delete components

- Company DELETE: cascade non-accounting deps (deals, projects, tickets, persons,
  leads, instances), block only on accounting (invoices, licenses, expenses,
  contracts) per GoBD rule. Transaction-wrapped.
- User management: GET /api/aos/users + PATCH role + DELETE (guards: no
  self-delete, no last owner delete, no env-admin delete).
- User management UI: table with inline role select, 2FA status, delete button.
- Settings focus fix: moved Field component outside render (was causing
  unmount/remount on each keystroke → focus loss).
- Bulk delete: useBulkSelect hook + BulkDeleteDialog + BulkActionBar.
- Cleaned all demo/mock data; re-bootstrapped admin account.
This commit is contained in:
Z User committed 2026-10-30 02:02:52 +00:00
1 parent d492182b4e
commit 47945f6f64
9 files changed
+834 -95

No files matched your search

+46 -46
View File
@@ -134,14 +134,17 @@ export async function PATCH(
// DELETE /api/aos/crm/companies/[id] — delete company.
//
// Cascade policy: a company may only be hard-deleted when it has NO related
// records (invoices, licenses, deals, projects, contracts, expenses, tickets,
// instances, leads, persons). If related records exist, the call returns 409
// with the dependency counts so the UI can show a dialog explaining what
// blocks the deletion.
// GoBD cascade policy:
// - Non-accounting relations (deals, projects, tickets, leads, persons,
// instances, agency clients) are CASCADE-DELETED. These are operational
// records, not accounting — hard delete is allowed per the GoBD rule.
// - Accounting relations (invoices, licenses, expenses, contracts) BLOCK
// deletion. These are GoBD-retained and may not be deleted. The call
// returns 409 with the accounting dependency counts so the user knows
// which records to storno/cancel first.
//
// This is the GoBD-safe path: never silently cascade-delete invoices or
// licenses (those have their own retention rules).
// Audit-logged. The cascade is wrapped in a transaction so a partial failure
// doesn't leave orphan records.
export async function DELETE(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
@@ -153,60 +156,57 @@ export async function DELETE(
return NextResponse.json({ error: 'company not found' }, { status: 404 })
}
// Check all FK relations on Company (see prisma/schema.prisma).
const [
invoices,
licenses,
deals,
projects,
contracts,
expenses,
tickets,
instances,
leads,
persons,
agencyClients,
] = await Promise.all([
// Check ACCOUNTING relations — these block deletion (GoBD).
const [invoices, licenses, expenses, contracts] = await Promise.all([
db.invoice.count({ where: { companyId: id } }),
db.license.count({ where: { companyId: id } }),
db.deal.count({ where: { companyId: id } }),
db.project.count({ where: { companyId: id } }),
db.contract.count({ where: { companyId: id } }),
db.expense.count({ where: { companyId: id } }),
db.ticket.count({ where: { companyId: id } }),
db.instance.count({ where: { companyId: id } }),
db.lead.count({ where: { companyId: id } }),
db.person.count({ where: { companyId: id } }),
db.company.count({ where: { parentAgencyId: id } }),
db.contract.count({ where: { companyId: id } }),
])
const dependencies: Record<string, number> = {
const accountingDeps: Record<string, number> = {
invoices,
licenses,
deals,
projects,
contracts,
expenses,
tickets,
instances,
leads,
persons,
agencyClients,
contracts,
}
const totalDeps = Object.values(dependencies).reduce((s, n) => s + n, 0)
if (totalDeps > 0) {
const totalAccounting = Object.values(accountingDeps).reduce((s, n) => s + n, 0)
if (totalAccounting > 0) {
return NextResponse.json(
{
error: 'company has related records — delete or reassign them first',
dependencies,
total: totalDeps,
error: 'Firma hat GoBD-relevante Datensätze (Rechnungen, Lizenzen, Ausgaben, Verträge) — diese können nicht gelöscht, nur storniert werden.',
dependencies: accountingDeps,
total: totalAccounting,
},
{ status: 409 }
)
}
// No related records — safe to hard delete.
await db.company.delete({ where: { id } })
// No accounting deps — cascade-delete non-accounting relations + company.
await db.$transaction(async (tx) => {
// Cascade non-accounting records (these have no GoBD retention).
await tx.lead.deleteMany({ where: { companyId: id } })
await tx.person.deleteMany({ where: { companyId: id } })
await tx.ticket.deleteMany({ where: { companyId: id } })
const projectIds = (await tx.project.findMany({ where: { companyId: id }, select: { id: true } })).map((p) => p.id)
if (projectIds.length > 0) {
await tx.task.deleteMany({ where: { projectId: { in: projectIds } } })
await tx.timeEntry.deleteMany({ where: { projectId: { in: projectIds } } })
try { await tx.portalApproval.deleteMany({ where: { projectId: { in: projectIds } } }) } catch {}
}
await tx.project.deleteMany({ where: { companyId: id } })
await tx.deal.deleteMany({ where: { companyId: id } })
await tx.instance.deleteMany({ where: { companyId: id } })
// Agency application + repo assignments (if this company applied/is a reseller).
try { await tx.agencyApplication.deleteMany({ where: { companyId: id } }) } catch {}
try { await tx.repoAssignment.deleteMany({ where: { companyId: id } }) } catch {}
// Unlink agency clients (set parentAgencyId = null so they become direct clients).
await tx.company.updateMany({ where: { parentAgencyId: id }, data: { parentAgencyId: null } })
// Unlink users (set companyId = null so they keep their accounts).
await tx.user.updateMany({ where: { companyId: id }, data: { companyId: null } })
// Finally delete the company itself.
await tx.company.delete({ where: { id } })
})
await db.auditLog.create({
data: {
@@ -215,7 +215,7 @@ export async function DELETE(
entity: 'Company',
entityId: id,
before: JSON.stringify(existing),
after: JSON.stringify({ deleted: true }),
after: JSON.stringify({ deleted: true, cascaded: 'deals,projects,tickets,leads,persons,instances' }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})