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