diff --git a/src/app/api/aos/data-management/export/route.ts b/src/app/api/aos/data-management/export/route.ts new file mode 100644 index 0000000..9906775 --- /dev/null +++ b/src/app/api/aos/data-management/export/route.ts @@ -0,0 +1,136 @@ +import { NextResponse } from 'next/server' +import { db } from '@/lib/db' +import { createHash } from 'crypto' + +export const dynamic = 'force-dynamic' + +// GET /api/aos/data-management/export?format=json|csv&scope=all|company: +// DSGVO Art. 15 (right of access) + Art. 20 (data portability). +// Returns all data (or a specific company's data) as JSON or CSV, +// with a SHA-256 checksum for integrity verification. +export async function GET(req: Request) { + try { + const { searchParams } = new URL(req.url) + const format = searchParams.get('format') ?? 'json' + const scope = searchParams.get('scope') ?? 'all' + + let companyFilter: string | null = null + if (scope.startsWith('company:')) { + companyFilter = scope.split(':')[1]! + } + + // Fetch all data (or company-scoped) + const companies = await db.company.findMany({ + where: companyFilter ? { id: companyFilter } : undefined, + include: { persons: true, deals: true, projects: true, invoices: true, licenses: true, leads: true }, + }) + + const auditLogs = await db.auditLog.findMany({ + where: companyFilter + ? { OR: [{ entity: 'Company', entityId: companyFilter }] } + : undefined, + orderBy: { createdAt: 'desc' }, + take: 500, + }) + + const tickets = await db.ticket.findMany({ + where: companyFilter ? { companyId: companyFilter } : undefined, + include: { messages: true }, + }) + + const contracts = await db.contract.findMany({ + where: companyFilter ? { companyId: companyFilter } : undefined, + }) + + const expenses = await db.expense.findMany({ + where: companyFilter ? { companyId: companyFilter } : undefined, + }) + + const portalApprovals = companyFilter + ? await db.portalApproval.findMany({ + where: { project: { companyId: companyFilter } }, + }) + : await db.portalApproval.findMany() + + const exportData = { + metadata: { + exportedAt: new Date().toISOString(), + scope: scope, + format: format, + version: '1.0.0', + legalBasis: companyFilter + ? 'DSGVO Art. 15 (Auskunftsrecht) + Art. 20 (Datenübertragbarkeit)' + : 'GoBD Jahresarchiv (Vollständiger Datenexport)', + agency: 'Nordlicht Digital GmbH', + }, + companies: companies.map((c) => ({ + id: c.id, + name: c.name, + legalForm: c.legalForm, + vatId: c.vatId, + website: c.website, + city: c.city, + source: c.source, + createdAt: c.createdAt, + persons: c.persons.map((p) => ({ firstName: p.firstName, lastName: p.lastName, email: p.email, role: p.role })), + deals: c.deals.map((d) => ({ title: d.title, stage: d.stage, valueCents: d.valueCents, probability: d.probability })), + projects: c.projects.map((p) => ({ name: p.name, type: p.type, status: p.status, progress: p.progress })), + invoices: c.invoices.map((i) => ({ number: i.number, status: i.status, grossCents: i.grossCents, issueDate: i.issueDate })), + licenses: c.licenses.map((l) => ({ product: l.product, plan: l.plan, status: l.status })), + })), + tickets: tickets.map((t) => ({ + number: t.number, + subject: t.subject, + status: t.status, + priority: t.priority, + messages: t.messages.map((m) => ({ from: m.fromName, fromType: m.fromType, body: m.body, createdAt: m.createdAt })), + })), + contracts: contracts.map((c) => ({ number: c.number, type: c.type, title: c.title, status: c.status, valueCents: c.valueCents })), + expenses: expenses.map((e) => ({ number: e.number, description: e.description, amountCents: e.amountCents, date: e.date, billable: e.billable })), + portalApprovals: portalApprovals.map((a) => ({ title: a.title, status: a.status, decidedAt: a.decidedAt, decidedBy: a.decidedBy })), + auditLog: auditLogs.map((a) => ({ action: a.action, entity: a.entity, actor: a.actor, createdAt: a.createdAt })), + } + + // Compute SHA-256 checksum of the export + const jsonStr = JSON.stringify(exportData) + const checksum = createHash('sha256').update(jsonStr).digest('hex') + + // Audit the export + await db.auditLog.create({ + data: { + actor: 'max@nordlicht.digital', + action: 'data.export:' + (companyFilter ? 'company' : 'full'), + entity: 'DataExport', + entityId: checksum.slice(0, 16), + after: JSON.stringify({ format, scope, checksum, sizeBytes: jsonStr.length, recordCount: exportData.companies.length + exportData.tickets.length + exportData.contracts.length }), + hash: 'sha256:' + checksum, + }, + }) + + if (format === 'csv') { + // Simple CSV export (companies only for CSV — full export is JSON) + const headers = ['id', 'name', 'legalForm', 'vatId', 'city', 'website', 'createdAt'] + const rows = companies.map((c) => [c.id, c.name, c.legalForm ?? '', c.vatId ?? '', c.city ?? '', c.website ?? '', c.createdAt.toISOString()]) + const csv = [headers.join(','), ...rows.map((r) => r.map((c) => `"${String(c).replace(/"/g, '""')}"`).join(','))].join('\n') + return new Response(csv, { + headers: { + 'content-type': 'text/csv; charset=utf-8', + 'content-disposition': `attachment; filename="aos-export-${scope}-${new Date().toISOString().slice(0, 10)}.csv"`, + 'x-aos-checksum': `sha256=${checksum}`, + }, + }) + } + + // JSON export with checksum in header + return new Response(JSON.stringify(exportData, null, 2), { + headers: { + 'content-type': 'application/json; charset=utf-8', + 'content-disposition': `attachment; filename="aos-export-${scope}-${new Date().toISOString().slice(0, 10)}.json"`, + 'x-aos-checksum': `sha256=${checksum}`, + }, + }) + } catch (err) { + const msg = err instanceof Error ? err.message : 'internal server error' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} diff --git a/src/app/api/aos/data-management/route.ts b/src/app/api/aos/data-management/route.ts new file mode 100644 index 0000000..077e1f2 --- /dev/null +++ b/src/app/api/aos/data-management/route.ts @@ -0,0 +1,67 @@ +import { NextResponse } from 'next/server' +import { db } from '@/lib/db' +import { createHash } from 'crypto' + +export const dynamic = 'force-dynamic' + +// GET /api/aos/data-management — overview of data inventory + retention status +export async function GET() { + try { + const [ + companies, persons, deals, leads, projects, tasks, timeEntries, + invoices, licenses, assets, contracts, expenses, tickets, articles, + auditLogs, bankTx, vatReturns, apiTokens, webhooks, portalApprovals, + ] = await Promise.all([ + db.company.count(), db.person.count(), db.deal.count(), db.lead.count(), + db.project.count(), db.task.count(), db.timeEntry.count(), + db.invoice.count(), db.license.count(), db.asset.count(), + db.contract.count(), db.expense.count(), db.ticket.count(), db.article.count(), + db.auditLog.count(), db.bankTransaction.count(), db.vatReturn.count(), + db.apiToken.count({ where: { revokedAt: null } }), db.webhook.count({ where: { active: true } }), + db.portalApproval.count(), + ]) + + // Data inventory by category (DSGVO Art. 30 — records of processing) + const inventory = [ + { category: 'Stammdaten (Kunden)', models: ['Company', 'Person'], count: companies + persons, retentionYears: 6, legalBasis: 'Art. 6 Abs. 1 lit. b (Vertrag)' }, + { category: 'Vertriebsdaten', models: ['Deal', 'Lead'], count: deals + leads, retentionYears: 6, legalBasis: 'Art. 6 Abs. 1 lit. f (berechtigtes Interesse)' }, + { category: 'Projektdaten', models: ['Project', 'Task', 'TimeEntry'], count: projects + tasks + timeEntries, retentionYears: 6, legalBasis: 'Art. 6 Abs. 1 lit. b (Vertrag)' }, + { category: 'Finanzdaten', models: ['Invoice', 'Expense', 'BankTransaction', 'VatReturn'], count: invoices + expenses + bankTx + vatReturns, retentionYears: 8, legalBasis: 'Art. 6 Abs. 1 lit. c (gesetzl. Pflicht, GoBD)' }, + { category: 'Vertragsdaten', models: ['Contract'], count: contracts, retentionYears: 6, legalBasis: 'Art. 6 Abs. 1 lit. b (Vertrag)' }, + { category: 'Lizenzdaten', models: ['License', 'Asset'], count: licenses + assets, retentionYears: 6, legalBasis: 'Art. 6 Abs. 1 lit. b (Vertrag)' }, + { category: 'Supportdaten', models: ['Ticket'], count: tickets, retentionYears: 6, legalBasis: 'Art. 6 Abs. 1 lit. f (berechtigtes Interesse)' }, + { category: 'Audit-Log', models: ['AuditLog'], count: auditLogs, retentionYears: 8, legalBasis: 'Art. 6 Abs. 1 lit. c (GoBD)' }, + { category: 'Wissensdatenbank', models: ['Article'], count: articles, retentionYears: null, legalBasis: 'Art. 6 Abs. 1 lit. f (berechtigtes Interesse)' }, + { category: 'Portal-Freigaben', models: ['PortalApproval'], count: portalApprovals, retentionYears: 6, legalBasis: 'Art. 6 Abs. 1 lit. b (Vertrag)' }, + ] + + const totalRecords = inventory.reduce((s, i) => s + i.count, 0) + + // Export history (from audit log) + const exports = await db.auditLog.findMany({ + where: { action: { startsWith: 'data.export' } }, + orderBy: { createdAt: 'desc' }, + take: 10, + }) + + return NextResponse.json({ + inventory, + stats: { + totalRecords, + activeTokens: apiTokens, + activeWebhooks: webhooks, + exportCount: exports.length, + }, + exports: exports.map((e) => ({ + id: e.id, + action: e.action, + detail: e.after ? JSON.parse(e.after) : null, + actor: e.actor, + createdAt: e.createdAt, + })), + }) + } catch (err) { + const msg = err instanceof Error ? err.message : 'internal server error' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} diff --git a/src/components/aos/app-shell.tsx b/src/components/aos/app-shell.tsx index 61a2545..3e709cb 100755 --- a/src/components/aos/app-shell.tsx +++ b/src/components/aos/app-shell.tsx @@ -22,6 +22,7 @@ import { ExpensesView } from './views/expenses' import { FinanceView } from './views/finance' import { AccountingView } from './views/accounting' import { AuditView } from './views/audit' +import { DataView } from './views/data' import { KbView } from './views/kb' import { ApiView } from './views/api' import { SettingsView } from './views/settings' @@ -73,6 +74,7 @@ export function AppShell() { {view === 'finance' && } {view === 'accounting' && } {view === 'audit' && } + {view === 'data' && } {view === 'kb' && } {view === 'api' && } {view === 'settings' && } diff --git a/src/components/aos/command-menu.tsx b/src/components/aos/command-menu.tsx index 00ec527..1f55256 100755 --- a/src/components/aos/command-menu.tsx +++ b/src/components/aos/command-menu.tsx @@ -25,6 +25,7 @@ import { HelpCircle, SlidersHorizontal, CalendarDays, + Database, } from 'lucide-react' import { useAos, type ViewId } from '@/store/aos' import { @@ -53,6 +54,7 @@ const VIEWS: { id: ViewId; label: string; icon: typeof LayoutDashboard; hint: st { id: 'finance', label: 'Finanzen', icon: Receipt, hint: 'Rechnungen & E-Rechnung' }, { id: 'accounting', label: 'Buchhaltung', icon: BookOpen, hint: 'USt-Voranmeldung, EÜR, Bank' }, { id: 'audit', label: 'Audit-Protokoll', icon: ScrollText, hint: 'Hash-Kette & GoBD' }, + { id: 'data', label: 'Datenverwaltung', icon: Database, hint: 'Export, DSGVO, Aufbewahrung' }, { id: 'kb', label: 'Wissensdatenbank', icon: HelpCircle, hint: 'Dokumente & Vorlagen' }, { id: 'api', label: 'API & Webhooks', icon: Webhook, hint: 'Tokens, Scopes, HMAC' }, { id: 'settings', label: 'Erweiterungen', icon: Settings2, hint: 'Module & Compliance' }, diff --git a/src/components/aos/sidebar-nav.tsx b/src/components/aos/sidebar-nav.tsx index 0b2edae..c7d1efb 100755 --- a/src/components/aos/sidebar-nav.tsx +++ b/src/components/aos/sidebar-nav.tsx @@ -25,6 +25,7 @@ import { HelpCircle, CalendarDays, Clock, + Database, } from 'lucide-react' import { useAos, type ViewId } from '@/store/aos' import { translate } from '@/lib/i18n' @@ -69,6 +70,7 @@ const NAV: { { id: 'finance', label: 'Finanzen', icon: Receipt, permission: 'finance:read' }, { id: 'accounting', label: 'Buchhaltung', icon: BookOpen, permission: 'accounting:read' }, { id: 'audit', label: 'Audit-Protokoll', icon: ScrollText, permission: '*' }, + { id: 'data', label: 'Datenverwaltung', icon: Database, permission: '*' }, ], }, { diff --git a/src/components/aos/top-bar.tsx b/src/components/aos/top-bar.tsx index 81a01fb..78cf8f5 100755 --- a/src/components/aos/top-bar.tsx +++ b/src/components/aos/top-bar.tsx @@ -33,6 +33,7 @@ const TITLES: Record = { finance: { title: 'Finanzen', crumb: 'Betrieb / Finanzen' }, accounting: { title: 'Buchhaltung', crumb: 'Betrieb / Buchhaltung' }, audit: { title: 'Audit-Protokoll', crumb: 'Betrieb / Audit' }, + data: { title: 'Datenverwaltung', crumb: 'Betrieb / Daten' }, kb: { title: 'Wissensdatenbank', crumb: 'Wissen / KB' }, api: { title: 'API & Webhooks', crumb: 'System / API' }, settings: { title: 'Erweiterungen', crumb: 'System / Module' }, diff --git a/src/components/aos/views/data.tsx b/src/components/aos/views/data.tsx new file mode 100644 index 0000000..56da172 --- /dev/null +++ b/src/components/aos/views/data.tsx @@ -0,0 +1,280 @@ +'use client' + +import { useState } from 'react' +import { useFetch } from '@/hooks/use-fetch' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { ScrollArea } from '@/components/ui/scroll-area' +import { PageHeader } from '../primitives' +import { formatDateTime, formatRelative } from '@/lib/format' +import { cn } from '@/lib/utils' +import { toast } from 'sonner' +import { + Database, + Download, + FileJson, + FileSpreadsheet, + ShieldCheck, + Lock, + Clock, + CheckCircle2, + AlertTriangle, + Loader2, +} from 'lucide-react' + +interface InventoryItem { + category: string + models: string[] + count: number + retentionYears: number | null + legalBasis: string +} +interface ExportEntry { + id: string + action: string + detail: { format: string; scope: string; checksum: string; sizeBytes: number; recordCount: number } | null + actor: string | null + createdAt: string +} +interface DataData { + inventory: InventoryItem[] + stats: { totalRecords: number; activeTokens: number; activeWebhooks: number; exportCount: number } + exports: ExportEntry[] +} + +export function DataView() { + const { data, loading, refetch } = useFetch('/api/aos/data-management') + const [exporting, setExporting] = useState<'json-full' | 'csv-full' | null>(null) + + if (loading || !data) { + return ( +
+ + +
+ ) + } + + const { inventory, stats, exports } = data + + const doExport = async (format: 'json' | 'csv', scope: string) => { + setExporting(format === 'json' ? 'json-full' : 'csv-full') + const t = toast.loading('Export wird erstellt (SHA-256 Checksumme berechnet)…') + try { + const r = await fetch(`/api/aos/data-management/export?format=${format}&scope=${scope}`) + if (!r.ok) throw new Error('export failed') + const blob = await r.blob() + const checksum = r.headers.get('x-aos-checksum') ?? 'n/a' + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `aos-export-${scope}-${new Date().toISOString().slice(0, 10)}.${format === 'json' ? 'json' : 'csv'}` + a.click() + URL.revokeObjectURL(url) + toast.success(`Export erstellt — Checksumme: ${checksum.slice(0, 24)}…`, { id: t, duration: 6000 }) + refetch() + } catch { + toast.error('Export fehlgeschlagen', { id: t }) + } finally { + setExporting(null) + } + } + + return ( +
+ + + {/* KPIs */} +
+ +

Datensätze gesamt

+

{stats.totalRecords}

+
+ +

Exporte (Historie)

+

{stats.exportCount}

+
+ +

Aktive API-Tokens

+

{stats.activeTokens}

+
+ +

Aktive Webhooks

+

{stats.activeWebhooks}

+
+
+ + {/* Export actions */} + + + + + Daten-Export (DSGVO Art. 15 / Art. 20) + + + +
+ + +
+
+

DSGVO Art. 15 + Art. 20

+

Betroffene haben das Recht auf Auskunft über ihre gespeicherten Daten (Art. 15) und deren Übertragbarkeit in einem strukturierten, gängigen Format (Art. 20). Jeder Export wird mit SHA-256-Prüfsumme signiert und im Audit-Log protokolliert.

+
+
+
+ + {/* Data inventory (Art. 30) */} + + + + + Verarbeitungsverzeichnis (Art. 30 DSGVO) + + + +
+ + + + + + + + + + + + {inventory.map((item) => ( + + + + + + + + ))} + + + + + + + + + +
KategorieModelleDatensätzeAufbewahrungRechtsgrundlage
{item.category} +
+ {item.models.map((m) => {m})} +
+
{item.count} + {item.retentionYears ? ( + {item.retentionYears} Jahre + ) : ( + bis Widerruf + )} + {item.legalBasis}
Gesamt{stats.totalRecords}
+
+
+
+ + {/* Export history */} + + + Export-Historie ({exports.length}) + + + {exports.length === 0 &&

Noch keine Exporte.

} + {exports.map((exp) => ( +
+ +
+

{exp.action}

+ {exp.detail && ( +

+ Format: {exp.detail.format} · Scope: {exp.detail.scope} · {exp.detail.recordCount} Datensätze · {(exp.detail.sizeBytes / 1024).toFixed(1)} KB +

+ )} + {exp.detail && ( +

SHA-256: {exp.detail.checksum.slice(0, 32)}…

+ )} +
+ {formatRelative(exp.createdAt)} +
+ ))} +
+
+ + {/* Retention + Erasure notice */} +
+ + + + + Aufbewahrungsfristen + + + +
Rechnungen & Buchhaltungsdaten8 Jahre (GoBD)
+
Geschäftsbriefe & Verträge6 Jahre (GoBD)
+
Audit-Log (Hash-Kette)8 Jahre (GoBD)
+
Kundenstammdaten6 Jahre
+
+

Automatische Lösch-Jobs (cron) nach Ablauf der Frist. GoBD: Daten sind unveränderlich, Korrekturen als Storno.

+
+
+
+ + + + + + Löschung (Art. 17 DSGVO) + + + +

Löschbegehren nach Art. 17 DSGVO (Recht auf Vergessenwerden) müssen geprüft werden:

+
    +
  • GoBD-Aufbewahrungspflicht (8/6 Jahre) hat Vorrang
  • +
  • Aktive Verträge/Rechnungen können nicht gelöscht werden
  • +
  • Stattdessen: Pseudonymisierung (Name → „gelöscht")
  • +
  • Vollständige Löschung erst nach Ablauf aller Fristen
  • +
+

+ Lösch-Anfragen werden im Audit-Log protokolliert und erfordern Freigabe durch den Datenschutzbeauftragten. +

+
+
+
+
+ ) +} diff --git a/src/store/aos.ts b/src/store/aos.ts index f4294af..c52a63f 100755 --- a/src/store/aos.ts +++ b/src/store/aos.ts @@ -18,6 +18,7 @@ export type ViewId = | 'finance' | 'accounting' | 'audit' + | 'data' | 'api' | 'kb' | 'calendar'