cfbb20c1-e953-4788-abed-5c8308f329ee

This commit is contained in:
Z User committed 2026-10-26 19:36:03 +00:00
1 parent be34f88822
commit 9feb124da6
18 files changed
+998 -4

No files matched your search

@@ -0,0 +1,75 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { createHash } from 'crypto'
export const dynamic = 'force-dynamic'
// Extract the real client IP from the request (behind Caddy/proxy)
function getClientIp(req: Request): string {
const headers = new Headers(req.headers)
const xff = headers.get('x-forwarded-for')
if (xff) return xff.split(',')[0]!.trim()
const realIp = headers.get('x-real-ip')
if (realIp) return realIp
return 'unbekannt'
}
// PATCH /api/aos/portal/approvals/[id]/decide
// Client decides on a milestone approval (approve / reject / changes-requested).
// Records REAL IP + timestamp + content hash (PRD §6.3: "approvals with
// timestamp and IP evidence"). Writes an audit entry.
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const body = (await req.json()) as { decision: string; decidedBy?: string; feedback?: string }
if (!['approved', 'rejected', 'changes-requested'].includes(body.decision)) {
return NextResponse.json({ error: 'invalid decision' }, { status: 400 })
}
const approval = await db.portalApproval.findUnique({ where: { id } })
if (!approval) return NextResponse.json({ error: 'not found' }, { status: 404 })
if (approval.status !== 'pending') return NextResponse.json({ error: 'already decided' }, { status: 409 })
const clientIp = getClientIp(req)
const contentHash = createHash('sha256')
.update([approval.id, approval.title, body.decision, new Date().toISOString()].join('|'))
.digest('hex')
const updated = await db.portalApproval.update({
where: { id },
data: {
status: body.decision,
decidedAt: new Date(),
decidedBy: body.decidedBy ?? 'Kunde (via Portal)',
decidedIp: clientIp,
decision: body.feedback ?? null,
contentHash,
},
})
// audit
await db.auditLog.create({
data: {
actor: body.decidedBy ?? 'portal-client',
action: `portal.approval:${body.decision}`,
entity: 'PortalApproval',
entityId: id,
before: JSON.stringify({ status: 'pending' }),
after: JSON.stringify({ status: body.decision, ip: clientIp, contentHash, feedback: body.feedback?.slice(0, 200) }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({
approval: updated,
evidence: {
decidedAt: updated.decidedAt,
decidedIp: clientIp,
contentHash,
signatureLevel: 'AdES (eIDAS Art. 25 Abs. 2)',
},
})
}
@@ -0,0 +1,109 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
// GET /api/aos/portal/companies/[id] — client portal dashboard data.
// Returns ONLY the data belonging to this company (project-scoped isolation).
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const company = await db.company.findUnique({
where: { id },
include: {
projects: { orderBy: { updatedAt: 'desc' }, include: { tasks: true, timeEntries: { orderBy: { date: 'desc' }, take: 5 } } },
invoices: { orderBy: { issueDate: 'desc' }, include: { lines: true } },
licenses: { include: { activations: true } },
deals: { where: { stage: { not: 'lost' } } },
},
})
if (!company) return NextResponse.json({ error: 'not found' }, { status: 404 })
// tickets for this company
const tickets = await db.ticket.findMany({
where: { companyId: id },
orderBy: { updatedAt: 'desc' },
include: { messages: { orderBy: { createdAt: 'asc' } } },
})
// portal approvals for this company's projects
const projectIds = company.projects.map((p) => p.id)
const approvals = await db.portalApproval.findMany({
where: { projectId: { in: projectIds } },
orderBy: { createdAt: 'desc' },
})
// KPIs
const openProjects = company.projects.filter((p) => p.status === 'active' || p.status === 'planning' || p.status === 'review').length
const unpaidInvoices = company.invoices.filter((i) => i.status === 'sent' || i.status === 'overdue')
const unpaidCents = unpaidInvoices.reduce((s, i) => s + i.grossCents, 0)
const openTickets = tickets.filter((t) => t.status === 'open' || t.status === 'pending').length
const pendingApprovals = approvals.filter((a) => a.status === 'pending').length
const activeLicenses = company.licenses.filter((l) => l.status === 'active').length
return NextResponse.json({
company: {
id: company.id,
name: company.name,
legalForm: company.legalForm,
city: company.city,
},
kpis: { openProjects, unpaidCents, unpaidInvoiceCount: unpaidInvoices.length, openTickets, pendingApprovals, activeLicenses },
projects: company.projects.map((p) => ({
id: p.id,
name: p.name,
type: p.type,
status: p.status,
progress: p.progress,
budgetType: p.budgetType,
spentHours: p.spentHours,
budgetHours: p.budgetHours,
dueDate: p.dueDate,
taskCount: p.tasks.length,
doneTasks: p.tasks.filter((t) => t.status === 'done').length,
})),
invoices: company.invoices.map((i) => ({
id: i.id,
number: i.number,
status: i.status,
type: i.type,
issueDate: i.issueDate,
dueDate: i.dueDate,
grossCents: i.grossCents,
zugferd: i.zugferd,
xrechnung: i.xrechnung,
lineCount: i.lines.length,
})),
tickets: tickets.map((t) => ({
id: t.id,
number: t.number,
subject: t.subject,
status: t.status,
priority: t.priority,
messageCount: t.messages.length,
lastMessageAt: t.messages[t.messages.length - 1]?.createdAt ?? t.createdAt,
})),
approvals: approvals.map((a) => ({
id: a.id,
title: a.title,
description: a.description,
status: a.status,
decidedAt: a.decidedAt,
decidedBy: a.decidedBy,
decidedIp: a.decidedIp,
decision: a.decision,
createdAt: a.createdAt,
})),
licenses: company.licenses.map((l) => ({
id: l.id,
key: l.key,
product: l.product,
plan: l.plan,
status: l.status,
validUntil: l.validUntil,
activations: l.activations.map((a) => ({ domain: a.domain, environment: a.environment })),
})),
})
}
+30
View File
@@ -0,0 +1,30 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
// GET /api/aos/portal/companies — list companies that can log into the portal
export async function GET() {
// A company can use the portal if it has projects, invoices, or tickets.
const companies = await db.company.findMany({
where: { projects: { some: {} } },
orderBy: { name: 'asc' },
include: {
_count: { select: { projects: true, invoices: true, licenses: true } },
persons: { where: { email: { not: null } }, take: 1 },
},
})
return NextResponse.json({
companies: companies.map((c) => ({
id: c.id,
name: c.name,
legalForm: c.legalForm,
city: c.city,
projectCount: c._count.projects,
invoiceCount: c._count.invoices,
licenseCount: c._count.licenses,
contactName: c.persons[0] ? `${c.persons[0].firstName} ${c.persons[0].lastName}` : null,
contactEmail: c.persons[0]?.email ?? null,
})),
})
}
+1
View File
@@ -20,6 +20,7 @@ export async function POST() {
await db.bankTransaction.deleteMany()
await db.webhook.deleteMany()
await db.apiToken.deleteMany()
await db.portalApproval.deleteMany()
await db.estimateLine.deleteMany()
await db.estimate.deleteMany()
await db.ticketMessage.deleteMany()