Merge remote-tracking branch 'origin/main'

# Conflicts:
#	db/custom.db
#	src/components/FuseButton.tsx
#	src/components/SlideCommit.tsx
This commit is contained in:
Z User committed 2026-10-28 17:41:22 +00:00
commit 58521ca03a
23 files changed
+2622 -26

No files matched your search

+103
View File
@@ -0,0 +1,103 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { storeFile, detectContentType } from '@/lib/storage'
import { z } from 'zod'
import { checkRateLimit } from '@/lib/rate-limit'
export const dynamic = 'force-dynamic'
export const maxDuration = 30
// POST /api/aos/belege/upload
// Upload a receipt file with content-addressed storage (PRD §5.5 + §5.7 Belege).
// Accepts multipart/form-data with file + metadata.
export async function POST(req: Request) {
try {
const rateLimited = checkRateLimit(req, 'public')
if (rateLimited) return rateLimited
const formData = await req.formData()
const file = formData.get('file') as File | null
const type = formData.get('type') as string | null
const vendor = formData.get('vendor') as string | null
const invoiceNumber = formData.get('invoiceNumber') as string | null
const amountCentsStr = formData.get('amountCents') as string | null
const bookingDateStr = formData.get('bookingDate') as string | null
if (!file) {
return NextResponse.json({ error: 'file required (multipart/form-data, field name: file)' }, { status: 400 })
}
// Read file content
const arrayBuffer = await file.arrayBuffer()
const buffer = Buffer.from(arrayBuffer)
// Detect content type from magic bytes
const detectedType = detectContentType(buffer)
const fileName = file.name
const fileType = file.type || detectedType
const fileSizeBytes = buffer.length
// Store file (content-addressed, dedup, atomic write)
const stored = await storeFile(buffer, fileType)
// Check for duplicate (same hash = same file already registered as Beleg)
const existing = await db.beleg.findUnique({ where: { fileHash: stored.hash } })
if (existing) {
return NextResponse.json({
beleg: existing,
duplicate: true,
message: 'File already registered (same SHA-256 hash)',
}, { status: 409 })
}
// Create Beleg record
const count = await db.beleg.count()
const number = `BEL-2026-${String(count + 1).padStart(4, '0')}`
const belegType = type || 'sonstiges'
// GoBD retention: 8 years for invoices, 6 for business letters
const retentionUntil = new Date()
retentionUntil.setFullYear(retentionUntil.getFullYear() + (belegType.includes('rechnung') ? 8 : 6))
const beleg = await db.beleg.create({
data: {
number,
type: belegType,
fileHash: stored.hash,
fileName,
fileType,
fileSizeBytes,
vendor: vendor ?? null,
invoiceNumber: invoiceNumber ?? null,
amountCents: amountCentsStr ? parseInt(amountCentsStr) : null,
bookingDate: bookingDateStr ? new Date(bookingDateStr) : null,
retentionUntil,
},
})
await db.auditLog.create({
data: {
actor: 'nora@nordlicht.digital',
action: 'beleg.upload',
entity: 'Beleg',
entityId: beleg.id,
after: JSON.stringify({ number, type: belegType, fileName, hash: stored.hash.slice(0, 16) + '…', sizeBytes: fileSizeBytes }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({
beleg,
storage: {
hash: stored.hash,
sizeBytes: stored.size,
contentType: stored.contentType,
detectedType,
deduped: stored.size !== fileSizeBytes, // if size differs, existing file was returned
},
})
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
+165
View File
@@ -0,0 +1,165 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
export const maxDuration = 15
// GET /api/aos/invoices/[id]/pdf
// Generates a real PDF invoice (German layout) using HTML → print-ready format.
// Returns the invoice as an HTML page that browsers can "Print to PDF".
// In production, this would use Typst (PRD §5.2) for server-side PDF generation.
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const invoice = await db.invoice.findUnique({
where: { id },
include: { company: true, lines: true },
})
if (!invoice) {
return NextResponse.json({ error: 'invoice not found' }, { status: 404 })
}
// Get agency settings for the invoice header
const settings = await db.setting.findMany()
const getSetting = (key: string) => settings.find((s) => s.key === key)?.value ?? ''
const agencyName = getSetting('company.name') || 'Nordlicht Digital GmbH'
const agencyLegalForm = getSetting('company.legalForm') || 'GmbH'
const agencyVatId = getSetting('company.vatId') || ''
const agencyRegisterCourt = getSetting('company.registerCourt') || ''
const agencyRegisterNumber = getSetting('company.registerNumber') || ''
const agencyCity = getSetting('company.city') || ''
const agencyBank = getSetting('company.bank') || ''
const agencyBic = getSetting('company.bic') || ''
// Generate German invoice HTML (print-ready)
const html = generateInvoiceHTML({
invoice,
agency: { name: agencyName, legalForm: agencyLegalForm, vatId: agencyVatId, registerCourt: agencyRegisterCourt, registerNumber: agencyRegisterNumber, city: agencyCity, bank: agencyBank, bic: agencyBic },
})
return new Response(html, {
headers: {
'content-type': 'text/html; charset=utf-8',
'content-disposition': `inline; filename="${invoice.number}.html"`,
'cache-control': 'no-store',
},
})
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
function generateInvoiceHTML({ invoice, agency }: {
invoice: {
number: string; status: string; type: string; issueDate: Date; dueDate: Date;
netCents: number; taxBp: number; grossCents: number;
zugferd: boolean; xrechnung: boolean;
company: { name: string; legalForm: string | null; city: string | null; vatId: string | null };
lines: { description: string; quantity: number; unitCents: number; totalCents: number }[];
};
agency: { name: string; legalForm: string; vatId: string; registerCourt: string; registerNumber: string; city: string; bank: string; bic: string };
}): string {
const fmtDate = (d: Date) => d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
const fmtEuro = (cents: number) => (cents / 100).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €'
const taxPct = (invoice.taxBp / 100).toFixed(0)
return `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Rechnung ${invoice.number}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, 'Segoe UI', Roboto, Arial, sans-serif; color: #111; line-height: 1.6; padding: 40px; max-width: 800px; margin: 0 auto; }
.header { display: flex; justify-content: space-between; margin-bottom: 40px; }
.agency h1 { font-size: 18px; font-weight: 700; }
.agency p { font-size: 12px; color: #666; }
.invoice-meta { text-align: right; }
.invoice-meta h2 { font-size: 24px; font-weight: 700; margin-bottom: 8px; }
.invoice-meta p { font-size: 12px; color: #666; }
.customer { margin-bottom: 30px; }
.customer h3 { font-size: 14px; font-weight: 600; margin-bottom: 4px; }
.customer p { font-size: 13px; color: #333; }
table { width: 100%; border-collapse: collapse; margin-bottom: 30px; }
th { text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: #999; border-bottom: 2px solid #111; padding: 8px; }
td { font-size: 13px; padding: 10px 8px; border-bottom: 1px solid #eee; }
.right { text-align: right; }
.totals { width: 300px; margin-left: auto; }
.totals td { border: none; padding: 6px 8px; font-size: 13px; }
.totals .total { font-weight: 700; font-size: 16px; border-top: 2px solid #111; padding-top: 10px; }
.footer { margin-top: 40px; padding-top: 20px; border-top: 1px solid #eee; font-size: 11px; color: #999; }
.footer p { margin-bottom: 4px; }
@media print { body { padding: 0; } }
</style>
</head>
<body>
<div class="header">
<div class="agency">
<h1>${agency.name}</h1>
<p>${agency.city}</p>
<p>USt-IdNr.: ${agency.vatId}</p>
<p>${agency.registerCourt} ${agency.registerNumber}</p>
</div>
<div class="invoice-meta">
<h2>RECHNUNG</h2>
<p>Nr.: ${invoice.number}</p>
<p>Datum: ${fmtDate(invoice.issueDate)}</p>
<p>Fällig: ${fmtDate(invoice.dueDate)}</p>
${invoice.zugferd ? '<p style="font-weight:600">✓ ZUGFeRD</p>' : ''}
${invoice.xrechnung ? '<p style="font-weight:600">✓ XRechnung</p>' : ''}
</div>
</div>
<div class="customer">
<h3>Rechnungsempfänger</h3>
<p><strong>${invoice.company.name}</strong></p>
${invoice.company.legalForm ? `<p>${invoice.company.legalForm}</p>` : ''}
${invoice.company.city ? `<p>${invoice.company.city}</p>` : ''}
${invoice.company.vatId ? `<p>USt-IdNr.: ${invoice.company.vatId}</p>` : ''}
</div>
<table>
<thead>
<tr>
<th>Beschreibung</th>
<th class="right">Menge</th>
<th class="right">Einzelpreis</th>
<th class="right">Gesamt</th>
</tr>
</thead>
<tbody>
${invoice.lines.map((l) => `
<tr>
<td>${l.description}</td>
<td class="right">${l.quantity}</td>
<td class="right">${fmtEuro(l.unitCents)}</td>
<td class="right">${fmtEuro(l.totalCents)}</td>
</tr>`).join('')}
</tbody>
</table>
<table class="totals">
<tr><td>Netto</td><td class="right">${fmtEuro(invoice.netCents)}</td></tr>
<tr><td>USt (${taxPct}%)</td><td class="right">${fmtEuro(invoice.grossCents - invoice.netCents)}</td></tr>
<tr class="total"><td>Gesamt</td><td class="right">${fmtEuro(invoice.grossCents)}</td></tr>
</table>
<div class="footer">
<p><strong>${agency.name}</strong> · ${agency.city}</p>
<p>Bank: ${agency.bank}</p>
<p>BIC: ${agency.bic}</p>
<p>USt-IdNr.: ${agency.vatId}</p>
<p>Register: ${agency.registerCourt} ${agency.registerNumber}</p>
<p style="margin-top:10px">Rechnung Nr. ${invoice.number} vom ${fmtDate(invoice.issueDate)}. Fällig bis ${fmtDate(invoice.dueDate)}.</p>
</div>
</body>
</html>`
}
+159
View File
@@ -0,0 +1,159 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { createHmac } from 'crypto'
import { checkRateLimit } from '@/lib/rate-limit'
export const dynamic = 'force-dynamic'
// POST /api/aos/webhooks/github
// GitHub/Gitea webhook receiver (PRD §6.3: "GitHub/Gitea webhooks (commits and PRs linked to tasks)")
//
// Receives push events and PR events from GitHub, extracts commit messages,
// and links them to tasks by matching "#TASK-XXXX" references in commit messages.
//
// Security: verifies X-Hub-Signature-256 HMAC using GITHUB_WEBHOOK_SECRET env var.
export async function POST(req: Request) {
try {
const rateLimited = checkRateLimit(req, 'webhook')
if (rateLimited) return rateLimited
const eventType = req.headers.get('x-github-event') ?? ''
const signature = req.headers.get('x-hub-signature-256') ?? ''
const body = await req.text()
// Verify HMAC signature if secret is configured
const secret = process.env.GITHUB_WEBHOOK_SECRET
if (secret) {
const expected = 'sha256=' + createHmac('sha256', secret).update(body).digest('hex')
if (signature !== expected) {
return NextResponse.json({ error: 'invalid signature' }, { status: 401 })
}
}
const payload = JSON.parse(body)
const repo = payload.repository?.full_name ?? 'unknown'
if (eventType === 'push') {
// Push event: extract commits and link to tasks
const commits = payload.commits ?? []
const linkedTasks: { commit: string; message: string; taskId: string; taskTitle: string }[] = []
for (const commit of commits) {
const message = commit.message ?? ''
const sha = (commit.id ?? '').slice(0, 7)
// Match task references in commit message: #TKT-2026-XXXX or task:<id>
const taskMatches = message.match(/(?:#TKT-2026-|task:)([a-z0-9]+)/gi) ?? []
for (const match of taskMatches) {
const taskId = match.replace(/#TKT-2026-|task:/i, '').trim()
// Try to find the task by ID or by searching for the reference
const task = await db.task.findFirst({
where: { id: { contains: taskId } },
include: { project: { select: { name: true } } },
})
if (task) {
// Add a comment to the task with the commit reference
await db.taskComment.create({
data: {
taskId: task.id,
author: `GitHub (${payload.pusher?.name ?? 'unknown'})`,
body: `Commit ${sha} in ${repo}:\n${message.split('\n')[0]}\n\nLink: ${payload.repository?.html_url}/commit/${commit.id}`,
},
})
linkedTasks.push({
commit: sha,
message: message.split('\n')[0],
taskId: task.id,
taskTitle: task.title,
})
}
}
}
await db.auditLog.create({
data: {
actor: 'system:github-webhook',
action: 'webhook.github:push',
entity: 'Task',
entityId: 'batch',
after: JSON.stringify({ repo, commits: commits.length, linked: linkedTasks.length }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({
ok: true,
event: 'push',
repo,
commits: commits.length,
linkedTasks: linkedTasks.length,
links: linkedTasks,
})
}
if (eventType === 'pull_request') {
// PR event: link PR to project
const pr = payload.pull_request ?? {}
const action = payload.action ?? 'opened'
const prTitle = pr.title ?? 'Unknown PR'
const prNumber = pr.number ?? 0
const prUrl = pr.html_url ?? ''
const branch = pr.head?.ref ?? ''
// Try to match project by branch name (e.g. "feature/intranet-portal" → Intranet-Portal)
const project = await db.project.findFirst({
where: { name: { contains: branch.replace(/feature\/|fix\//g, '').replace(/-/g, ' ') } },
})
if (project) {
await db.auditLog.create({
data: {
actor: 'system:github-webhook',
action: 'webhook.github:pr',
entity: 'Project',
entityId: project.id,
after: JSON.stringify({ prNumber, prTitle, prUrl, action, branch, repo }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({
ok: true,
event: 'pull_request',
action,
repo,
prNumber,
prTitle,
branch,
linkedProject: project.name,
})
}
return NextResponse.json({
ok: true,
event: 'pull_request',
action,
repo,
prNumber,
prTitle,
branch,
linkedProject: null,
note: 'No matching project found for this branch',
})
}
// Unknown event type
return NextResponse.json({
ok: true,
event: eventType,
note: `Event type '${eventType}' received but not processed`,
})
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
+10
View File
@@ -48,6 +48,15 @@ export const metadata: Metadata = {
},
};
// PRD §5.6: suppressHydrationWarning on html + body because we use:
// 1. next-themes (class on <html> changes client-side)
// 2. Zustand persist (view/locale loaded from localStorage client-side)
// 3. Radix UI generates IDs with useId() which differ between server and client
// (this is expected and the warning is cosmetic — Radix components still work correctly)
// The alternative would be to delay rendering until after hydration (mount gate),
// but that causes a flash of unstyled content. suppressHydrationWarning is the
// official next-themes + Radix pattern.
export default function RootLayout({
children,
}: Readonly<{
@@ -57,6 +66,7 @@ export default function RootLayout({
<html lang="de" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background text-foreground`}
suppressHydrationWarning
>
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
{children}