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
151 lines
4.7 KiB
TypeScript
151 lines
4.7 KiB
TypeScript
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 })
|
|
}
|
|
}
|