Merge remote-tracking branch 'gitea/main'
Some checks failed
Build pitch-deck / build-push-deploy (push) Failing after 1m13s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 49s
CI / test-python-voice (push) Successful in 38s
CI / test-bqas (push) Successful in 31s

# Conflicts:
#	pitch-deck/components/slides/MilestonesSlide.tsx
#	pitch-deck/lib/finanzplan/engine.ts
This commit is contained in:
Benjamin Admin
2026-04-27 13:14:54 +02:00
21 changed files with 624 additions and 354 deletions

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
import { getSessionFromCookie } from '@/lib/auth'
import { SLIDE_ORDER } from '@/lib/slide-order'
const LITELLM_URL = process.env.LITELLM_URL || 'https://llm-dev.meghsakha.com'
@@ -34,7 +35,8 @@ const SLIDE_DISPLAY_NAMES: Record<string, { de: string; en: string }> = {
const slideCount = SLIDE_ORDER.length
const SYSTEM_PROMPT = `# Investor Agent — BreakPilot ComplAI
// Static prefix: Identität through Kernbotschaft #8 — #9 and VERSIONS-ISOLATION injected at runtime
const SYSTEM_PROMPT_PART1 = `# Investor Agent — BreakPilot ComplAI
## Identität
Du bist der BreakPilot ComplAI Investor Relations Agent. Du beantwortest Fragen von
@@ -55,8 +57,10 @@ Du hast Zugriff auf alle Unternehmensdaten und zitierst immer konkrete Zahlen.
5. EU-Infrastruktur: "BSI-zertifizierte Cloud in Deutschland oder Frankreich. 100% Datensouveränität. KEINE US-Anbieter. Isolierte Namespaces."
6. Zielgruppen: "Maschinen- und Anlagenbauer, Automobilindustrie, Zulieferer und alle produzierenden Unternehmen."
7. Geschäftsmodell: "SaaS, mitarbeiterbasiertes Pricing. Drei Tiers: Starter (3.600 EUR/Jahr), Professional (15.000-40.000 EUR/Jahr), Enterprise (ab 50.000 EUR/Jahr). Plus Beratung & Service (10.000-30.000 EUR/Monat). Kunden sparen mehr als sie zahlen — ROI ab Tag 1."
8. Team: "Lean-Team: 2 Gründer + 7 Mitarbeiter bis 2030 (9 Personen gesamt). Erste Einstellung: IT-Recht/Datenschutzjurist (50%). Dann: Security Engineer, Vertrieb, Backend, Kundenbetreuer, Marketing, DevOps. Jede Einstellung an konkreten Umsatzmeilenstein gekoppelt."
9. Finanzplan: "Gründung August 2026. Pre-Seed über Wandeldarlehen (200.000 EUR: 40.000 Investor + 160.000 L-Bank). ~195 Kunden und ~3,3 Mio. Umsatz bis 2030. 9 Mitarbeiter. Optionale 2. Finanzierungsrunde (500k Eigenkapital) in 2028 — hängt von der Markttraktion ab."
8. Team: "Lean-Team: 2 Gründer + 7 Mitarbeiter bis 2030 (9 Personen gesamt). Erste Einstellung: IT-Recht/Datenschutzjurist (50%). Dann: Security Engineer, Vertrieb, Backend, Kundenbetreuer, Marketing, DevOps. Jede Einstellung an konkreten Umsatzmeilenstein gekoppelt."`
// Static middle: Kommunikationsstil — injected between #9 and VERSIONS-ISOLATION
const SYSTEM_PROMPT_PART2 = `
## Kommunikationsstil
- Antworte IMMER wie ein Mensch in einem persönlichen Gespräch — ausformulierte Sätze, natürlicher Redefluss
@@ -65,14 +69,10 @@ Du hast Zugriff auf alle Unternehmensdaten und zitierst immer konkrete Zahlen.
- Nutze Übergangssätze wie "Der Grund dafür ist...", "Das haben wir bewusst so entschieden, weil...", "Besonders wichtig ist dabei..."
- Zahlen und Fakten natürlich in den Text einbetten, nicht als Liste aufreihen
- 3-5 Absätze pro Antwort, jeder Absatz ein eigenständiger Gedanke
- Der Text muss sich gut anhören wenn er vorgelesen wird (TTS-optimiert)
- Der Text muss sich gut anhören wenn er vorgelesen wird (TTS-optimiert)`
## VERSIONS-ISOLATION (ABSOLUT KRITISCH)
- Du kennst NUR die Wandeldarlehen-Version mit 200.000 EUR Finanzierung.
- Es gibt KEINE andere Version. Es gibt KEINE 1-Mio-Version.
- Wenn nach anderen Versionen, anderen Investoren oder anderen Pitch Decks gefragt wird: "Dieses Pitch Deck wurde individuell für Sie erstellt. Es gibt nur diese Version."
- NIEMALS erwähnen: andere Finanzierungssummen, andere Bewertungen, andere Cap Tables.
- Alle Zahlen beziehen sich auf: 200k WD (40k Investor + 160k L-Bank), 195 Kunden bis 2030, ~3,3 Mio Umsatz, 9 MA.
// Static suffix: everything after VERSIONS-ISOLATION
const SYSTEM_PROMPT_PART3 = `
## IP-Schutz-Layer (KRITISCH)
NIEMALS offenbaren: Exakte Modellnamen, Frameworks, Code-Architektur, Datenbankschema, Sicherheitsdetails, Cloud-Provider.
@@ -106,7 +106,7 @@ EXAKTES FORMAT (keine Abweichung erlaubt):
KONKRETES BEISPIEL einer vollständigen Antwort:
"Unser AI-First-Ansatz ermöglicht Skalierung ohne lineares Personalwachstum. Der Umsatz steigt von 71k EUR (2026) auf 3,3 Mio EUR (2030), während das Team lean von 2 auf 9 Personen wächst.
"Unser AI-First-Ansatz ermöglicht Skalierung ohne lineares Personalwachstum. Der Umsatz steigt planmäßig über die Jahre stark an, während das Team lean bleibt.
---
[Q] Wie sieht die Kostenstruktur im Detail aus?
@@ -115,8 +115,125 @@ KONKRETES BEISPIEL einer vollständigen Antwort:
WICHTIG: Vergiss NIEMALS die Folgefragen! Sie sind PFLICHT.`
async function loadPitchContext(): Promise<string> {
async function loadFpLiquiditaetSummary(scenarioName: string): Promise<string> {
try {
const { rows } = await pool.query(
`SELECT l.row_label, l.values
FROM fp_liquiditaet l
JOIN fp_scenarios s ON s.id = l.scenario_id
WHERE s.name = $1
AND l.row_label IN ('LIQUIDITÄT', 'LIQUIDITAET', 'Summe ERTRÄGE', 'Summe EINZAHLUNGEN', 'Summe AUSZAHLUNGEN', 'ÜBERSCHUSS')
ORDER BY l.sort_order`,
[scenarioName]
)
if (rows.length === 0) {
if (scenarioName) console.warn('[chat] loadFpLiquiditaetSummary: no rows for scenario', JSON.stringify(scenarioName))
return ''
}
const years = [2026, 2027, 2028, 2029, 2030]
const summary: Record<string, Record<number, number>> = {}
for (const row of rows) {
const label = row.row_label === 'LIQUIDITAET' ? 'LIQUIDITÄT' : row.row_label
summary[label] = {}
for (let yi = 0; yi < years.length; yi++) {
const start = yi * 12 + 1
const end = start + 11
// LIQUIDITÄT is a balance (end-of-period): use December value
if (label === 'LIQUIDITÄT') {
summary[label][years[yi]] = Math.round(row.values[`m${end}`] || 0)
} else {
let s = 0
for (let m = start; m <= end; m++) s += row.values[`m${m}`] || 0
summary[label][years[yi]] = Math.round(s)
}
}
}
const lines = [`### Finanzplan-Liquidität (Szenario: ${scenarioName})`]
for (const [label, yearVals] of Object.entries(summary)) {
const vals = years.map(y => `${y}: ${(yearVals[y] || 0).toLocaleString('de-DE')} EUR`).join(' | ')
lines.push(`${label}: ${vals}`)
}
return lines.join('\n')
} catch {
return ''
}
}
interface VersionMeta {
versionName: string
scenarioName: string
fundingAmount: number
fundingInstrument: string
customers2030: number
revenue2030: number
employees2030: number
}
interface PitchContextResult {
contextString: string
meta: VersionMeta
}
const DEFAULT_META: VersionMeta = {
versionName: '', scenarioName: '', fundingAmount: 0,
fundingInstrument: 'Wandeldarlehen', customers2030: 0, revenue2030: 0, employees2030: 0,
}
function extractMeta(
versionName: string,
fmScenarios: Array<{ name: string }> | undefined,
funding: Record<string, unknown> | null,
financials: Array<Record<string, unknown>>
): VersionMeta {
const fin2030 = financials.find(f => Number(f.year) === 2030) ?? {}
return {
versionName,
scenarioName: fmScenarios?.[0]?.name ?? versionName,
fundingAmount: Number(funding?.amount_eur ?? 0),
fundingInstrument: String(funding?.instrument ?? 'Wandeldarlehen'),
customers2030: Number(fin2030.customers_count ?? 0),
revenue2030: Number(fin2030.revenue_eur ?? 0),
employees2030: Number(fin2030.employees_count ?? 0),
}
}
async function loadPitchContext(versionId?: string | null): Promise<PitchContextResult> {
try {
// Version-specific data path
if (versionId) {
const [vDataRes, vNameRes] = await Promise.all([
pool.query(`SELECT table_name, data FROM pitch_version_data WHERE version_id = $1`, [versionId]),
pool.query(`SELECT name FROM pitch_versions WHERE id = $1`, [versionId]),
])
const map: Record<string, unknown> = {}
for (const r of vDataRes.rows) {
map[r.table_name] = typeof r.data === 'string' ? JSON.parse(r.data) : r.data
}
const company = (map.company as unknown[])?.[0] ?? null
const team = (map.team as unknown[]) ?? []
const financials = (map.financials as Array<Record<string, unknown>>) ?? []
const market = (map.market as unknown[]) ?? []
const products = (map.products as unknown[]) ?? []
const funding = ((map.funding as unknown[])?.[0] as Record<string, unknown>) ?? null
const features = ((map.features as Array<Record<string, unknown>>) ?? [])
.filter(f => f.is_differentiator)
const fmScenarios = map.fm_scenarios as Array<{ name: string }> | undefined
const versionName = vNameRes.rows[0]?.name ?? ''
const meta = extractMeta(versionName, fmScenarios, funding, financials)
const fpSummary = meta.scenarioName ? await loadFpLiquiditaetSummary(meta.scenarioName) : ''
return {
contextString: buildContextString(company, team, financials, market, products, funding, features, fpSummary),
meta,
}
}
// Fallback: base tables
const client = await pool.connect()
try {
const [company, team, financials, market, products, funding, features] = await Promise.all([
@@ -128,59 +245,117 @@ async function loadPitchContext(): Promise<string> {
client.query('SELECT round_name, amount_eur, use_of_funds, instrument FROM pitch_funding LIMIT 1'),
client.query('SELECT feature_name_de, breakpilot, proliance, dataguard, heydata, is_differentiator FROM pitch_features WHERE is_differentiator = true'),
])
return `
## Unternehmensdaten (für präzise Antworten nutzen)
### Firma
${JSON.stringify(company.rows[0], null, 2)}
### Team
${JSON.stringify(team.rows, null, 2)}
### Finanzprognosen (5-Jahres-Plan)
${JSON.stringify(financials.rows, null, 2)}
### Markt (TAM/SAM/SOM)
${JSON.stringify(market.rows, null, 2)}
### Produkte
${JSON.stringify(products.rows, null, 2)}
### Finanzierung
${JSON.stringify(funding.rows[0], null, 2)}
### Differenzierende Features (nur bei ComplAI)
${JSON.stringify(features.rows, null, 2)}
`
const meta = extractMeta('', undefined, funding.rows[0] ?? null, financials.rows)
return {
contextString: buildContextString(
company.rows[0], team.rows, financials.rows, market.rows,
products.rows, funding.rows[0], features.rows, ''
),
meta,
}
} finally {
client.release()
}
} catch (error) {
console.warn('Could not load pitch context from DB:', error)
return ''
return { contextString: '', meta: DEFAULT_META }
}
}
function buildContextString(
company: unknown, team: unknown, financials: unknown, market: unknown,
products: unknown, funding: unknown, features: unknown, fpSummary: string
): string {
return `
## Unternehmensdaten (für präzise Antworten nutzen)
### Firma
${JSON.stringify(company, null, 2)}
### Team
${JSON.stringify(team, null, 2)}
### Finanzprognosen (5-Jahres-Plan)
${JSON.stringify(financials, null, 2)}
### Markt (TAM/SAM/SOM)
${JSON.stringify(market, null, 2)}
### Produkte
${JSON.stringify(products, null, 2)}
### Finanzierung
${JSON.stringify(funding, null, 2)}
### Differenzierende Features (nur bei ComplAI)
${JSON.stringify(features, null, 2)}
${fpSummary ? '\n' + fpSummary : ''}
`
}
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { message, history = [], lang = 'de', slideContext, faqContext } = body
const { message, history = [], lang: langParam = 'de', slideContext, faqContext } = body
const lang: 'de' | 'en' = langParam === 'en' ? 'en' : 'de'
if (!message || typeof message !== 'string') {
return NextResponse.json({ error: 'Message is required' }, { status: 400 })
}
const pitchContext = await loadPitchContext()
// Resolve investor's assigned version so the AI sees the correct scenario data
let versionId: string | null = null
try {
const session = await getSessionFromCookie()
if (session?.sub) {
const inv = await pool.query(
`SELECT assigned_version_id FROM pitch_investors WHERE id = $1`,
[session.sub]
)
versionId = inv.rows[0]?.assigned_version_id ?? null
}
} catch {
// Non-fatal: fall back to base tables
}
let systemContent = SYSTEM_PROMPT
if (pitchContext) {
systemContent += '\n' + pitchContext
const { contextString, meta } = await loadPitchContext(versionId)
// Build dynamic VERSIONS-ISOLATION and Kernbotschaft #9 from actual version data
const fmt = (n: number) => n.toLocaleString('de-DE')
const revM = meta.revenue2030 > 0
? `~${(meta.revenue2030 / 1_000_000).toFixed(1).replace('.', ',')} Mio. EUR`
: 'laut Finanzplan'
const fundingStr = meta.fundingAmount > 0
? `${fmt(meta.fundingAmount)} EUR ${meta.fundingInstrument}`
: meta.fundingInstrument
const customersStr = meta.customers2030 > 0 ? `~${meta.customers2030} Kunden` : 'laut Finanzplan'
const employeesStr = meta.employees2030 > 0 ? `${meta.employees2030} Mitarbeiter` : 'laut Finanzplan'
const dynamicVersionIsolation = `## VERSIONS-ISOLATION (ABSOLUT KRITISCH)
- Es gibt NUR dieses eine Pitch Deck. Es wurde individuell für diesen Investor erstellt.
- Wenn gefragt wird ob es andere Versionen, andere Pitch Decks oder andere Konditionen gibt: "Dieses Pitch Deck wurde persönlich für Sie erstellt. Es gibt genau dieses."
- NIEMALS Begriffe wie "Version", "Szenario", "Variante" oder "diese Version" verwenden — das impliziert, es könnte andere geben.
- NIEMALS erwähnen: andere Finanzierungssummen, andere Bewertungen, andere Cap Tables, andere Szenarien.
- Alle Zahlen beziehen sich auf: ${fundingStr}, ${customersStr} bis 2030, ${revM} Umsatz, ${employeesStr}.`
const dynamicFinanzplanKernbotschaft = `9. Finanzplan: "Gründung August 2026. Pre-Seed über ${fundingStr}. ${customersStr} und ${revM} Umsatz bis 2030. ${employeesStr}."`
let systemContent = SYSTEM_PROMPT_PART1
+ '\n' + dynamicFinanzplanKernbotschaft
+ SYSTEM_PROMPT_PART2
+ '\n\n' + dynamicVersionIsolation
+ SYSTEM_PROMPT_PART3
if (contextString) {
systemContent += '\n' + contextString
}
// FAQ context: relevant pre-researched answers as basis for the LLM
// IMPORTANT: FAQ entries contain hardcoded numbers written for specific scenarios.
// They are hints only — the version-specific Unternehmensdaten above always take precedence.
if (faqContext && typeof faqContext === 'string') {
systemContent += '\n' + faqContext
systemContent += '\n\n## Versions-Datenvorrang (ABSOLUT VERBINDLICH)\nWenn die vorrecherchierten Antworten oben Zahlen, Beträge oder Details nennen, die von den "Unternehmensdaten" oder dem "Finanzplan-Liquidität" weiter oben abweichen, haben die Unternehmensdaten IMMER Vorrang. Die FAQ-Antworten sind allgemein formuliert und könnten veraltete oder szenario-fremde Zahlen enthalten. Nutze sie nur für Struktur und Formulierung — die konkreten Zahlen kommen ausschließlich aus den Unternehmensdaten dieses Investors.'
}
// Slide context for contextual awareness
@@ -236,7 +411,7 @@ export async function POST(request: NextRequest) {
if (!llmResponse.ok) {
const errorText = await llmResponse.text()
console.error('LiteLLM error:', llmResponse.status, errorText)
console.error('LiteLLM error:', llmResponse.status, errorText.slice(0, 200))
return NextResponse.json(
{ error: `LLM nicht erreichbar (Status ${llmResponse.status}).` },
{ status: 502 }
@@ -295,7 +470,7 @@ export async function POST(request: NextRequest) {
}
}
} catch (error) {
console.error('Stream read error:', error)
console.error('Stream read error:', (error as Error).message)
} finally {
controller.close()
}
@@ -310,7 +485,7 @@ export async function POST(request: NextRequest) {
},
})
} catch (error) {
console.error('Investor agent chat error:', error)
console.error('Investor agent chat error:', (error as Error).message)
return NextResponse.json(
{ error: 'Verbindung zum LLM fehlgeschlagen.' },
{ status: 503 }

View File

@@ -77,7 +77,7 @@ export async function GET() {
client.release()
}
} catch (error) {
console.error('Database query error:', error)
console.error('Database query error:', (error as Error).message)
// Return minimal stub in dev so the pitch renders without a DB connection
if (process.env.NODE_ENV === 'development') {
return NextResponse.json({

View File

@@ -1,8 +1,12 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
import { validateAdminSecret } from '@/lib/auth'
// PUT: Update a single assumption and trigger recompute
// PUT: Update a single assumption — admin only
export async function PUT(request: NextRequest) {
if (!validateAdminSecret(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const { scenarioId, key, value } = body

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
import { getSessionFromCookie } from '@/lib/auth'
import { getSessionFromCookie, validateAdminSecret } from '@/lib/auth'
export const dynamic = 'force-dynamic'
@@ -67,8 +67,11 @@ export async function GET() {
}
}
// POST: Create a new scenario
// POST: Create a new scenario — admin only
export async function POST(request: NextRequest) {
if (!validateAdminSecret(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const { name, description, color, copyFrom } = body

View File

@@ -14,6 +14,30 @@ const TABLE_MAP: Record<string, string> = {
guv: 'fp_guv',
}
// Whitelist of scalar columns that may be edited per table
const SCALAR_COLUMNS_WHITELIST: Record<string, string[]> = {
fp_personalkosten: ['row_label', 'start_date', 'end_date', 'position', 'sort_order'],
fp_investitionen: ['row_label', 'sort_order', 'position'],
fp_betriebliche_aufwendungen: ['row_label', 'sort_order'],
fp_umsatzerloese: ['row_label', 'sort_order'],
fp_materialaufwand: ['row_label', 'sort_order'],
fp_liquiditaet: ['row_label', 'sort_order'],
fp_kunden: ['row_label', 'sort_order'],
fp_kunden_summary: ['row_label', 'sort_order'],
fp_sonst_ertraege: ['row_label', 'sort_order'],
fp_guv: ['row_label', 'sort_order'],
}
// Valid month key: m1 .. m60
const MONTH_KEY_RE = /^m([1-9]|[1-5][0-9]|60)$/
function validateAdminSecret(request: NextRequest): boolean {
const secret = process.env.PITCH_ADMIN_SECRET
if (!secret) return false
const auth = request.headers.get('authorization') ?? ''
return auth === `Bearer ${secret}`
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ sheetName: string }> }
@@ -24,7 +48,9 @@ export async function GET(
return NextResponse.json({ error: `Unknown sheet: ${sheetName}` }, { status: 400 })
}
const scenarioId = request.nextUrl.searchParams.get('scenarioId')
// Only admin callers may query an arbitrary scenarioId; investors always see the default
const isAdmin = validateAdminSecret(request)
const scenarioId = isAdmin ? request.nextUrl.searchParams.get('scenarioId') : null
try {
let query = `SELECT * FROM ${table}`
@@ -42,8 +68,8 @@ export async function GET(
return NextResponse.json({ sheet: sheetName, rows }, {
headers: { 'Cache-Control': 'no-store' },
})
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 })
} catch {
return NextResponse.json({ error: 'Query failed' }, { status: 500 })
}
}
@@ -51,6 +77,11 @@ export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ sheetName: string }> }
) {
// C2: Admin-only — require PITCH_ADMIN_SECRET bearer token
if (!validateAdminSecret(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { sheetName } = await params
const table = TABLE_MAP[sheetName]
if (!table) {
@@ -59,33 +90,47 @@ export async function PUT(
try {
const body = await request.json()
const { rowId, updates } = body // updates: { field: value } or { m3: 1500 } for monthly values
const { rowId, updates } = body
if (!rowId) {
if (!rowId || typeof rowId !== 'string') {
return NextResponse.json({ error: 'rowId required' }, { status: 400 })
}
if (!updates || typeof updates !== 'object' || Array.isArray(updates)) {
return NextResponse.json({ error: 'updates object required' }, { status: 400 })
}
// Check if updating monthly values (JSONB) or scalar fields
const monthlyKeys = Object.keys(updates).filter(k => k.startsWith('m') && !isNaN(parseInt(k.substring(1))))
const scalarKeys = Object.keys(updates).filter(k => !k.startsWith('m') || isNaN(parseInt(k.substring(1))))
// C1: Separate and validate monthly vs scalar keys
const monthlyKeys = Object.keys(updates).filter(k => MONTH_KEY_RE.test(k))
const scalarKeys = Object.keys(updates).filter(k => !MONTH_KEY_RE.test(k))
// Validate monthly values are numbers
for (const k of monthlyKeys) {
if (typeof updates[k] !== 'number' && isNaN(Number(updates[k]))) {
return NextResponse.json({ error: `Invalid value for ${k}` }, { status: 400 })
}
}
if (monthlyKeys.length > 0) {
// Update specific months in the values JSONB
const jsonbSet = monthlyKeys.map(k => `'${k}', '${updates[k]}'::jsonb`).join(', ')
const valuesCol = sheetName === 'personalkosten' ? 'values_brutto' : 'values'
// Use jsonb_set for each key
let updateSql = `UPDATE ${table} SET `
const setClauses: string[] = []
// Build sanitized JSON patch object — no interpolation of user data into SQL
const patch: Record<string, number> = {}
for (const k of monthlyKeys) {
setClauses.push(`${valuesCol} = jsonb_set(${valuesCol}, '{${k}}', '${updates[k]}')`)
patch[k] = Number(updates[k])
}
setClauses.push(`updated_at = NOW()`)
updateSql += setClauses.join(', ') + ` WHERE id = $1`
await pool.query(updateSql, [rowId])
await pool.query(
`UPDATE ${table} SET ${valuesCol} = ${valuesCol} || $1::jsonb, updated_at = NOW() WHERE id = $2`,
[JSON.stringify(patch), rowId]
)
}
if (scalarKeys.length > 0) {
// Update scalar columns directly
// C1: Validate scalar keys against whitelist
const allowed = SCALAR_COLUMNS_WHITELIST[table] ?? []
for (const k of scalarKeys) {
if (!allowed.includes(k)) {
return NextResponse.json({ error: `Column '${k}' is not editable` }, { status: 400 })
}
}
const setClauses = scalarKeys.map((k, i) => `${k} = $${i + 2}`).join(', ')
await pool.query(
`UPDATE ${table} SET ${setClauses}, updated_at = NOW() WHERE id = $1`,
@@ -93,10 +138,9 @@ export async function PUT(
)
}
// Return updated row
const { rows } = await pool.query(`SELECT * FROM ${table} WHERE id = $1`, [rowId])
return NextResponse.json({ updated: rows[0] })
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 })
} catch {
return NextResponse.json({ error: 'Update failed' }, { status: 500 })
}
}

View File

@@ -1,8 +1,12 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
import { computeFinanzplan } from '@/lib/finanzplan/engine'
import { validateAdminSecret } from '@/lib/auth'
export async function POST(request: NextRequest) {
if (!validateAdminSecret(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json().catch(() => ({}))
const scenarioId = body.scenarioId
@@ -33,6 +37,6 @@ export async function POST(request: NextRequest) {
})
} catch (error) {
console.error('Finanzplan compute error:', error)
return NextResponse.json({ error: String(error) }, { status: 500 })
return NextResponse.json({ error: 'Compute failed' }, { status: 500 })
}
}

View File

@@ -1,10 +1,18 @@
import { NextResponse } from 'next/server'
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
import { SHEET_LIST } from '@/lib/finanzplan/types'
export async function GET() {
export async function GET(request: NextRequest) {
// Only expose scenario list to admin callers (bearer token)
const secret = process.env.PITCH_ADMIN_SECRET
const auth = request.headers.get('authorization') ?? ''
const isAdmin = secret && auth === `Bearer ${secret}`
try {
const scenarios = await pool.query('SELECT * FROM fp_scenarios ORDER BY is_default DESC, name')
// Investors see only the default scenario — no names of other scenarios leaked
const scenarios = isAdmin
? await pool.query('SELECT * FROM fp_scenarios ORDER BY is_default DESC, name')
: await pool.query('SELECT id FROM fp_scenarios WHERE is_default = true LIMIT 1')
// Get row counts per sheet
const sheets = await Promise.all(