e70ae79d-5898-4c55-8ceb-d2c701ce08b4

This commit is contained in:
Z User committed 2026-10-26 14:18:39 +00:00
1 parent d226dabba3
commit 5855f699f3
53 files changed
+1911 -44

No files matched your search

+35 -4
View File
@@ -3,8 +3,39 @@ import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
// GET /api/aos/audit — audit log (hash-chain evidence)
export async function GET() {
const entries = await db.auditLog.findMany({ orderBy: { createdAt: 'desc' }, take: 50 })
return NextResponse.json({ entries })
// GET /api/aos/audit — full audit log (hash-chain evidence)
export async function GET(req: Request) {
const { searchParams } = new URL(req.url)
const limit = Math.min(Number(searchParams.get('limit') ?? 100), 500)
const action = searchParams.get('action')
const where = action ? { action: { contains: action } } : {}
const entries = await db.auditLog.findMany({
where,
orderBy: { createdAt: 'desc' },
take: limit,
})
const parsed = entries.map((e) => ({
...e,
before: e.before ? safeParse(e.before) : null,
after: e.after ? safeParse(e.after) : null,
}))
const stats = {
total: parsed.length,
byAction: groupBy(parsed, (e) => e.action.split(':')[0]),
byActor: groupBy(parsed, (e) => e.actor ?? 'system'),
}
return NextResponse.json({ entries: parsed, stats })
}
function safeParse(s: string) {
try { return JSON.parse(s) } catch { return s }
}
function groupBy<T>(arr: T[], key: (t: T) => string) {
const m: Record<string, number> = {}
for (const x of arr) { const k = key(x); m[k] = (m[k] ?? 0) + 1 }
return m
}
@@ -0,0 +1,53 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
// GET /api/aos/crm/companies/[id] — full company detail with timeline
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
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 })
}
+47
View File
@@ -0,0 +1,47 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
// POST /api/aos/crm/deals — create a new deal in the pipeline
export async function POST(req: Request) {
const body = (await req.json()) as {
companyId: string
title: string
valueCents: number
stage?: string
probability?: number
expectedClose?: string | null
}
if (!body.companyId) {
return NextResponse.json({ error: 'companyId required' }, { status: 400 })
}
if (!body.title?.trim()) {
return NextResponse.json({ error: 'title required' }, { status: 400 })
}
const deal = await db.deal.create({
data: {
companyId: body.companyId,
title: body.title.trim(),
valueCents: Math.max(0, Math.round(body.valueCents)),
stage: body.stage ?? 'lead',
probability: body.probability ?? 10,
expectedClose: body.expectedClose ? new Date(body.expectedClose) : null,
},
})
await db.auditLog.create({
data: {
actor: 'sven@nordlicht.digital',
action: 'crm.deal:create',
entity: 'Deal',
entityId: deal.id,
after: JSON.stringify({ title: deal.title, stage: deal.stage, valueCents: deal.valueCents }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ deal })
}
@@ -0,0 +1,46 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
// PATCH /api/aos/finance/[id]/finalize — finalize a draft invoice:
// assigns gapless number if needed, marks ZUGFeRD/XRechnung, sets status=sent, locks.
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const body = (await req.json().catch(() => ({}))) as { zugferd?: boolean; xrechnung?: boolean }
const inv = await db.invoice.findUnique({ where: { id } })
if (!inv) return NextResponse.json({ error: 'not found' }, { status: 404 })
if (inv.status !== 'draft') return NextResponse.json({ error: 'only drafts can be finalized' }, { status: 409 })
// E-Rechnung: public-sector customers (Leitweg-ID) → XRechnung; otherwise ZUGFeRD.
const wantZugferd = body.zugferd ?? true
const wantXrechnung = body.xrechnung ?? false
const updated = await db.invoice.update({
where: { id },
data: {
status: 'sent',
zugferd: wantZugferd,
xrechnung: wantXrechnung,
issueDate: new Date(),
},
})
await db.auditLog.create({
data: {
actor: 'nora@nordlicht.digital',
action: 'invoice.finalize',
entity: 'Invoice',
entityId: id,
before: JSON.stringify({ status: 'draft', zugferd: false, xrechnung: false }),
after: JSON.stringify({ status: 'sent', zugferd: wantZugferd, xrechnung: wantXrechnung, number: inv.number }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ invoice: updated })
}
+83
View File
@@ -0,0 +1,83 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
// POST /api/aos/leads/convert
// Convert an approved lead into a CRM Company + Deal.
// This is the "lead → deal" domain-event flow from the PRD (§5.4 events, §6.2 hand-off).
export async function POST(req: Request) {
const { leadId } = (await req.json()) as { leadId: string }
const lead = await db.lead.findUnique({ where: { id: leadId } })
if (!lead) return NextResponse.json({ error: 'lead not found' }, { status: 404 })
if (lead.status === 'converted') return NextResponse.json({ error: 'already converted' }, { status: 409 })
// 1. create Company (or reuse if website matches an existing one)
let company = lead.website
? await db.company.findFirst({ where: { website: lead.website } })
: null
if (!company) {
company = await db.company.create({
data: {
name: lead.companyName,
website: lead.website,
city: lead.city,
source: 'lead-finder',
tags: [lead.scoreLabel, lead.branch].filter(Boolean).join(','),
},
})
}
// 2. create a Deal in the "lead" stage seeded from the audit evidence
const labelMap: Record<string, string> = {
'new-site': 'Neue Website',
redesign: 'Website-Redesign',
seo: 'SEO-Optimierung',
shop: 'Online-Shop Aufbau',
maintenance: 'Wartungsvertrag',
accessibility: 'Barrierefreiheit (BFSG)',
}
const title = lead.scoreLabel ? `${labelMap[lead.scoreLabel] ?? 'Projekt'} — ${lead.companyName}` : lead.companyName
const deal = await db.deal.create({
data: {
companyId: company.id,
title,
valueCents: 0, // to be qualified
probability: 10,
stage: 'lead',
},
})
// 3. mark lead as converted + link
await db.lead.update({
where: { id: leadId },
data: { status: 'converted', companyId: company.id, reviewedAt: new Date() },
})
// 4. audit entries (domain event crm.deal.created + leads.converted)
await db.auditLog.createMany({
data: [
{
actor: 'sven@nordlicht.digital',
action: 'leads.convert:crm',
entity: 'Lead',
entityId: leadId,
after: JSON.stringify({ status: 'converted', companyId: company.id, dealId: deal.id }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
{
actor: 'sven@nordlicht.digital',
action: 'crm.deal:create',
entity: 'Deal',
entityId: deal.id,
after: JSON.stringify({ title, stage: 'lead', sourceLeadId: leadId }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
],
})
return NextResponse.json({ company, deal, leadId })
}
@@ -0,0 +1,37 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
// POST /api/aos/projects/[id]/tasks — add a task to a project
export async function POST(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const body = (await req.json()) as { title: string; priority?: string; assignee?: string; estimateMin?: number }
const task = await db.task.create({
data: {
projectId: id,
title: body.title,
priority: body.priority ?? 'medium',
assignee: body.assignee ?? null,
estimateMin: body.estimateMin ?? null,
status: 'todo',
},
})
await db.auditLog.create({
data: {
actor: 'aylin@nordlicht.digital',
action: 'projects.task:create',
entity: 'Task',
entityId: task.id,
after: JSON.stringify({ title: task.title, projectId: id }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ task })
}
@@ -0,0 +1,43 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
// POST /api/aos/projects/[id]/time — log a time entry on a project
export async function POST(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const body = (await req.json()) as { description: string; minutes: number; billable?: boolean; rateCentsPerHour?: number }
const entry = await db.timeEntry.create({
data: {
projectId: id,
description: body.description,
minutes: Math.max(1, Math.round(body.minutes)),
billable: body.billable ?? true,
rateCentsPerHour: body.rateCentsPerHour ?? null,
date: new Date(),
},
})
// bump spentHours on the project (denormalized for the board)
await db.project.update({
where: { id },
data: { spentHours: { increment: Math.round(entry.minutes / 60) } },
})
await db.auditLog.create({
data: {
actor: 'tom@nordlicht.digital',
action: 'projects.time:log',
entity: 'TimeEntry',
entityId: entry.id,
after: JSON.stringify({ minutes: entry.minutes, projectId: id }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ entry })
}
+35
View File
@@ -0,0 +1,35 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
// GET /api/aos/search?q=...
// Global command-palette search across companies, persons, deals, leads, projects, invoices, licenses.
export async function GET(req: Request) {
const { searchParams } = new URL(req.url)
const q = (searchParams.get('q') ?? '').trim().toLowerCase()
if (q.length < 2) return NextResponse.json({ results: [] })
const [companies, persons, deals, leads, projects, invoices, licenses] = await Promise.all([
db.company.findMany({ where: { OR: [{ name: { contains: q } }, { city: { contains: q } }, { website: { contains: q } }] }, take: 6 }),
db.person.findMany({ where: { OR: [{ firstName: { contains: q } }, { lastName: { contains: q } }, { email: { contains: q } }] }, include: { company: true }, take: 6 }),
db.deal.findMany({ where: { title: { contains: q } }, include: { company: true }, take: 6 }),
db.lead.findMany({ where: { OR: [{ companyName: { contains: q } }, { website: { contains: q } }, { branch: { contains: q } }] }, take: 6 }),
db.project.findMany({ where: { name: { contains: q } }, include: { company: true }, take: 6 }),
db.invoice.findMany({ where: { OR: [{ number: { contains: q } }] }, include: { company: true }, take: 6 }),
db.license.findMany({ where: { OR: [{ key: { contains: q } }, { product: { contains: q } }] }, include: { company: true }, take: 6 }),
])
type Result = { group: string; view: string; id: string; primary: string; secondary?: string; tertiary?: string }
const results: Result[] = []
for (const c of companies) results.push({ group: 'Firmen', view: 'crm', id: c.id, primary: c.name, secondary: c.city ?? undefined, tertiary: c.legalForm ?? undefined })
for (const p of persons) results.push({ group: 'Ansprechpartner', view: 'crm', id: p.id, primary: `${p.firstName} ${p.lastName}`, secondary: p.company?.name, tertiary: p.role ?? undefined })
for (const d of deals) results.push({ group: 'Deals', view: 'crm', id: d.id, primary: d.title, secondary: d.company.name, tertiary: d.stage })
for (const l of leads) results.push({ group: 'Leads', view: 'leads', id: l.id, primary: l.companyName, secondary: l.website ?? l.city ?? undefined, tertiary: `Score ${l.score}` })
for (const p of projects) results.push({ group: 'Projekte', view: 'projects', id: p.id, primary: p.name, secondary: p.company.name, tertiary: p.status })
for (const i of invoices) results.push({ group: 'Rechnungen', view: 'finance', id: i.id, primary: i.number, secondary: i.company.name, tertiary: i.status })
for (const l of licenses) results.push({ group: 'Lizenzen', view: 'licenses', id: l.id, primary: l.key, secondary: l.product, tertiary: l.company.name })
return NextResponse.json({ results, query: q })
}