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 = {} 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 = { 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 }) } }