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:
1 parent
d492182b4e
commit
47945f6f64
9 files changed
+834
-95
No files matched your search
@@ -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),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// PATCH /api/aos/users/[id] — update user role (admin only).
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const body = await req.json().catch(() => ({}))
|
||||
const { role } = body as { role?: string }
|
||||
|
||||
const existing = await db.user.findUnique({ where: { id } })
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'user not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {}
|
||||
if (role) {
|
||||
const validRoles = ['owner', 'admin', 'sales', 'pm', 'developer', 'finance', 'readonly', 'client', 'member', 'reseller']
|
||||
if (!validRoles.includes(role)) {
|
||||
return NextResponse.json({ error: `invalid role: ${role}` }, { status: 400 })
|
||||
}
|
||||
data.role = role
|
||||
}
|
||||
|
||||
if (Object.keys(data).length === 0) {
|
||||
return NextResponse.json({ error: 'no fields to update' }, { status: 400 })
|
||||
}
|
||||
|
||||
const updated = await db.user.update({ where: { id }, data })
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'admin:ui',
|
||||
action: 'user.update',
|
||||
entity: 'User',
|
||||
entityId: id,
|
||||
before: JSON.stringify({ role: existing.role }),
|
||||
after: JSON.stringify({ role: updated.role }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ user: { id: updated.id, email: updated.email, role: updated.role } })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/aos/users/[id] — hard-delete a user.
|
||||
// Guard rails:
|
||||
// - Cannot delete the env-admin (ADMIN_EMAIL). 403.
|
||||
// - Cannot delete the last owner (prevents lockout). 409.
|
||||
// - Cannot delete yourself (session check). 409.
|
||||
export async function DELETE(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const existing = await db.user.findUnique({ where: { id } })
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'user not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Block deletion of the env-managed admin account.
|
||||
const adminEmail = process.env.ADMIN_EMAIL
|
||||
if (adminEmail && existing.email.toLowerCase() === adminEmail.toLowerCase()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Der Admin-Account (Umgebungsvariable) kann nicht gelöscht werden.' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Block deleting the last owner — prevents lockout.
|
||||
if (existing.role === 'owner') {
|
||||
const ownerCount = await db.user.count({ where: { role: 'owner' } })
|
||||
if (ownerCount <= 1) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Der letzte Owner kann nicht gelöscht werden — mindestens ein Owner muss erhalten bleiben.' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent self-deletion via session check.
|
||||
const { getCurrentUser } = await import('@/lib/session')
|
||||
const me = await getCurrentUser()
|
||||
if (me?.id === id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Sie können sich nicht selbst löschen.' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
await db.user.delete({ where: { id } })
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: me ? `user:${me.email}` : 'admin:ui',
|
||||
action: 'user.delete',
|
||||
entity: 'User',
|
||||
entityId: id,
|
||||
before: JSON.stringify({ email: existing.email, name: existing.name, role: existing.role }),
|
||||
after: JSON.stringify({ deleted: true }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ ok: true, deleted: true, id })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// GET /api/aos/users — list all users (admin management).
|
||||
// Excludes passwordHash + 2FA secrets (never sent to the client).
|
||||
export async function GET() {
|
||||
try {
|
||||
const users = await db.user.findMany({
|
||||
orderBy: { createdAt: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
role: true,
|
||||
companyId: true,
|
||||
emailVerifiedAt: true,
|
||||
twoFactorEnabled: true,
|
||||
twoFactorMethod: true,
|
||||
lastLoginAt: true,
|
||||
createdAt: true,
|
||||
company: { select: { id: true, name: true } },
|
||||
},
|
||||
})
|
||||
return NextResponse.json({ users })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user