Merge remote-tracking branch 'origin/main'
# Conflicts: # db/custom.db # src/components/FuseButton.tsx # src/components/SlideCommit.tsx
This commit is contained in:
commit
58521ca03a
23 files changed
+2622
-26
No files matched your search
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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>`
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user