e70ae79d-5898-4c55-8ceb-d2c701ce08b4
This commit is contained in:
1 parent
d226dabba3
commit
5855f699f3
53 files changed
+1911
-44
No files matched your search
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
Reference in new issue
Block a user