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:
Z User committed 2026-10-27 21:29:51 +00:00
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()
}
+14 -6
View File
@@ -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
+11 -7
View File
@@ -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))
+17 -5
View File
@@ -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 } })
+26 -11
View File
@@ -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 })
+10 -1
View File
@@ -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 })
+16 -16
View File
@@ -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,
},
})
+12 -5
View File
@@ -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)
+10 -1
View File
@@ -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 })
+11 -1
View File
@@ -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 })
+15 -7
View File
@@ -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' } })
+11 -6
View File
@@ -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.
+10 -5
View File
@@ -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 },
+10 -1
View File
@@ -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 })
+10 -1
View File
@@ -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 })
+19 -14
View File
@@ -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 })
+14 -2
View File
@@ -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',
+14 -2
View File
@@ -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(),
},
+13 -6
View File
@@ -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),
+11 -2
View File
@@ -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' },
+10 -2
View File
@@ -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 })
+11 -5
View File
@@ -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 })