- 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.
229 lines
9.2 KiB
TypeScript
Executable File
229 lines
9.2 KiB
TypeScript
Executable File
import { NextResponse } from 'next/server'
|
|
import { z } from 'zod'
|
|
import { db } from '@/lib/db'
|
|
|
|
export const dynamic = 'force-dynamic'
|
|
|
|
// Allowed fields for an inline update. All optional — only the keys
|
|
// actually present in the body are touched. This makes the endpoint
|
|
// safe for partial edits (e.g. only changing the email).
|
|
const updateCompanySchema = z.object({
|
|
name: z.string().min(1).max(200).optional(),
|
|
legalForm: z.string().max(50).nullable().optional(),
|
|
vatId: z.string().max(50).nullable().optional(),
|
|
registerCourt: z.string().max(100).nullable().optional(),
|
|
registerNumber: z.string().max(100).nullable().optional(),
|
|
website: z.string().max(200).nullable().optional(),
|
|
email: z.string().max(200).nullable().optional(),
|
|
phone: z.string().max(100).nullable().optional(),
|
|
city: z.string().max(100).nullable().optional(),
|
|
zip: z.string().max(20).nullable().optional(),
|
|
street: z.string().max(200).nullable().optional(),
|
|
source: z.string().max(100).nullable().optional(),
|
|
tags: z.string().max(500).nullable().optional(),
|
|
})
|
|
|
|
// GET /api/aos/crm/companies/[id] — full company detail with timeline
|
|
export async function GET(
|
|
_req: Request,
|
|
{ params }: { params: Promise<{ id: string }> }
|
|
) {
|
|
try {
|
|
const { id } = await params
|
|
const company = await db.company.findUnique({
|
|
where: { id },
|
|
include: {
|
|
persons: true,
|
|
deals: { orderBy: { updatedAt: 'desc' } },
|
|
projects: { orderBy: { updatedAt: 'desc' } },
|
|
invoices: { orderBy: { issueDate: 'desc' }, take: 10 },
|
|
licenses: { include: { activations: true } },
|
|
leads: true,
|
|
},
|
|
})
|
|
if (!company) return NextResponse.json({ error: 'not found' }, { status: 404 })
|
|
|
|
// audit entries touching this company's deals/invoices/licenses
|
|
const audit = await db.auditLog.findMany({
|
|
where: {
|
|
OR: [
|
|
{ entity: 'Company', entityId: id },
|
|
],
|
|
},
|
|
orderBy: { createdAt: 'desc' },
|
|
take: 20,
|
|
})
|
|
|
|
// build a synthetic timeline from deals + invoices
|
|
const timeline = [
|
|
...company.deals.map((d) => ({ kind: 'deal', date: d.updatedAt, title: d.title, meta: d.stage, valueCents: d.valueCents })),
|
|
...company.invoices.map((i) => ({ kind: 'invoice', date: i.issueDate, title: i.number, meta: i.status, valueCents: i.grossCents })),
|
|
...company.projects.map((p) => ({ kind: 'project', date: p.updatedAt, title: p.name, meta: p.status, valueCents: p.fixedPriceCents })),
|
|
].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
|
|
|
// DSGVO consent record (synthetic from source + tags)
|
|
const consent = {
|
|
legalBasis: 'Art. 6 Abs. 1 lit. f (berechtigtes Interesse, § 13 DSGVO)',
|
|
purpose: 'Geschäftsanbahnung, Vertragsdurchführung',
|
|
source: company.source ?? 'manuell erfasst',
|
|
recordedAt: company.createdAt,
|
|
evidence: company.source === 'lead-finder' ? 'Leads-Finder-Audit + Impressum-Veröffentlichung' : 'Direkte Kontaktaufnahme',
|
|
}
|
|
|
|
return NextResponse.json({ company, timeline, audit, consent })
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : 'internal server error'
|
|
return NextResponse.json({ error: msg }, { status: 500 })
|
|
}
|
|
}
|
|
|
|
// PATCH /api/aos/crm/companies/[id] — update company master data
|
|
// Audit-logged (GoBD). Agency-only fields (isAgency, agencyStatus,
|
|
// parentAgencyId, agencyDiscountPct) are intentionally NOT editable here —
|
|
// those go through /api/aos/agency/[id]/approve|reject.
|
|
export async function PATCH(
|
|
req: Request,
|
|
{ params }: { params: Promise<{ id: string }> }
|
|
) {
|
|
try {
|
|
const { id } = await params
|
|
const parsed = updateCompanySchema.safeParse(await req.json().catch(() => ({})))
|
|
if (!parsed.success) {
|
|
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
|
|
}
|
|
const body = parsed.data
|
|
|
|
const existing = await db.company.findUnique({ where: { id } })
|
|
if (!existing) {
|
|
return NextResponse.json({ error: 'company not found' }, { status: 404 })
|
|
}
|
|
|
|
// Only write keys that are actually present — avoids clobbering
|
|
// fields the UI didn't submit.
|
|
const data: Record<string, unknown> = {}
|
|
for (const [k, v] of Object.entries(body)) {
|
|
data[k] = v
|
|
}
|
|
if (Object.keys(data).length === 0) {
|
|
return NextResponse.json({ error: 'no fields to update' }, { status: 400 })
|
|
}
|
|
|
|
const updated = await db.company.update({
|
|
where: { id },
|
|
data,
|
|
})
|
|
|
|
await db.auditLog.create({
|
|
data: {
|
|
actor: 'admin:ui',
|
|
action: 'crm.company:update',
|
|
entity: 'Company',
|
|
entityId: id,
|
|
before: JSON.stringify(existing),
|
|
after: JSON.stringify(updated),
|
|
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
|
},
|
|
})
|
|
|
|
return NextResponse.json({ company: updated })
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : 'internal server error'
|
|
return NextResponse.json({ error: msg }, { status: 500 })
|
|
}
|
|
}
|
|
|
|
// DELETE /api/aos/crm/companies/[id] — delete company.
|
|
//
|
|
// 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.
|
|
//
|
|
// 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 }> }
|
|
) {
|
|
try {
|
|
const { id } = await params
|
|
const existing = await db.company.findUnique({ where: { id } })
|
|
if (!existing) {
|
|
return NextResponse.json({ error: 'company not found' }, { status: 404 })
|
|
}
|
|
|
|
// 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.expense.count({ where: { companyId: id } }),
|
|
db.contract.count({ where: { companyId: id } }),
|
|
])
|
|
|
|
const accountingDeps: Record<string, number> = {
|
|
invoices,
|
|
licenses,
|
|
expenses,
|
|
contracts,
|
|
}
|
|
const totalAccounting = Object.values(accountingDeps).reduce((s, n) => s + n, 0)
|
|
if (totalAccounting > 0) {
|
|
return NextResponse.json(
|
|
{
|
|
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 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: {
|
|
actor: 'admin:ui',
|
|
action: 'crm.company:delete',
|
|
entity: 'Company',
|
|
entityId: id,
|
|
before: JSON.stringify(existing),
|
|
after: JSON.stringify({ deleted: true, cascaded: 'deals,projects,tickets,leads,persons,instances' }),
|
|
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 })
|
|
}
|
|
}
|