feat: Zod validation on all API inputs (PRD §5.2)
26 files with Zod schemas, 27 safeParse calls. All POST/PATCH handlers now validate input: - Missing required fields → 400 with structured error - Invalid types (string vs number) → 400 - Out-of-range values (probability >100, negative amounts) → 400 - Valid data still works (behavior preserved) Pattern: schema.safeParse(await req.json()) → 400 on failure
This commit is contained in:
1 parent
3d56770669
commit
2614ce4e3d
25 files changed
+316
-116
No files matched your search
@@ -1,8 +1,13 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const matchTransactionSchema = z.object({
|
||||
invoiceId: z.string().min(1),
|
||||
})
|
||||
|
||||
// POST /api/aos/accounting/bank/[id]/match — match a bank transaction to an invoice.
|
||||
// Body: { invoiceId } — marks the transaction matched + invoice paid.
|
||||
export async function POST(
|
||||
@@ -11,7 +16,11 @@ export async function POST(
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const { invoiceId } = (await req.json()) as { invoiceId: string }
|
||||
const parsed = matchTransactionSchema.safeParse(await req.json())
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
const { invoiceId } = parsed.data
|
||||
|
||||
const tx = await db.bankTransaction.findUnique({ where: { id } })
|
||||
if (!tx) return NextResponse.json({ error: 'transaction not found' }, { status: 404 })
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
import { createHash, randomBytes } from 'crypto'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const maxDuration = 30
|
||||
|
||||
const importBankSchema = z.object({
|
||||
xml: z.string().min(1),
|
||||
})
|
||||
|
||||
interface ParsedTx {
|
||||
amountCents: number
|
||||
bookingDate: Date
|
||||
@@ -25,8 +30,11 @@ export async function POST(req: Request) {
|
||||
|
||||
let xmlString: string
|
||||
if (contentType.includes('application/json')) {
|
||||
const body = (await req.json()) as { xml?: string }
|
||||
xmlString = body.xml ?? ''
|
||||
const parsed = importBankSchema.safeParse(await req.json())
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
xmlString = parsed.data.xml
|
||||
} else {
|
||||
xmlString = await req.text()
|
||||
}
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const createAnlageSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
category: z.string().optional().default('hardware'),
|
||||
purchaseDate: z.string().min(1),
|
||||
purchaseCostCents: z.number().int().positive(),
|
||||
usefulLifeYears: z.number().int().min(1).optional(),
|
||||
afaMethod: z.string().optional(),
|
||||
})
|
||||
|
||||
// GET /api/aos/anlagen — list asset register with AfA
|
||||
export async function GET() {
|
||||
try {
|
||||
@@ -26,13 +36,11 @@ export async function GET() {
|
||||
// POST /api/aos/anlagen — create an asset with AfA computation
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { name, category, purchaseDate, purchaseCostCents, usefulLifeYears, afaMethod } = (await req.json()) as {
|
||||
name: string; category: string; purchaseDate: string; purchaseCostCents: number
|
||||
usefulLifeYears?: number; afaMethod?: string
|
||||
const parsed = createAnlageSchema.safeParse(await req.json())
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!name?.trim()) return NextResponse.json({ error: 'name required' }, { status: 400 })
|
||||
if (!purchaseCostCents || purchaseCostCents <= 0) return NextResponse.json({ error: 'purchaseCostCents must be > 0' }, { status: 400 })
|
||||
const { name, category, purchaseDate, purchaseCostCents, usefulLifeYears, afaMethod } = parsed.data
|
||||
|
||||
const cost = Math.round(purchaseCostCents)
|
||||
const years = usefulLifeYears ?? 3
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
import { createHash, randomBytes } from 'crypto'
|
||||
|
||||
@@ -22,6 +23,12 @@ const ALL_SCOPES = [
|
||||
'mcp:write',
|
||||
]
|
||||
|
||||
const createTokenSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
scopes: z.array(z.string()).min(1),
|
||||
expiresAt: z.string().optional(),
|
||||
})
|
||||
|
||||
// GET /api/aos/api-tokens — list tokens (without revealing the secret)
|
||||
export async function GET() {
|
||||
const tokens = await db.apiToken.findMany({ orderBy: { createdAt: 'desc' } })
|
||||
@@ -44,14 +51,11 @@ export async function GET() {
|
||||
// POST /api/aos/api-tokens — create a new token. Returns the plaintext token ONCE.
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { name, scopes, expiresAt } = (await req.json()) as {
|
||||
name: string
|
||||
scopes: string[]
|
||||
expiresAt?: string
|
||||
const parsed = createTokenSchema.safeParse(await req.json())
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!name?.trim()) return NextResponse.json({ error: 'name required' }, { status: 400 })
|
||||
if (!scopes?.length) return NextResponse.json({ error: 'at least one scope required' }, { status: 400 })
|
||||
const { name, scopes, expiresAt } = parsed.data
|
||||
|
||||
// validate scopes
|
||||
const invalid = scopes.filter((s) => !ALL_SCOPES.includes(s))
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
import { createHash } from 'crypto'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const createBelegSchema = z.object({
|
||||
fileName: z.string().min(1),
|
||||
fileType: z.string().optional().default(''),
|
||||
fileSizeBytes: z.number().int().min(0).optional().default(0),
|
||||
fileContent: z.string().optional(),
|
||||
type: z.string().min(1),
|
||||
vendor: z.string().optional(),
|
||||
invoiceNumber: z.string().optional(),
|
||||
amountCents: z.number().int().optional(),
|
||||
bookingDate: z.string().optional(),
|
||||
})
|
||||
|
||||
// GET /api/aos/belege — list receipts/documents
|
||||
export async function GET() {
|
||||
try {
|
||||
@@ -23,12 +36,11 @@ export async function GET() {
|
||||
// POST /api/aos/belege — register a receipt (content-addressed)
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { fileName, fileType, fileSizeBytes, fileContent, type, vendor, invoiceNumber, amountCents, bookingDate } = (await req.json()) as {
|
||||
fileName: string; fileType: string; fileSizeBytes: number; fileContent: string // base64
|
||||
type: string; vendor?: string; invoiceNumber?: string; amountCents?: number; bookingDate?: string
|
||||
const parsed = createBelegSchema.safeParse(await req.json())
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!fileName?.trim()) return NextResponse.json({ error: 'fileName required' }, { status: 400 })
|
||||
const { fileName, fileType, fileSizeBytes, fileContent, type, vendor, invoiceNumber, amountCents, bookingDate } = parsed.data
|
||||
|
||||
// Compute SHA-256 hash of file content (content-addressed storage)
|
||||
const fileHash = createHash('sha256').update(fileContent ?? fileName).digest('hex')
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
import { createHash, randomBytes } from 'crypto'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const createJahresabschlussSchema = z.object({
|
||||
jahr: z.number().int().min(1900).max(9999),
|
||||
})
|
||||
|
||||
// POST /api/aos/buchhaltung/jahresabschluss
|
||||
// GoBD Jahresabschluss: creates an immutable archive with SHA-256 hash chain.
|
||||
// Links to previous year's archive (hash chain).
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { jahr } = (await req.json()) as { jahr: number }
|
||||
|
||||
if (!jahr) return NextResponse.json({ error: 'jahr required' }, { status: 400 })
|
||||
const parsed = createJahresabschlussSchema.safeParse(await req.json())
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
const { jahr } = parsed.data
|
||||
|
||||
// Check if archive already exists for this year
|
||||
const existing = await db.jahresarchiv.findFirst({ where: { jahr } })
|
||||
|
||||
@@ -1,9 +1,26 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
import { createHash } from 'crypto'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const createBuchungSchema = z.object({
|
||||
belegdatum: z.string().min(1),
|
||||
sollKontoNummer: z.string().min(1),
|
||||
habenKontoNummer: z.string().min(1),
|
||||
betragCents: z.number().int(),
|
||||
buchungstext: z.string().min(1),
|
||||
belegNummer: z.string().optional(),
|
||||
steuerBp: z.number().int().min(0).optional(),
|
||||
reverseCharge: z.boolean().optional(),
|
||||
par19UStG: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const festschreibenSchema = z.object({
|
||||
buchungId: z.string().min(1),
|
||||
})
|
||||
|
||||
// GET /api/aos/buchhaltung — SKR04 Konten + Journal + Saldenliste + EÜR + Jahresabschluss
|
||||
export async function GET() {
|
||||
try {
|
||||
@@ -69,17 +86,11 @@ export async function GET() {
|
||||
// POST /api/aos/buchhaltung/buchung — create a Buchungssatz (Soll an Haben)
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = (await req.json()) as {
|
||||
belegdatum: string
|
||||
sollKontoNummer: string
|
||||
habenKontoNummer: string
|
||||
betragCents: number
|
||||
buchungstext: string
|
||||
belegNummer?: string
|
||||
steuerBp?: number
|
||||
reverseCharge?: boolean
|
||||
par19UStG?: boolean
|
||||
const parsed = createBuchungSchema.safeParse(await req.json())
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
const body = parsed.data
|
||||
|
||||
const sollKonto = await db.konto.findUnique({ where: { nummer: body.sollKontoNummer } })
|
||||
const habenKonto = await db.konto.findUnique({ where: { nummer: body.habenKontoNummer } })
|
||||
@@ -120,7 +131,11 @@ export async function POST(req: Request) {
|
||||
// PATCH — Festschreibung (GoBD: make immutable)
|
||||
export async function PATCH(req: Request) {
|
||||
try {
|
||||
const { buchungId } = (await req.json()) as { buchungId: string }
|
||||
const parsed = festschreibenSchema.safeParse(await req.json())
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
const { buchungId } = parsed.data
|
||||
const buchung = await db.buchungssatz.findUnique({ where: { id: buchungId } })
|
||||
if (!buchung) return NextResponse.json({ error: 'not found' }, { status: 404 })
|
||||
if (buchung.festgeschrieben) return NextResponse.json({ error: 'already festgeschrieben' }, { status: 409 })
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
import { randomBytes, createHash } from 'crypto'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const signContractSchema = z.object({
|
||||
signedBy: z.string().optional(),
|
||||
})
|
||||
|
||||
// Extract the real client IP from the request (behind Caddy/proxy).
|
||||
function getClientIp(req: Request): string {
|
||||
const headers = new Headers(req.headers)
|
||||
@@ -31,7 +36,11 @@ export async function PATCH(
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const body = (await req.json().catch(() => ({}))) as { signedBy?: string }
|
||||
const parsed = signContractSchema.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 contract = await db.contract.findUnique({ where: { id } })
|
||||
if (!contract) return NextResponse.json({ error: 'not found' }, { status: 404 })
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const createDealSchema = z.object({
|
||||
companyId: z.string().min(1),
|
||||
title: z.string().min(1).max(200),
|
||||
valueCents: z.number().int().min(0),
|
||||
stage: z.string().optional().default('lead'),
|
||||
probability: z.number().int().min(0).max(100).optional().default(10),
|
||||
expectedClose: z.string().nullable().optional(),
|
||||
})
|
||||
|
||||
// POST /api/aos/crm/deals — create a new deal in the pipeline
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
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 parsed = createDealSchema.safeParse(await req.json())
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
const body = parsed.data
|
||||
|
||||
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,
|
||||
stage: body.stage,
|
||||
probability: body.probability,
|
||||
expectedClose: body.expectedClose ? new Date(body.expectedClose) : null,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const maxDuration = 30
|
||||
|
||||
const importSchema = z.object({
|
||||
csv: z.string().min(1),
|
||||
mapping: z.record(z.string(), z.number()),
|
||||
})
|
||||
|
||||
// POST /api/aos/crm/import
|
||||
// Import companies + persons from a CSV string.
|
||||
// Body: { csv: string, mapping: { name: number, vatId: number, ... } }
|
||||
@@ -13,13 +19,14 @@ export const maxDuration = 30
|
||||
// Duplicate detection: skips companies with the same name or website.
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { csv, mapping } = (await req.json()) as {
|
||||
csv: string
|
||||
mapping: Record<string, number>
|
||||
const parsed = importSchema.safeParse(await req.json())
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
const { csv, mapping } = parsed.data
|
||||
|
||||
if (!csv?.trim()) return NextResponse.json({ error: 'csv data required' }, { status: 400 })
|
||||
if (!mapping?.name && mapping.name !== 0) return NextResponse.json({ error: 'mapping.name required' }, { status: 400 })
|
||||
if (!csv.trim()) return NextResponse.json({ error: 'csv data required' }, { status: 400 })
|
||||
if (mapping.name === undefined) return NextResponse.json({ error: 'mapping.name required' }, { status: 400 })
|
||||
|
||||
// Parse CSV (simple RFC 4180 parser — handles quoted fields with commas)
|
||||
const lines = parseCSV(csv)
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const acceptEstimateSchema = z.object({
|
||||
acceptedBy: z.string().optional(),
|
||||
})
|
||||
|
||||
// PATCH /api/aos/estimates/[id]/accept — customer accepts the quote.
|
||||
// Domain event: creates a Project (from the quote) + updates Deal stage → won.
|
||||
// (PRD §5.4 events, §6.5 "accepted quote seeds project budget")
|
||||
@@ -11,7 +16,11 @@ export async function PATCH(
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const body = (await req.json().catch(() => ({}))) as { acceptedBy?: string }
|
||||
const parsed = acceptEstimateSchema.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 estimate = await db.estimate.findUnique({ where: { id }, include: { lines: true } })
|
||||
if (!estimate) return NextResponse.json({ error: 'not found' }, { status: 404 })
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const finalizeInvoiceSchema = z.object({
|
||||
zugferd: z.boolean().optional(),
|
||||
xrechnung: z.boolean().optional(),
|
||||
})
|
||||
|
||||
// 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(
|
||||
@@ -10,7 +16,11 @@ export async function PATCH(
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const body = (await req.json().catch(() => ({}))) as { zugferd?: boolean; xrechnung?: boolean }
|
||||
const parsed = finalizeInvoiceSchema.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 inv = await db.invoice.findUnique({ where: { id } })
|
||||
if (!inv) return NextResponse.json({ error: 'not found' }, { status: 404 })
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const createKassenbuchSchema = z.object({
|
||||
date: z.string().min(1),
|
||||
type: z.enum(['einnahme', 'ausgabe']),
|
||||
description: z.string().min(1),
|
||||
amountCents: z.number().int().positive(),
|
||||
taxBp: z.number().int().min(0).optional(),
|
||||
taxFree: z.boolean().optional(),
|
||||
belegId: z.string().optional(),
|
||||
})
|
||||
|
||||
// GET /api/aos/kassenbuch — list cash book entries (GoBD: unveränderlich)
|
||||
export async function GET() {
|
||||
try {
|
||||
@@ -30,14 +41,11 @@ export async function GET() {
|
||||
// POST /api/aos/kassenbuch — add a cash book entry (§ 146 AO, § 22 UStG)
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { date, type, description, amountCents, taxBp, taxFree, belegId } = (await req.json()) as {
|
||||
date: string; type: 'einnahme' | 'ausgabe'; description: string; amountCents: number
|
||||
taxBp?: number; taxFree?: boolean; belegId?: string
|
||||
const parsed = createKassenbuchSchema.safeParse(await req.json())
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!description?.trim()) return NextResponse.json({ error: 'description required' }, { status: 400 })
|
||||
if (!amountCents || amountCents <= 0) return NextResponse.json({ error: 'amountCents must be > 0' }, { status: 400 })
|
||||
if (!['einnahme', 'ausgabe'].includes(type)) return NextResponse.json({ error: 'type must be einnahme|ausgabe' }, { status: 400 })
|
||||
const { date, type, description, amountCents, taxBp, taxFree, belegId } = parsed.data
|
||||
|
||||
// Compute running balance
|
||||
const lastEntry = await db.kassenbuchEntry.findFirst({ orderBy: { date: 'desc' } })
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
import ZAI from 'z-ai-web-dev-sdk'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const maxDuration = 60
|
||||
|
||||
const generateKbSchema = z.object({
|
||||
topic: z.string().min(1),
|
||||
category: z.string().optional(),
|
||||
audience: z.enum(['internal', 'client', 'public']).optional(),
|
||||
})
|
||||
|
||||
// POST /api/aos/kb/generate
|
||||
// AI-drafts a knowledge-base article from a brief/topic.
|
||||
// Structured output (Zod-validated), markdown content.
|
||||
export async function POST(req: Request) {
|
||||
const { topic, category, audience } = (await req.json()) as {
|
||||
topic: string
|
||||
category?: string
|
||||
audience?: 'internal' | 'client' | 'public'
|
||||
const parsed = generateKbSchema.safeParse(await req.json())
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!topic?.trim()) return NextResponse.json({ error: 'topic required' }, { status: 400 })
|
||||
const { topic, category, audience } = parsed.data
|
||||
|
||||
const sys = `Du bist ein technischer Redakteur einer deutschen Software-Agentur (Nordlicht Digital).
|
||||
Schreibe einen Wissensdatenbank-Artikel in Markdown.
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const updateLeadSchema = z.object({
|
||||
status: z.enum(['pending', 'approved', 'rejected', 'snoozed', 'converted']),
|
||||
reviewReason: z.string().optional(),
|
||||
})
|
||||
|
||||
// PATCH /api/aos/leads/[id] — human review: approve / reject / snooze / convert
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
@@ -10,12 +16,11 @@ export async function PATCH(
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const body = await req.json()
|
||||
const { status, reviewReason } = body as { status: string; reviewReason?: string }
|
||||
|
||||
if (!['pending', 'approved', 'rejected', 'snoozed', 'converted'].includes(status)) {
|
||||
return NextResponse.json({ error: 'invalid status' }, { status: 400 })
|
||||
const parsed = updateLeadSchema.safeParse(await req.json())
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
const { status, reviewReason } = parsed.data
|
||||
|
||||
const lead = await db.lead.update({
|
||||
where: { id },
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const convertLeadSchema = z.object({
|
||||
leadId: z.string().min(1),
|
||||
})
|
||||
|
||||
// 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) {
|
||||
try {
|
||||
const { leadId } = (await req.json()) as { leadId: string }
|
||||
const parsed = convertLeadSchema.safeParse(await req.json())
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
const { leadId } = parsed.data
|
||||
|
||||
const lead = await db.lead.findUnique({ where: { id: leadId } })
|
||||
if (!lead) return NextResponse.json({ error: 'lead not found' }, { status: 404 })
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
import ZAI from 'z-ai-web-dev-sdk'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const maxDuration = 60
|
||||
|
||||
const explainLeadSchema = z.object({
|
||||
leadId: z.string().min(1),
|
||||
})
|
||||
|
||||
// POST /api/aos/leads/explain
|
||||
// Generates an evidence-grounded "why this lead" draft via LLM.
|
||||
// Every claim in the output must reference an evidence id.
|
||||
export async function POST(req: Request) {
|
||||
const { leadId } = (await req.json()) as { leadId: string }
|
||||
const parsed = explainLeadSchema.safeParse(await req.json())
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
const { leadId } = parsed.data
|
||||
|
||||
const lead = await db.lead.findUnique({ where: { id: leadId } })
|
||||
if (!lead) return NextResponse.json({ error: 'lead not found' }, { status: 404 })
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const CENTS = (e: number) => Math.round(e * 100)
|
||||
|
||||
const createPauschaleSchema = z.object({
|
||||
type: z.enum(['homeoffice', 'kilometer', 'verpflegung']),
|
||||
date: z.string().min(1),
|
||||
userId: z.string().optional(),
|
||||
days: z.number().int().min(0).optional(),
|
||||
km: z.number().min(0).optional(),
|
||||
vehicleType: z.string().optional(),
|
||||
tripPurpose: z.string().optional(),
|
||||
hoursAway: z.number().min(0).optional(),
|
||||
overnight: z.boolean().optional(),
|
||||
description: z.string().optional(),
|
||||
projectId: z.string().optional(),
|
||||
})
|
||||
|
||||
// GET /api/aos/pauschalen — list flat-rate allowances
|
||||
export async function GET() {
|
||||
try {
|
||||
@@ -26,21 +41,11 @@ export async function GET() {
|
||||
// POST /api/aos/pauschalen — create a pauschale (auto-computes amount)
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = (await req.json()) as {
|
||||
type: 'homeoffice' | 'kilometer' | 'verpflegung'
|
||||
date: string
|
||||
userId?: string
|
||||
days?: number
|
||||
km?: number
|
||||
vehicleType?: string
|
||||
tripPurpose?: string
|
||||
hoursAway?: number
|
||||
overnight?: boolean
|
||||
description?: string
|
||||
projectId?: string
|
||||
const parsed = createPauschaleSchema.safeParse(await req.json())
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!body.type || !body.date) return NextResponse.json({ error: 'type + date required' }, { status: 400 })
|
||||
const body = parsed.data
|
||||
|
||||
// Auto-compute amount based on German tax law (2026 rates)
|
||||
const rates = getRates()
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
import { createHash } from 'crypto'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const decideApprovalSchema = z.object({
|
||||
decision: z.enum(['approved', 'rejected', 'changes-requested']),
|
||||
decidedBy: z.string().optional(),
|
||||
feedback: z.string().optional(),
|
||||
})
|
||||
|
||||
// Extract the real client IP from the request (behind Caddy/proxy)
|
||||
function getClientIp(req: Request): string {
|
||||
const headers = new Headers(req.headers)
|
||||
@@ -23,11 +30,11 @@ export async function PATCH(
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const body = (await req.json()) as { decision: string; decidedBy?: string; feedback?: string }
|
||||
|
||||
if (!['approved', 'rejected', 'changes-requested'].includes(body.decision)) {
|
||||
return NextResponse.json({ error: 'invalid decision' }, { status: 400 })
|
||||
const parsed = decideApprovalSchema.safeParse(await req.json())
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
const body = parsed.data
|
||||
|
||||
const approval = await db.portalApproval.findUnique({ where: { id } })
|
||||
if (!approval) return NextResponse.json({ error: 'not found' }, { status: 404 })
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const createTaskSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
priority: z.enum(['low', 'medium', 'high', 'urgent']).optional().default('medium'),
|
||||
assignee: z.string().optional(),
|
||||
estimateMin: z.number().int().min(0).optional(),
|
||||
})
|
||||
|
||||
// POST /api/aos/projects/[id]/tasks — add a task to a project
|
||||
export async function POST(
|
||||
req: Request,
|
||||
@@ -10,13 +18,17 @@ export async function POST(
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const body = (await req.json()) as { title: string; priority?: string; assignee?: string; estimateMin?: number }
|
||||
const parsed = createTaskSchema.safeParse(await req.json())
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
const body = parsed.data
|
||||
|
||||
const task = await db.task.create({
|
||||
data: {
|
||||
projectId: id,
|
||||
title: body.title,
|
||||
priority: body.priority ?? 'medium',
|
||||
priority: body.priority,
|
||||
assignee: body.assignee ?? null,
|
||||
estimateMin: body.estimateMin ?? null,
|
||||
status: 'todo',
|
||||
|
||||
@@ -1,22 +1,34 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logTimeSchema = z.object({
|
||||
description: z.string().min(1),
|
||||
minutes: z.number().int().positive(),
|
||||
billable: z.boolean().optional().default(true),
|
||||
rateCentsPerHour: z.number().int().min(0).optional(),
|
||||
})
|
||||
|
||||
// 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 parsed = logTimeSchema.safeParse(await req.json())
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
const body = parsed.data
|
||||
|
||||
const entry = await db.timeEntry.create({
|
||||
data: {
|
||||
projectId: id,
|
||||
description: body.description,
|
||||
minutes: Math.max(1, Math.round(body.minutes)),
|
||||
billable: body.billable ?? true,
|
||||
billable: body.billable,
|
||||
rateCentsPerHour: body.rateCentsPerHour ?? null,
|
||||
date: new Date(),
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import * as net from 'net'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -13,6 +14,14 @@ interface MailTestResult {
|
||||
error?: string
|
||||
}
|
||||
|
||||
const testMailSchema = z.object({
|
||||
smtpHost: z.string().optional(),
|
||||
smtpPort: z.number().int().min(0).max(65535).optional(),
|
||||
imapHost: z.string().optional(),
|
||||
imapPort: z.number().int().min(0).max(65535).optional(),
|
||||
user: z.string().optional(),
|
||||
})
|
||||
|
||||
// Real TCP connection test to an SMTP/IMAP server.
|
||||
// Attempts to open a socket, read the greeting banner, then close.
|
||||
// No fake success — returns the actual connection result or error.
|
||||
@@ -76,13 +85,11 @@ function testConnection(host: string, port: number, timeoutMs = 8000): Promise<M
|
||||
// POST /api/aos/setup/test-mail
|
||||
// Real SMTP + IMAP connection test. No simulation.
|
||||
export async function POST(req: Request) {
|
||||
const body = (await req.json().catch(() => ({}))) as {
|
||||
smtpHost?: string
|
||||
smtpPort?: number
|
||||
imapHost?: string
|
||||
imapPort?: number
|
||||
user?: string
|
||||
const parsed = testMailSchema.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 [smtp, imap] = await Promise.all([
|
||||
testConnection(body.smtpHost ?? '', Number(body.smtpPort) || 0),
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const createCommentSchema = z.object({
|
||||
body: z.string().min(1),
|
||||
author: z.string().optional(),
|
||||
})
|
||||
|
||||
// GET /api/aos/tasks/[id]/comments — list comments on a task
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
@@ -23,8 +29,11 @@ export async function POST(
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const { body, author } = (await req.json()) as { body: string; author?: string }
|
||||
if (!body?.trim()) return NextResponse.json({ error: 'body required' }, { status: 400 })
|
||||
const parsed = createCommentSchema.safeParse(await req.json())
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
const { body, author } = parsed.data
|
||||
|
||||
const comment = await db.taskComment.create({
|
||||
data: { taskId: id, body: body.trim(), author: author ?? 'Max Brandt' },
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const lockWeekSchema = z.object({
|
||||
week: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'week required (YYYY-MM-DD)'),
|
||||
})
|
||||
|
||||
// GET /api/aos/timesheet?week=YYYY-MM-DD
|
||||
// Returns a weekly timesheet grid: projects × days (Mon-Sun)
|
||||
// with time entries per cell + totals + lock status.
|
||||
@@ -111,8 +116,11 @@ export async function GET(req: Request) {
|
||||
// PATCH /api/aos/timesheet — lock all entries in a week (PM approval)
|
||||
export async function PATCH(req: Request) {
|
||||
try {
|
||||
const { week } = (await req.json()) as { week?: string }
|
||||
if (!week) return NextResponse.json({ error: 'week required (YYYY-MM-DD)' }, { status: 400 })
|
||||
const parsed = lockWeekSchema.safeParse(await req.json())
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
const { week } = parsed.data
|
||||
|
||||
const refDate = new Date(week + 'T00:00:00Z')
|
||||
if (isNaN(refDate.getTime())) return NextResponse.json({ error: 'invalid date' }, { status: 400 })
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
import { randomBytes } from 'crypto'
|
||||
|
||||
@@ -14,6 +15,11 @@ const KNOWN_EVENTS = [
|
||||
'test.event',
|
||||
]
|
||||
|
||||
const createWebhookSchema = z.object({
|
||||
url: z.string().min(1).startsWith('https://'),
|
||||
events: z.array(z.string()).min(1),
|
||||
})
|
||||
|
||||
// GET /api/aos/webhooks — list registered webhooks
|
||||
export async function GET() {
|
||||
const webhooks = await db.webhook.findMany({ orderBy: { createdAt: 'desc' } })
|
||||
@@ -37,11 +43,11 @@ export async function GET() {
|
||||
// POST /api/aos/webhooks — register a webhook (generates a real HMAC secret)
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { url, events } = (await req.json()) as { url: string; events: string[] }
|
||||
|
||||
if (!url?.trim()) return NextResponse.json({ error: 'url required' }, { status: 400 })
|
||||
if (!url.startsWith('https://')) return NextResponse.json({ error: 'url must be https' }, { status: 400 })
|
||||
if (!events?.length) return NextResponse.json({ error: 'at least one event required' }, { status: 400 })
|
||||
const parsed = createWebhookSchema.safeParse(await req.json())
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
const { url, events } = parsed.data
|
||||
|
||||
const invalid = events.filter((e) => !KNOWN_EVENTS.includes(e) && e !== '*')
|
||||
if (invalid.length) return NextResponse.json({ error: `invalid events: ${invalid.join(', ')}`, valid: KNOWN_EVENTS }, { status: 400 })
|
||||
|
||||
Reference in new issue
Block a user