feat: full CRUD support — companies, deals, projects, expenses, tickets, licenses
API routes (new + modified): - POST /api/aos/crm/companies (create company) - PATCH/DELETE /api/aos/crm/companies/[id] (edit/delete with dependency check) - PATCH/DELETE /api/aos/crm/deals/[id] (edit/delete deal) - POST /api/aos/projects (create project) - PATCH/DELETE /api/aos/projects/[id] (edit/delete with dependency check) - POST /api/aos/expenses (create expense) - PATCH/DELETE /api/aos/expenses/[id] (edit/delete with GoBD lock) - PATCH/DELETE /api/aos/tickets/[id] (edit/delete with message check) - DELETE /api/aos/licenses/[id] (delete only if cancelled + no deps) - NO invoice delete endpoint (GoBD retention — storno only) UI components (new): - DeleteDialog: reusable AlertDialog with dependency list - CompanyFormDialog: create + edit (12 fields) - DealFormDialog: create + edit (replaces CreateDealDialog) - ProjectFormDialog: create + edit - ExpenseFormDialog: create + edit - TicketEditDialog: edit status/priority/category/assignee UI views (modified): - CRM: 'Neue Firma' + 'Neuer Deal' buttons, edit/delete per row - Projects: 'Projekt' button, edit/delete on cards - Expenses: 'Ausgabe' button, edit/delete per row (hidden if DATEV-locked) - Support: edit/delete in inbox header - Licenses: delete button (only if cancelled/revoked/expired) Cascade policy: - Every DELETE counts FK relations, returns 409 with dependency list - Hard delete only when no dependencies exist - GoBD: expenses locked if DATEV-exported or invoiced - Audit log on every create/update/delete
This commit is contained in:
1 parent
27669adc21
commit
aa075762b2
21 files changed
+2618
-40
No files matched your search
@@ -1,8 +1,28 @@
|
||||
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,
|
||||
@@ -56,3 +76,153 @@ export async function GET(
|
||||
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.
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// This is the GoBD-safe path: never silently cascade-delete invoices or
|
||||
// licenses (those have their own retention rules).
|
||||
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 all FK relations on Company (see prisma/schema.prisma).
|
||||
const [
|
||||
invoices,
|
||||
licenses,
|
||||
deals,
|
||||
projects,
|
||||
contracts,
|
||||
expenses,
|
||||
tickets,
|
||||
instances,
|
||||
leads,
|
||||
persons,
|
||||
agencyClients,
|
||||
] = 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 } }),
|
||||
])
|
||||
|
||||
const dependencies: Record<string, number> = {
|
||||
invoices,
|
||||
licenses,
|
||||
deals,
|
||||
projects,
|
||||
contracts,
|
||||
expenses,
|
||||
tickets,
|
||||
instances,
|
||||
leads,
|
||||
persons,
|
||||
agencyClients,
|
||||
}
|
||||
const totalDeps = Object.values(dependencies).reduce((s, n) => s + n, 0)
|
||||
if (totalDeps > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'company has related records — delete or reassign them first',
|
||||
dependencies,
|
||||
total: totalDeps,
|
||||
},
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
// No related records — safe to hard delete.
|
||||
await db.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 }),
|
||||
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,73 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const createCompanySchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
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).optional().default('manual'),
|
||||
tags: z.string().max(500).nullable().optional(),
|
||||
})
|
||||
|
||||
// POST /api/aos/crm/companies — create a new CRM company.
|
||||
// Audit-logged (GoBD). The UI's "Neue Firma" button posts here.
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const parsed = createCompanySchema.safeParse(await req.json().catch(() => ({})))
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
const body = parsed.data
|
||||
|
||||
// Trim website (strip protocol — schema stores bare host).
|
||||
let website = body.website ?? null
|
||||
if (website) {
|
||||
website = website.replace(/^https?:\/\//, '').replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
const company = await db.company.create({
|
||||
data: {
|
||||
name: body.name.trim(),
|
||||
legalForm: body.legalForm ?? null,
|
||||
vatId: body.vatId ?? null,
|
||||
registerCourt: body.registerCourt ?? null,
|
||||
registerNumber: body.registerNumber ?? null,
|
||||
website,
|
||||
email: body.email ?? null,
|
||||
phone: body.phone ?? null,
|
||||
city: body.city ?? null,
|
||||
zip: body.zip ?? null,
|
||||
street: body.street ?? null,
|
||||
source: body.source,
|
||||
tags: body.tags ?? null,
|
||||
},
|
||||
})
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'admin:ui',
|
||||
action: 'crm.company:create',
|
||||
entity: 'Company',
|
||||
entityId: company.id,
|
||||
after: JSON.stringify({ name: company.name, source: company.source }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ company }, { status: 201 })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const updateDealSchema = z.object({
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
valueCents: z.number().int().min(0).optional(),
|
||||
stage: z.enum(['lead', 'qualified', 'proposal', 'negotiation', 'won', 'lost']).optional(),
|
||||
probability: z.number().int().min(0).max(100).optional(),
|
||||
expectedClose: z.string().nullable().optional(),
|
||||
lossReason: z.string().max(500).nullable().optional(),
|
||||
ownerId: z.string().nullable().optional(),
|
||||
})
|
||||
|
||||
// PATCH /api/aos/crm/deals/[id] — update a deal in the pipeline.
|
||||
// Audit-logged (GoBD). Used by the deal edit dialog (title, value, stage,
|
||||
// probability, expected close, loss reason).
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const parsed = updateDealSchema.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.deal.findUnique({ where: { id } })
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'deal not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {}
|
||||
for (const [k, v] of Object.entries(body)) {
|
||||
if (k === 'expectedClose' && typeof v === 'string') {
|
||||
data[k] = new Date(v)
|
||||
} else {
|
||||
data[k] = v
|
||||
}
|
||||
}
|
||||
if (Object.keys(data).length === 0) {
|
||||
return NextResponse.json({ error: 'no fields to update' }, { status: 400 })
|
||||
}
|
||||
|
||||
const updated = await db.deal.update({ where: { id }, data })
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'admin:ui',
|
||||
action: 'crm.deal:update',
|
||||
entity: 'Deal',
|
||||
entityId: id,
|
||||
before: JSON.stringify(existing),
|
||||
after: JSON.stringify(updated),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ deal: updated })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/aos/crm/deals/[id] — delete a deal.
|
||||
// Deals have no downstream FKs (no invoice/contract references in the
|
||||
// schema), so this is a hard delete. Audit-logged for GoBD.
|
||||
// If a deal has been converted into a contract or invoice, the UI should
|
||||
// block the deletion — the contract/invoice still references the company,
|
||||
// not the deal, so we don't enforce it here at the schema level.
|
||||
export async function DELETE(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const existing = await db.deal.findUnique({ where: { id } })
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'deal not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
await db.deal.delete({ where: { id } })
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'admin:ui',
|
||||
action: 'crm.deal:delete',
|
||||
entity: 'Deal',
|
||||
entityId: id,
|
||||
before: JSON.stringify(existing),
|
||||
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,145 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const updateExpenseSchema = z.object({
|
||||
categoryId: z.string().max(50).optional(),
|
||||
vendor: z.string().max(200).nullable().optional(),
|
||||
description: z.string().min(1).max(8000).optional(),
|
||||
amountCents: z.number().int().min(0).optional(),
|
||||
taxBp: z.number().int().min(0).max(10000).optional(),
|
||||
grossCents: z.number().int().min(0).optional(),
|
||||
billable: z.boolean().optional(),
|
||||
markupPct: z.number().int().min(0).max(500).optional(),
|
||||
recurring: z.boolean().optional(),
|
||||
recurringInterval: z.enum(['monthly', 'quarterly', 'yearly']).nullable().optional(),
|
||||
status: z.enum(['recorded', 'approved', 'invoiced', 'rejected']).optional(),
|
||||
companyId: z.string().nullable().optional(),
|
||||
projectId: z.string().nullable().optional(),
|
||||
date: z.string().optional(),
|
||||
})
|
||||
|
||||
// PATCH /api/aos/expenses/[id] — update an expense.
|
||||
// Recomputes grossCents from amountCents + taxBp when amount or tax changes.
|
||||
// Audit-logged (GoBD).
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const parsed = updateExpenseSchema.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.expense.findUnique({ where: { id } })
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'expense not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Block edits once the expense has been exported to DATEV (GoBD lock).
|
||||
if (existing.datevExported) {
|
||||
return NextResponse.json(
|
||||
{ error: 'expense is DATEV-exported and locked (GoBD)' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {}
|
||||
for (const [k, v] of Object.entries(body)) {
|
||||
if (k === 'date' && typeof v === 'string') {
|
||||
data[k] = new Date(v)
|
||||
} else {
|
||||
data[k] = v
|
||||
}
|
||||
}
|
||||
if (Object.keys(data).length === 0) {
|
||||
return NextResponse.json({ error: 'no fields to update' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Recompute grossCents if amount or tax changed.
|
||||
const newAmount = typeof data.amountCents === 'number' ? data.amountCents : existing.amountCents
|
||||
const newTaxBp = typeof data.taxBp === 'number' ? data.taxBp : existing.taxBp
|
||||
if (
|
||||
typeof data.amountCents === 'number' ||
|
||||
typeof data.taxBp === 'number'
|
||||
) {
|
||||
data.grossCents = Math.round(newAmount * (1 + newTaxBp / 10000))
|
||||
}
|
||||
|
||||
const updated = await db.expense.update({ where: { id }, data })
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'admin:ui',
|
||||
action: 'expense:update',
|
||||
entity: 'Expense',
|
||||
entityId: id,
|
||||
before: JSON.stringify(existing),
|
||||
after: JSON.stringify(updated),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ expense: updated })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/aos/expenses/[id] — delete an expense.
|
||||
//
|
||||
// Cascade policy:
|
||||
// - DATEV-exported expenses cannot be deleted (GoBD lock). Returns 409.
|
||||
// - Expenses already converted to an invoice cannot be deleted. Returns 409.
|
||||
// - Otherwise hard delete — expenses are typically just bookkeeping drafts
|
||||
// and don't have downstream FKs.
|
||||
export async function DELETE(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const existing = await db.expense.findUnique({ where: { id } })
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'expense not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (existing.datevExported) {
|
||||
return NextResponse.json(
|
||||
{ error: 'expense is DATEV-exported and cannot be deleted (GoBD)' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
if (existing.status === 'invoiced' || existing.convertedInvoiceId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'expense already converted to an invoice — cancel the invoice first' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
await db.expense.delete({ where: { id } })
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'admin:ui',
|
||||
action: 'expense:delete',
|
||||
entity: 'Expense',
|
||||
entityId: id,
|
||||
before: JSON.stringify(existing),
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -60,3 +61,73 @@ export async function GET() {
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
const createExpenseSchema = z.object({
|
||||
categoryId: z.enum(['reise', 'software', 'hardware', 'fremdleistung', 'buero', 'marketing']),
|
||||
vendor: z.string().max(200).nullable().optional(),
|
||||
description: z.string().min(1).max(8000),
|
||||
amountCents: z.number().int().min(0),
|
||||
taxBp: z.number().int().min(0).max(10000).optional().default(1900),
|
||||
date: z.string().optional(),
|
||||
companyId: z.string().nullable().optional(),
|
||||
projectId: z.string().nullable().optional(),
|
||||
billable: z.boolean().optional().default(false),
|
||||
markupPct: z.number().int().min(0).max(500).optional().default(0),
|
||||
recurring: z.boolean().optional().default(false),
|
||||
recurringInterval: z.enum(['monthly', 'quarterly', 'yearly']).nullable().optional(),
|
||||
})
|
||||
|
||||
// POST /api/aos/expenses — create a new expense.
|
||||
// Computes grossCents = amountCents * (1 + taxBp/10000). Audit-logged (GoBD).
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const parsed = createExpenseSchema.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 grossCents = Math.round(body.amountCents * (1 + body.taxBp / 10000))
|
||||
|
||||
// gapless expense number: AUS-YYYY-NNNN
|
||||
const year = new Date().getFullYear()
|
||||
const count = await db.expense.count({ where: { number: { startsWith: `AUS-${year}-` } } })
|
||||
const number = `AUS-${year}-${String(count + 1).padStart(4, '0')}`
|
||||
|
||||
const expense = await db.expense.create({
|
||||
data: {
|
||||
number,
|
||||
categoryId: body.categoryId,
|
||||
vendor: body.vendor ?? null,
|
||||
description: body.description,
|
||||
amountCents: body.amountCents,
|
||||
taxBp: body.taxBp,
|
||||
grossCents,
|
||||
date: body.date ? new Date(body.date) : new Date(),
|
||||
companyId: body.companyId ?? null,
|
||||
projectId: body.projectId ?? null,
|
||||
billable: body.billable,
|
||||
markupPct: body.markupPct,
|
||||
recurring: body.recurring,
|
||||
recurringInterval: body.recurring ? body.recurringInterval ?? null : null,
|
||||
status: 'recorded',
|
||||
},
|
||||
})
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'admin:ui',
|
||||
action: 'expense:create',
|
||||
entity: 'Expense',
|
||||
entityId: expense.id,
|
||||
after: JSON.stringify({ number, description: expense.description, grossCents }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ expense }, { status: 201 })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// DELETE /api/aos/licenses/[id] — hard-delete a license.
|
||||
//
|
||||
// Cascade policy: a license may only be hard-deleted when:
|
||||
// - it has NO activations, AND
|
||||
// - it has NO linked instance (License 1:1 Instance), AND
|
||||
// - it has NO child module licenses (parentId === this id), AND
|
||||
// - it is in status 'cancelled' (cancelled licenses that have reached their
|
||||
// effective date are safe to remove from active bookkeeping).
|
||||
//
|
||||
// Otherwise the call returns 409 with the dependency counts.
|
||||
//
|
||||
// GoBD: if the license has been issued, activated, or used to bill a
|
||||
// customer, do NOT delete — cancel (cancel/route.ts) keeps the audit record.
|
||||
// This endpoint exists for genuine cleanup (e.g. demo licenses that never
|
||||
// started a subscription, or test fixtures).
|
||||
export async function DELETE(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const existing = await db.license.findUnique({ where: { id } })
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'license not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const [activations, instances, modules] = await Promise.all([
|
||||
db.licenseActivation.count({ where: { licenseId: id } }),
|
||||
db.instance.count({ where: { licenseId: id } }),
|
||||
db.license.count({ where: { parentId: id } }),
|
||||
])
|
||||
|
||||
const dependencies: Record<string, number> = { activations, instances, modules }
|
||||
const totalDeps = Object.values(dependencies).reduce((s, n) => s + n, 0)
|
||||
|
||||
if (totalDeps > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'license has related records — cancel the license instead of deleting it',
|
||||
dependencies,
|
||||
total: totalDeps,
|
||||
},
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
// Only allow deletion when the license is already cancelled or never started.
|
||||
// Active/trial/suspended licenses must go through the cancel flow.
|
||||
if (
|
||||
existing.status !== 'cancelled' &&
|
||||
existing.status !== 'revoked' &&
|
||||
existing.status !== 'expired'
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: `license is ${existing.status} — cancel before deleting (GoBD retention)` },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
await db.license.delete({ where: { id } })
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'admin:ui',
|
||||
action: 'license:delete',
|
||||
entity: 'License',
|
||||
entityId: id,
|
||||
before: JSON.stringify({ key: existing.key, status: existing.status, product: existing.product }),
|
||||
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,132 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const updateProjectSchema = z.object({
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
type: z.enum(['website', 'shop', 'app', 'retainer']).optional(),
|
||||
status: z.enum(['planning', 'active', 'review', 'done', 'on-hold', 'cancelled']).optional(),
|
||||
budgetType: z.enum(['fixed', 'time-materials', 'retainer']).optional(),
|
||||
budgetHours: z.number().int().min(0).nullable().optional(),
|
||||
spentHours: z.number().int().min(0).optional(),
|
||||
fixedPriceCents: z.number().int().min(0).nullable().optional(),
|
||||
startDate: z.string().nullable().optional(),
|
||||
dueDate: z.string().nullable().optional(),
|
||||
progress: z.number().int().min(0).max(100).optional(),
|
||||
})
|
||||
|
||||
// PATCH /api/aos/projects/[id] — update project master data.
|
||||
// Audit-logged (GoBD). Used by the project edit dialog (name, type,
|
||||
// budget, dates, progress).
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const parsed = updateProjectSchema.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.project.findUnique({ where: { id } })
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'project not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {}
|
||||
for (const [k, v] of Object.entries(body)) {
|
||||
if ((k === 'startDate' || k === 'dueDate') && typeof v === 'string') {
|
||||
data[k] = new Date(v)
|
||||
} else {
|
||||
data[k] = v
|
||||
}
|
||||
}
|
||||
if (Object.keys(data).length === 0) {
|
||||
return NextResponse.json({ error: 'no fields to update' }, { status: 400 })
|
||||
}
|
||||
|
||||
const updated = await db.project.update({ where: { id }, data })
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'admin:ui',
|
||||
action: 'project:update',
|
||||
entity: 'Project',
|
||||
entityId: id,
|
||||
before: JSON.stringify(existing),
|
||||
after: JSON.stringify(updated),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ project: updated })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/aos/projects/[id] — delete a project.
|
||||
//
|
||||
// Cascade policy: a project with tasks, time entries, or portal approvals
|
||||
// cannot be hard-deleted (those records belong to the agency's audit trail
|
||||
// and may have been invoiced). When the project has any of these
|
||||
// dependencies, the call returns 409 with the dependency counts so the
|
||||
// UI can show a dialog explaining what blocks the deletion.
|
||||
//
|
||||
// GoBD: time entries already invoiced are never deleted through this path.
|
||||
export async function DELETE(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const existing = await db.project.findUnique({ where: { id } })
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'project not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const [tasks, timeEntries, approvals, invoices] = await Promise.all([
|
||||
db.task.count({ where: { projectId: id } }),
|
||||
db.timeEntry.count({ where: { projectId: id } }),
|
||||
db.portalApproval.count({ where: { projectId: id } }),
|
||||
db.invoice.count({ where: { projectId: id } }),
|
||||
])
|
||||
|
||||
const dependencies: Record<string, number> = { tasks, timeEntries, approvals, invoices }
|
||||
const totalDeps = Object.values(dependencies).reduce((s, n) => s + n, 0)
|
||||
if (totalDeps > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'project has related records — delete or reassign them first',
|
||||
dependencies,
|
||||
total: totalDeps,
|
||||
},
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
await db.project.delete({ where: { id } })
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'admin:ui',
|
||||
action: 'project:delete',
|
||||
entity: 'Project',
|
||||
entityId: id,
|
||||
before: JSON.stringify(existing),
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -23,3 +24,61 @@ export async function GET() {
|
||||
|
||||
return NextResponse.json({ projects, minutesThisWeek })
|
||||
}
|
||||
|
||||
const createProjectSchema = z.object({
|
||||
companyId: z.string().min(1),
|
||||
name: z.string().min(1).max(200),
|
||||
type: z.enum(['website', 'shop', 'app', 'retainer']).default('website'),
|
||||
budgetType: z.enum(['fixed', 'time-materials', 'retainer']).default('fixed'),
|
||||
budgetHours: z.number().int().min(0).nullable().optional(),
|
||||
fixedPriceCents: z.number().int().min(0).nullable().optional(),
|
||||
startDate: z.string().nullable().optional(),
|
||||
dueDate: z.string().nullable().optional(),
|
||||
})
|
||||
|
||||
// POST /api/aos/projects — create a new project.
|
||||
// Audit-logged (GoBD). The UI's "Projekt" button posts here.
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const parsed = createProjectSchema.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 company = await db.company.findUnique({ where: { id: body.companyId } })
|
||||
if (!company) {
|
||||
return NextResponse.json({ error: 'company not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const project = await db.project.create({
|
||||
data: {
|
||||
companyId: body.companyId,
|
||||
name: body.name.trim(),
|
||||
type: body.type,
|
||||
status: 'planning',
|
||||
budgetType: body.budgetType,
|
||||
budgetHours: body.budgetHours ?? null,
|
||||
fixedPriceCents: body.fixedPriceCents ?? null,
|
||||
startDate: body.startDate ? new Date(body.startDate) : null,
|
||||
dueDate: body.dueDate ? new Date(body.dueDate) : null,
|
||||
},
|
||||
})
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'admin:ui',
|
||||
action: 'project:create',
|
||||
entity: 'Project',
|
||||
entityId: project.id,
|
||||
after: JSON.stringify({ name: project.name, companyId: body.companyId, type: project.type }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ project }, { status: 201 })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
try {
|
||||
const stats: Record<string, number> = {}
|
||||
const stats: Record<string, number | string> = {}
|
||||
|
||||
// --- 1. Modules — activate all core modules ---
|
||||
const moduleDefs = [
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const updateTicketSchema = z.object({
|
||||
status: z.enum(['open', 'pending', 'resolved', 'closed']).optional(),
|
||||
priority: z.enum(['low', 'normal', 'high', 'urgent']).optional(),
|
||||
assignee: z.string().max(200).nullable().optional(),
|
||||
categoryId: z.string().max(50).optional(),
|
||||
subject: z.string().min(1).max(200).optional(),
|
||||
})
|
||||
|
||||
// PATCH /api/aos/tickets/[id] — update a ticket.
|
||||
// Side effects:
|
||||
// - status='resolved' → set slaResolvedAt (if not already set)
|
||||
// - status='closed' → set slaResolvedAt (if not already set) — close implies resolution
|
||||
// - First non-empty assignee → set slaRespondedAt (if not already set) — assignment counts as response
|
||||
//
|
||||
// Audit-logged (GoBD).
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const parsed = updateTicketSchema.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.ticket.findUnique({ where: { id } })
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'ticket not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {}
|
||||
for (const [k, v] of Object.entries(body)) {
|
||||
data[k] = v
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
|
||||
// SLA resolution timestamp.
|
||||
if (
|
||||
(data.status === 'resolved' || data.status === 'closed') &&
|
||||
!existing.slaResolvedAt
|
||||
) {
|
||||
data.slaResolvedAt = now
|
||||
}
|
||||
|
||||
// SLA response timestamp — assignment counts as first response.
|
||||
if (
|
||||
typeof data.assignee === 'string' &&
|
||||
data.assignee.trim().length > 0 &&
|
||||
!existing.slaRespondedAt
|
||||
) {
|
||||
data.slaRespondedAt = now
|
||||
}
|
||||
|
||||
if (Object.keys(data).length === 0) {
|
||||
return NextResponse.json({ error: 'no fields to update' }, { status: 400 })
|
||||
}
|
||||
|
||||
const updated = await db.ticket.update({ where: { id }, data })
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'admin:ui',
|
||||
action: 'ticket:update',
|
||||
entity: 'Ticket',
|
||||
entityId: id,
|
||||
before: JSON.stringify(existing),
|
||||
after: JSON.stringify(updated),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ ticket: updated })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/aos/tickets/[id] — delete a ticket.
|
||||
//
|
||||
// Cascade policy: tickets with messages cannot be hard-deleted (the
|
||||
// conversation is part of the audit trail). Tickets in status 'closed' or
|
||||
// 'resolved' with NO messages can be deleted. Tickets that have been
|
||||
// escalated (escalatedToAdmin=true) cannot be deleted.
|
||||
//
|
||||
// GoBD: never silently cascade-delete ticket messages.
|
||||
export async function DELETE(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const existing = await db.ticket.findUnique({
|
||||
where: { id },
|
||||
include: { messages: { select: { id: true } } },
|
||||
})
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'ticket not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Block if escalated to admin — those records stay for compliance.
|
||||
if (existing.escalatedToAdmin) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ticket escalated to admin — cannot be deleted' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
// Block if ticket has messages — conversation must be retained.
|
||||
if (existing.messages.length > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'ticket has messages — close the ticket instead of deleting it',
|
||||
messageCount: existing.messages.length,
|
||||
},
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
// Only allow deletion of empty (no message) tickets in resolved/closed state.
|
||||
// (Open/pending tickets with no messages were created in error and may be cleaned up.)
|
||||
await db.ticket.delete({ where: { id } })
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'admin:ui',
|
||||
action: 'ticket:delete',
|
||||
entity: 'Ticket',
|
||||
entityId: id,
|
||||
before: JSON.stringify(existing),
|
||||
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 })
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user