diff --git a/messages/de/imports.json b/messages/de/imports.json index b64c649..d4bef60 100644 --- a/messages/de/imports.json +++ b/messages/de/imports.json @@ -170,7 +170,8 @@ "email_invalid": "{field}: E-Mail-Adresse hat kein gültiges Format.", "phone_invalid": "{field}: Telefonnummer hat kein gültiges Format.", "end_before_start": "{field}: Ende liegt vor dem Beginn.", - "manual_entry": "Keine automatische Erkennung verfügbar. Bitte manuell erfassen." + "manual_entry": "Keine automatische Erkennung verfügbar. Bitte manuell erfassen.", + "ai_budget_exceeded": "Das monatliche KI-Kontingent ist aufgebraucht – deshalb keine automatische Erkennung." }, "positions": { "name": "Bezeichnung", diff --git a/messages/de/lotse.json b/messages/de/lotse.json index a879388..313ff19 100644 --- a/messages/de/lotse.json +++ b/messages/de/lotse.json @@ -76,7 +76,8 @@ "no_transcript": "Die Sprachnotiz hat noch keinen Text.", "pending": "Die Transkription läuft noch.", "conflict": "Inzwischen geändert. Bitte Seite neu laden.", - "invalid": "Bitte Eingaben prüfen." + "invalid": "Bitte Eingaben prüfen.", + "budget_exceeded": "Das monatliche KI-Kontingent des Betriebs ist aufgebraucht. Bitte den Bericht selbst schreiben oder das Büro fragen." }, "settings": { "back": "Einstellungen", @@ -96,6 +97,11 @@ "du": "du" }, "save": "Speichern", + "budget": "Monatliches KI-Kontingent (Tokens)", + "budgetHint": "Leer = Vorgabe der Plattform ({platform}). 0 = unbegrenzt. Ist das Kontingent aufgebraucht, bereitet der Lotse bis Monatsende nichts mehr vor.", + "budgetUnlimited": "unbegrenzt", + "budgetUsage": "Verbraucht seit {since}: {used} von {limit}", + "budgetExceeded": "Kontingent aufgebraucht", "dataTitle": "Welche Daten an wen gehen", "draftProvider": "Berichtsentwurf und Zusammenfassung", "transcriptionProvider": "Transkription von Sprachnotizen", diff --git a/messages/en/imports.json b/messages/en/imports.json index abf4b56..45b98f8 100644 --- a/messages/en/imports.json +++ b/messages/en/imports.json @@ -170,7 +170,8 @@ "email_invalid": "{field}: e-mail address format is invalid.", "phone_invalid": "{field}: phone number format is invalid.", "end_before_start": "{field}: end is before start.", - "manual_entry": "Automatic recognition is not available. Please enter manually." + "manual_entry": "Automatic recognition is not available. Please enter manually.", + "ai_budget_exceeded": "The monthly AI allowance is used up – therefore no automatic recognition." }, "positions": { "name": "Description", diff --git a/messages/en/lotse.json b/messages/en/lotse.json index 104753c..00b78a4 100644 --- a/messages/en/lotse.json +++ b/messages/en/lotse.json @@ -76,7 +76,8 @@ "no_transcript": "The voice note has no text yet.", "pending": "The transcription is still running.", "conflict": "Changed in the meantime. Please reload the page.", - "invalid": "Please check your input." + "invalid": "Please check your input.", + "budget_exceeded": "This business has used up its monthly AI allowance. Please write the report yourself or ask the office." }, "settings": { "back": "Settings", @@ -96,6 +97,11 @@ "du": "Informal (du)" }, "save": "Save", + "budget": "Monthly AI allowance (tokens)", + "budgetHint": "Empty = platform default ({platform}). 0 = unlimited. Once used up, Lotse prepares nothing until the end of the month.", + "budgetUnlimited": "unlimited", + "budgetUsage": "Used since {since}: {used} of {limit}", + "budgetExceeded": "Allowance used up", "dataTitle": "Which data goes where", "draftProvider": "Report draft and summary", "transcriptionProvider": "Voice note transcription", diff --git a/scripts/craftvia-worker.ts b/scripts/craftvia-worker.ts index f4395a0..649e67e 100644 --- a/scripts/craftvia-worker.ts +++ b/scripts/craftvia-worker.ts @@ -1,6 +1,6 @@ import "dotenv/config"; import { Worker } from "bullmq"; -import { JOB_QUEUES, workerConnection, closeJobQueues, type JobPayload } from "../src/server/jobs/queues"; +import { JOB_QUEUES, workerConnection, closeJobQueues, scheduleRecurringJobs, type JobPayload } from "../src/server/jobs/queues"; import { PROCESSORS } from "../src/server/jobs/processors"; /** Craftvia background worker: `npm run worker:craftvia`. One BullMQ worker per registered queue. */ @@ -24,6 +24,11 @@ async function main() { workers.push(w); console.info(`[worker] listening on ${name}`); } + // L10b: recurring jobs (AI log retention); a scheduling failure must not stop the queue workers + await scheduleRecurringJobs(connection).then( + () => console.info("[worker] recurring jobs scheduled"), + (err) => console.error("[worker] scheduling recurring jobs failed:", (err as Error).message), + ); const shutdown = async () => { await Promise.all(workers.map((w) => w.close())); await closeJobQueues(); diff --git a/scripts/test-betrieb-audit.ts b/scripts/test-betrieb-audit.ts new file mode 100644 index 0000000..bc1b029 --- /dev/null +++ b/scripts/test-betrieb-audit.ts @@ -0,0 +1,209 @@ +// Lane L10b „Betrieb & Aufräumen" — Transaktionen, Audit und Lotse-Betrieb: +// h) Audit-Einträge innerhalb von inTransaction erst nach dem Commit (Rollback → keine Einträge, +// außer „denied"; verschachtelt; aufgeschoben) +// d) mergeCustomers über inTransaction (atomar, in äußere Transaktion einbettbar) +// e) Audit-Aktion „read" für die Notdienst-Kundensuche +// k) KI-Protokoll: Aufbewahrungsfrist (Pseudonymisierung) + monatliches Token-Kontingent je Mandant +// Jeweils mit Mandantentrennung (B) und Rollen (Monteur → forbidden). +// +// Lauf: npx tsx scripts/test-betrieb-audit.ts (lokale Postgres-DB aus .env) + +import "dotenv/config"; +import { prisma, dbForTenant } from "../src/server/db"; +import { writeAuditLog } from "../src/server/audit"; +import { inTransaction } from "../src/server/services/context"; +import { mergeCustomers } from "../src/server/services/customers/merge"; +import { searchCustomersForEmergency } from "../src/server/services/emergency/lookup"; +import { aiGenerationRetentionDays, purgeExpiredAiGenerations } from "../src/server/services/lotse/retention"; +import { assertTokenBudget, getTokenBudget } from "../src/server/services/lotse/budget"; +import { getLotseSettings, updateLotseSettings } from "../src/server/services/lotse/settings"; +import { PROCESSORS } from "../src/server/jobs/processors"; +import { ctxFor, expectCode, failures, ok } from "./lib/einsatz-fixture"; + +const SLUG_A = "zz-l10b-audit-a"; +const SLUG_B = "zz-l10b-audit-b"; +const DOMAIN = "@zz-l10b-audit.test"; +const DAY = 24 * 60 * 60 * 1000; + +async function cleanup() { + for (const slug of [SLUG_A, SLUG_B]) { + const tenant = await prisma.tenant.findUnique({ where: { slug }, select: { id: true } }); + if (!tenant) continue; + const where = { tenantId: tenant.id }; + await prisma.auditLog.deleteMany({ where }); + await prisma.aiGeneration.deleteMany({ where }); + await prisma.workOrder.deleteMany({ where }); + await prisma.site.deleteMany({ where }); + await prisma.contact.deleteMany({ where }); + await prisma.customer.deleteMany({ where }); + await prisma.tenantModule.deleteMany({ where }); + await prisma.tenantSettings.deleteMany({ where }); + await prisma.user.deleteMany({ where }); + await prisma.tenant.delete({ where: { id: tenant.id } }); + } + await prisma.identity.deleteMany({ where: { email: { endsWith: DOMAIN }, memberships: { none: {} } } }); +} + +async function user(tenantId: string, local: string) { + const email = `${local}${DOMAIN}`; + const identity = await prisma.identity.upsert({ where: { email }, update: {}, create: { email, passwordHash: "x" } }); + return prisma.user.create({ data: { tenantId, identityId: identity.id, email, name: local } }); +} + +const auditCount = (tenantId: string, entity: string, entityId?: string, action?: string) => + prisma.auditLog.count({ where: { tenantId, entity, ...(entityId ? { entityId } : {}), ...(action ? { action } : {}) } }); + +async function main() { + await cleanup(); + const tenantA = await prisma.tenant.create({ data: { name: "L10b Audit A", slug: SLUG_A } }); + const tenantB = await prisma.tenant.create({ data: { name: "L10b Audit B", slug: SLUG_B } }); + const adminA = await user(tenantA.id, "admin-a"); + const techA = await user(tenantA.id, "tech-a"); + const adminB = await user(tenantB.id, "admin-b"); + const ctxAdminA = ctxFor(tenantA.id, adminA.id, "tenant-admin"); + const ctxTechA = ctxFor(tenantA.id, techA.id, "technician"); + const ctxAdminB = ctxFor(tenantB.id, adminB.id, "tenant-admin"); + await prisma.tenantSettings.create({ data: { tenantId: tenantA.id, orgName: "A" } }); + await prisma.tenantSettings.create({ data: { tenantId: tenantB.id, orgName: "B" } }); + + const customer = (tenantId: string, companyName: string) => prisma.customer.create({ data: { tenantId, companyName, city: "Kiel" } }); + const site = (tenantId: string, customerId: string) => + prisma.site.create({ data: { tenantId, customerId, name: "Halle", street: "Weg", houseNumber: "1", postalCode: "24103", city: "Kiel" } }); + + console.log("\n— h) Audit nach Commit —"); + const probe = await customer(tenantA.id, "Probe GmbH"); + await inTransaction(ctxAdminA, async (tx) => { + await tx.db.customer.update({ where: { id: probe.id }, data: { city: "Lübeck" } }); + await writeAuditLog({ tenantId: tx.tenantId, actorId: tx.userId, action: "update", entity: "zz_l10b_tx", entityId: "commit" }); + ok((await auditCount(tenantA.id, "zz_l10b_tx", "commit")) === 0, "innerhalb der Transaktion noch kein Audit-Eintrag (aufgeschoben)"); + }); + ok((await auditCount(tenantA.id, "zz_l10b_tx", "commit")) === 1, "nach Commit: Audit-Eintrag geschrieben"); + + await inTransaction(ctxAdminA, async (tx) => { + await tx.db.customer.update({ where: { id: probe.id }, data: { city: "Flensburg" } }); + await writeAuditLog({ tenantId: tx.tenantId, actorId: tx.userId, action: "update", entity: "zz_l10b_tx", entityId: "rollback" }); + await writeAuditLog({ tenantId: tx.tenantId, actorId: tx.userId, action: "denied", entity: "zz_l10b_tx", entityId: "rollback-denied" }); + throw new Error("zz rollback"); + }).catch(() => undefined); + ok((await prisma.customer.findUniqueOrThrow({ where: { id: probe.id } })).city === "Lübeck", "Rollback: Fachänderung verworfen"); + ok((await auditCount(tenantA.id, "zz_l10b_tx", "rollback")) === 0, "Rollback: kein Audit-Eintrag für die verworfene Änderung"); + ok((await auditCount(tenantA.id, "zz_l10b_tx", "rollback-denied", "denied")) === 1, "Rollback: „denied\"-Eintrag bleibt (Sicherheitsereignis)"); + + await inTransaction(ctxAdminA, async (outer) => { + await inTransaction(outer, async (inner) => { + await writeAuditLog({ tenantId: inner.tenantId, action: "update", entity: "zz_l10b_tx", entityId: "nested" }); + }); + ok((await auditCount(tenantA.id, "zz_l10b_tx", "nested")) === 0, "verschachtelt: innere Transaktion schreibt nicht vorzeitig"); + throw new Error("zz outer rollback"); + }).catch(() => undefined); + ok((await auditCount(tenantA.id, "zz_l10b_tx", "nested")) === 0, "verschachtelt: äußerer Rollback verwirft auch innere Audit-Einträge"); + await writeAuditLog({ tenantId: tenantA.id, action: "update", entity: "zz_l10b_tx", entityId: "direct" }); + ok((await auditCount(tenantA.id, "zz_l10b_tx", "direct")) === 1, "außerhalb einer Transaktion: sofort geschrieben"); + + console.log("\n— d) mergeCustomers atomar —"); + const src = await customer(tenantA.id, "Quelle GmbH"); + const tgt = await customer(tenantA.id, "Ziel GmbH"); + const srcSite = await site(tenantA.id, src.id); + await expectCode(() => mergeCustomers(ctxTechA, { sourceId: src.id, targetId: tgt.id, confirm: true }), "forbidden", "Monteur darf nicht zusammenführen"); + await expectCode(() => mergeCustomers(ctxAdminB, { sourceId: src.id, targetId: tgt.id, confirm: true }), "not_found", "Mandant B kann Kunden von A nicht zusammenführen"); + await inTransaction(ctxAdminA, async (tx) => { + await mergeCustomers(tx, { sourceId: src.id, targetId: tgt.id, confirm: true }); + throw new Error("zz merge rollback"); + }).catch(() => undefined); + ok((await prisma.customer.findUniqueOrThrow({ where: { id: src.id } })).status !== "merged", "Merge in äußerer Transaktion + Rollback → Quelle nicht zusammengeführt"); + ok((await prisma.site.findUniqueOrThrow({ where: { id: srcSite.id } })).customerId === src.id, "… und Objekt nicht umgehängt"); + ok((await auditCount(tenantA.id, "customer", src.id)) === 0, "… und keine Merge-Audit-Einträge"); + const merged = await mergeCustomers(ctxAdminA, { sourceId: src.id, targetId: tgt.id, confirm: true }); + ok(merged.moved.sites === 1 && (await prisma.site.findUniqueOrThrow({ where: { id: srcSite.id } })).customerId === tgt.id, "Merge: Objekt umgehängt"); + const srcAfter = await prisma.customer.findUniqueOrThrow({ where: { id: src.id } }); + ok(srcAfter.status === "merged" && srcAfter.mergedIntoId === tgt.id, "Merge: Quelle merged + mergedIntoId"); + ok((await auditCount(tenantA.id, "customer", src.id, "update")) === 1 && (await auditCount(tenantA.id, "customer", tgt.id, "update")) === 1, "Merge: Audit für Quelle und Ziel nach Commit"); + await expectCode(() => mergeCustomers(ctxAdminA, { sourceId: src.id, targetId: tgt.id, confirm: true }), "conflict", "zweites Zusammenführen → conflict"); + + console.log("\n— e) Audit-Aktion „read\" —"); + const hits = await searchCustomersForEmergency(ctxTechA, "Ziel"); + ok(hits.some((h) => h.id === tgt.id), "Notdienst-Suche findet Kunden"); + const searchAudit = await prisma.auditLog.findFirst({ where: { tenantId: tenantA.id, entity: "emergency_customer_search" }, orderBy: { createdAt: "desc" } }); + ok(searchAudit?.action === "read" && searchAudit.actorId === techA.id, "Suchzugriff als Aktion „read\" protokolliert"); + ok((await searchCustomersForEmergency(ctxFor(tenantB.id, adminB.id, "technician"), "Ziel")).length === 0, "Mandant B findet keine Kunden von A"); + await expectCode(() => searchCustomersForEmergency(ctxFor(tenantA.id, adminA.id, "backoffice"), "Ziel"), "forbidden", "ohne emergency:create → forbidden"); + + console.log("\n— k) Aufbewahrung KI-Protokoll —"); + const now = new Date(); + const gen = (tenantId: string, createdAt: Date, tokens = 10, createdById: string | null = null) => + prisma.aiGeneration.create({ + data: { tenantId, kind: "report_draft", provider: "fake", model: "fake-1", input: { text: "Kunde ruft an" }, output: { workPerformed: "x" }, inputTokens: tokens, outputTokens: tokens, createdById, createdAt }, + }); + const oldA = await gen(tenantA.id, new Date(now.getTime() - 200 * DAY), 10, techA.id); + const newA = await gen(tenantA.id, new Date(now.getTime() - 10 * DAY), 10, techA.id); + const oldB = await gen(tenantB.id, new Date(now.getTime() - 200 * DAY), 10, adminB.id); + ok(aiGenerationRetentionDays() === 180, "Default-Aufbewahrung 180 Tage"); + process.env.AI_GENERATION_RETENTION_DAYS = "30"; + ok(aiGenerationRetentionDays() === 30, "AI_GENERATION_RETENTION_DAYS überschreibt den Default"); + delete process.env.AI_GENERATION_RETENTION_DAYS; + ok(typeof PROCESSORS["ai-retention"] === "function", "Job ai-retention im Worker registriert"); + + const r1 = await purgeExpiredAiGenerations({ now, tenantIds: [tenantA.id] }); + const oldAAfter = await prisma.aiGeneration.findUniqueOrThrow({ where: { id: oldA.id } }); + ok(r1.pseudonymised === 1 && oldAAfter.input === null && oldAAfter.output === null && oldAAfter.createdById === null, "abgelaufener Eintrag: Inhalte gelöscht, Personenbezug entfernt"); + ok(oldAAfter.inputTokens === 10 && oldAAfter.model === "fake-1" && oldAAfter.kind === "report_draft", "Metadaten (Tokens, Modell, Art) bleiben"); + const newAAfter = await prisma.aiGeneration.findUniqueOrThrow({ where: { id: newA.id } }); + ok(newAAfter.input !== null && newAAfter.createdById === techA.id, "junger Eintrag unverändert"); + ok((await prisma.aiGeneration.findUniqueOrThrow({ where: { id: oldB.id } })).input !== null, "Lauf für Mandant A lässt Mandant B unberührt"); + ok((await auditCount(tenantA.id, "ai_generation_retention", undefined, "delete")) === 1, "Aufbewahrungslauf auditiert"); + ok((await purgeExpiredAiGenerations({ now, tenantIds: [tenantA.id] })).pseudonymised === 0, "zweiter Lauf idempotent"); + ok((await purgeExpiredAiGenerations({ now, days: 365, tenantIds: [tenantB.id] })).pseudonymised === 0, "längere Frist → nichts gelöscht"); + ok((await purgeExpiredAiGenerations({ now, tenantIds: [tenantB.id] })).pseudonymised === 1, "Mandant B eigener Lauf"); + + console.log("\n— k) Monatliches Token-Kontingent —"); + await prisma.aiGeneration.deleteMany({ where: { tenantId: { in: [tenantA.id, tenantB.id] } } }); + delete process.env.AI_MONTHLY_TOKEN_LIMIT; + await gen(tenantA.id, now, 600); + let budget = await getTokenBudget(ctxTechA, now); + ok(budget.limit === 0 && !budget.exceeded && budget.used === 1200, "ohne Limit: unbegrenzt, Verbrauch = Tokens ein+aus des Monats"); + await expectCode(() => updateLotseSettings(ctxTechA, { enabled: true, addressForm: "neutral", monthlyTokenLimit: 1000 }), "forbidden", "Monteur darf das Kontingent nicht setzen"); + await updateLotseSettings(ctxAdminA, { enabled: true, addressForm: "neutral", monthlyTokenLimit: 1000 }); + budget = await getTokenBudget(ctxTechA, now); + ok(budget.limit === 1000 && budget.source === "tenant" && budget.exceeded, "Mandanten-Limit 1000 bei 1200 Verbrauch → aufgebraucht"); + await expectCode(() => assertTokenBudget(ctxTechA, now), "blocked", "assertTokenBudget → blocked"); + try { + await assertTokenBudget(ctxTechA, now); + } catch (err) { + ok((err as { details?: { reason?: string } }).details?.reason === "budget_exceeded", "… mit reason budget_exceeded"); + } + const settingsAudit = await prisma.auditLog.findFirst({ where: { tenantId: tenantA.id, entity: "lotse_settings" }, orderBy: { createdAt: "desc" } }); + ok((settingsAudit?.after as { monthlyTokenLimit?: number } | null)?.monthlyTokenLimit === 1000, "Limit-Änderung auditiert"); + ok((await getLotseSettings(ctxAdminA)).budget.tenantLimit === 1000, "Einstellungsseite liefert Limit und Verbrauch"); + ok(!(await getTokenBudget(ctxAdminB, now)).exceeded && (await getTokenBudget(ctxAdminB, now)).used === 0, "Mandant B: eigener Verbrauch, nicht betroffen"); + ok((await prisma.tenantSettings.findFirstOrThrow({ where: { tenantId: tenantB.id } })).aiMonthlyTokenLimit === null, "Mandant B: Limit unverändert"); + + await gen(tenantB.id, new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1) - DAY), 5000); + process.env.AI_MONTHLY_TOKEN_LIMIT = "50"; + budget = await getTokenBudget(ctxAdminB, now); + ok(budget.limit === 50 && budget.source === "env" && budget.used === 0 && !budget.exceeded, "Env-Default greift; Verbrauch des Vormonats zählt nicht"); + await gen(tenantB.id, now, 30); + ok((await getTokenBudget(ctxAdminB, now)).exceeded, "Env-Limit 50 bei 60 Tokens → aufgebraucht"); + await updateLotseSettings(ctxAdminB, { enabled: true, addressForm: "neutral", monthlyTokenLimit: 0 }); + ok(!(await getTokenBudget(ctxAdminB, now)).exceeded, "Mandanten-Limit 0 = unbegrenzt überschreibt Env"); + await updateLotseSettings(ctxAdminB, { enabled: true, addressForm: "neutral" }); + ok((await prisma.tenantSettings.findFirstOrThrow({ where: { tenantId: tenantB.id } })).aiMonthlyTokenLimit === 0, "Speichern ohne Limit-Feld lässt das Limit unverändert"); + await updateLotseSettings(ctxAdminB, { enabled: true, addressForm: "neutral", monthlyTokenLimit: null }); + ok((await getTokenBudget(ctxAdminB, now)).source === "env", "null → wieder Plattform-Vorgabe"); + delete process.env.AI_MONTHLY_TOKEN_LIMIT; + + // guard: tenant db cannot read the other tenant's usage + const crossUsage = await dbForTenant(tenantB.id).aiGeneration.count({ where: { tenantId: tenantA.id } }).catch(() => 0); + ok(crossUsage === 0, "Mandanten-Client von B sieht keine KI-Nutzung von A"); +} + +main() + .catch((err) => { + console.error(err); + ok(false, `unerwarteter Fehler: ${(err as Error).message}`); + }) + .finally(async () => { + await cleanup().catch((e) => console.error("cleanup failed", e)); + await prisma.$disconnect(); + console.log(failures ? `\n✗ ${failures} Prüfung(en) fehlgeschlagen` : "\n✓ Alle Audit-/Transaktions-/Lotse-Betriebsprüfungen grün"); + process.exit(failures ? 1 : 0); + }); diff --git a/src/app/(app)/settings/lotse/page.tsx b/src/app/(app)/settings/lotse/page.tsx index 19f5d59..449a264 100644 --- a/src/app/(app)/settings/lotse/page.tsx +++ b/src/app/(app)/settings/lotse/page.tsx @@ -1,6 +1,6 @@ import Link from "next/link"; import { redirect } from "next/navigation"; -import { getTranslations } from "next-intl/server"; +import { getLocale, getTranslations } from "next-intl/server"; import { ArrowLeft, CheckCircle2, ListChecks, MinusCircle, ShieldCheck, XCircle } from "lucide-react"; import { LotseMark } from "@/components/lotse/lotse-mark"; import { PageHead } from "@/components/mockup-ui"; @@ -27,8 +27,9 @@ function ProviderStatus({ ok, labels }: { ok: boolean; labels: { ok: string; off export default async function LotseSettingsPage({ searchParams }: { searchParams: Promise<{ saved?: string; error?: string }> }) { const ctx = await readCtx(); if (!can(ctx, "tenant:manage")) redirect("/dashboard"); - const [sp, s, t] = await Promise.all([searchParams, getLotseSettings(ctx), getTranslations("lotse")]); + const [sp, s, t, locale] = await Promise.all([searchParams, getLotseSettings(ctx), getTranslations("lotse"), getLocale()]); const statusLabels = { ok: t("settings.configured"), off: t("settings.notConfigured") }; + const nf = new Intl.NumberFormat(locale); return (
@@ -72,6 +73,36 @@ export default async function LotseSettingsPage({ searchParams }: { searchParams ))} +
+ +

+ {t("settings.budgetHint", { platform: s.budget.platformLimit > 0 ? nf.format(s.budget.platformLimit) : t("settings.budgetUnlimited") })} +

+ +

+ {t("settings.budgetUsage", { + since: new Date(s.budget.periodStart).toLocaleDateString(locale, { timeZone: "UTC" }), + used: nf.format(s.budget.used), + limit: s.budget.limit > 0 ? nf.format(s.budget.limit) : t("settings.budgetUnlimited"), + })} + {s.budget.exceeded && ( + + {t("settings.budgetExceeded")} + + )} +

+
diff --git a/src/lib/imports/extraction.ts b/src/lib/imports/extraction.ts index bc0f58e..ca69d19 100644 --- a/src/lib/imports/extraction.ts +++ b/src/lib/imports/extraction.ts @@ -199,7 +199,9 @@ export type PlausibilityHintCode = | "email_invalid" | "phone_invalid" | "end_before_start" - | "manual_entry"; + | "manual_entry" + /** L10b: monthly AI token budget of the tenant used up → manual entry */ + | "ai_budget_exceeded"; export type PlausibilityHint = { field: ExtractionFieldKey | null; code: PlausibilityHintCode }; diff --git a/src/lib/lotse/action-state.ts b/src/lib/lotse/action-state.ts index f619283..081b109 100644 --- a/src/lib/lotse/action-state.ts +++ b/src/lib/lotse/action-state.ts @@ -12,6 +12,7 @@ export const LOTSE_ERROR_CODES = [ "pending", "conflict", "invalid", + "budget_exceeded", ] as const; export type LotseActionErrorCode = (typeof LOTSE_ERROR_CODES)[number]; diff --git a/src/server/actions/lotse-settings.ts b/src/server/actions/lotse-settings.ts index 98bb19f..896f8e1 100644 --- a/src/server/actions/lotse-settings.ts +++ b/src/server/actions/lotse-settings.ts @@ -19,9 +19,12 @@ export async function saveLotseSettings(fd: FormData): Promise { try { requirePermission(session, "tenant:manage"); // fast JWT check; requireApiContext re-checks against the DB const ctx = await requireApiContext(null, "tenant:manage"); + // L10b: empty = platform default (null); invalid numbers are rejected by the service schema + const rawLimit = String(fd.get("monthlyTokenLimit") ?? "").trim(); await updateLotseSettings(ctx, { enabled: fd.get("enabled") === "on", addressForm: (["sie", "du"].includes(String(fd.get("addressForm"))) ? String(fd.get("addressForm")) : "neutral") as "sie" | "du" | "neutral", + monthlyTokenLimit: rawLimit === "" ? null : Number(rawLimit), }); revalidatePath("/settings/lotse"); revalidatePath("/", "layout"); diff --git a/src/server/jobs/processors/ai-retention.ts b/src/server/jobs/processors/ai-retention.ts new file mode 100644 index 0000000..8be5632 --- /dev/null +++ b/src/server/jobs/processors/ai-retention.ts @@ -0,0 +1,11 @@ +import { purgeExpiredAiGenerations } from "@/server/services/lotse/retention"; + +/** + * Daily retention job for the AI log (L10b, Spec §31). Scheduled by the craftvia worker + * (`scheduleRecurringJobs`); the payload carries no tenant — the service iterates all tenants and + * writes through dbForTenant. + */ +export async function process(): Promise { + const res = await purgeExpiredAiGenerations(); + console.info(`[ai-retention] ${res.pseudonymised} entries older than ${res.days} days pseudonymised (${res.tenants} tenants)`); +} diff --git a/src/server/jobs/processors/index.ts b/src/server/jobs/processors/index.ts index 4ac9ea9..bc7653b 100644 --- a/src/server/jobs/processors/index.ts +++ b/src/server/jobs/processors/index.ts @@ -12,6 +12,7 @@ export const PROCESSORS: Partial Promise import("./transcription").then((m) => m.process), "report-pdf": () => import(/* turbopackIgnore: true */ "./report-pdf").then((m) => m.process), // worker-only (react-dom/server + Chromium), kept out of the app bundle "image-derivatives": () => import("./image-derivatives").then((m) => m.process), + "ai-retention": () => import("./ai-retention").then((m) => m.process), // L10b: daily AI log retention (scheduled by the worker) }; /** Inline fallback when no Redis is available (dev/demo). */ diff --git a/src/server/jobs/queues.ts b/src/server/jobs/queues.ts index 315d0b2..f310555 100644 --- a/src/server/jobs/queues.ts +++ b/src/server/jobs/queues.ts @@ -12,6 +12,7 @@ export const JOB_QUEUES = { transcription: "transcription", reportPdf: "report-pdf", imageDerivatives: "image-derivatives", + aiRetention: "ai-retention", } as const; export type JobQueueName = (typeof JOB_QUEUES)[keyof typeof JOB_QUEUES]; @@ -80,6 +81,24 @@ export async function enqueueJob(name: JobQueueName, payload: JobPayload): Promi return true; } +/** + * Recurring jobs (L10b), registered once by the craftvia worker at start. BullMQ job schedulers + * are idempotent per id, so several worker replicas do not create duplicates. + * - ai-retention: daily pseudonymisation of AI log contents (services/lotse/retention.ts) + */ +export async function scheduleRecurringJobs(connection: Redis): Promise { + const q = new Queue(JOB_QUEUES.aiRetention, { connection }); + try { + await q.upsertJobScheduler( + "ai-retention-daily", + { every: 24 * 60 * 60 * 1000 }, + { name: JOB_QUEUES.aiRetention, data: { tenantId: "*", entityId: "retention" }, opts: { removeOnComplete: { count: 30 }, removeOnFail: { count: 30 } } }, + ); + } finally { + await q.close(); + } +} + export async function closeJobQueues(): Promise { await Promise.all([...queues.values()].map((q) => q.close())); queues.clear(); diff --git a/src/server/services/imports/process.ts b/src/server/services/imports/process.ts index 0a17f59..bee48f0 100644 --- a/src/server/services/imports/process.ts +++ b/src/server/services/imports/process.ts @@ -13,6 +13,7 @@ import { checkPlausibility } from "@/lib/imports/plausibility"; // TODO(L3→L1): replace with "@/lib/customers/duplicates" after the L1 merge (same interface). import { findDuplicateCustomers } from "@/server/services/customers/duplicates"; import { findSiteCandidates } from "./site-candidates"; +import { getTokenBudget } from "@/server/services/lotse/budget"; export type ProcessDeps = { provider: DocumentExtractionProvider | null; @@ -45,9 +46,11 @@ export async function processImport(ctx: ServiceCtx, importId: string, deps: Pro let providerName: string | null = null; let model: string | null = null; - if (!deps.provider) { + // L10b: monthly AI token budget used up → same graceful path as without provider + const budgetExceeded = deps.provider ? (await getTokenBudget(ctx, deps.now)).exceeded : false; + if (!deps.provider || budgetExceeded) { fields = emptyExtraction(); - hints = [{ field: null, code: "manual_entry" }]; + hints = budgetExceeded ? [{ field: null, code: "manual_entry" }, { field: null, code: "ai_budget_exceeded" }] : [{ field: null, code: "manual_entry" }]; } else { const bytes = await deps.loadBytes({ storageKey: doc.storageKey }); if (!bytes) throw new Error("file_unavailable"); diff --git a/src/server/services/lotse/budget.ts b/src/server/services/lotse/budget.ts new file mode 100644 index 0000000..d2f1d79 --- /dev/null +++ b/src/server/services/lotse/budget.ts @@ -0,0 +1,57 @@ +import { ServiceError, type ServiceCtx } from "@/server/services/context"; + +/** + * Monthly AI token budget per tenant (Spec §31 „Kostenlimit", L10b). + * Limit = `TenantSettings.aiMonthlyTokenLimit` if set, otherwise env `AI_MONTHLY_TOKEN_LIMIT` + * (default 0). 0 = unlimited. Usage = sum of input + output tokens of all `AiGeneration`s of the + * tenant since the start of the current calendar month (UTC). Checked BEFORE a provider call — + * one call may overshoot the limit by its own size, the next one is refused. + * Transcription (audio) reports no tokens and is not counted. + */ + +export function envMonthlyTokenLimit(): number { + const v = Number(process.env.AI_MONTHLY_TOKEN_LIMIT); + return Number.isInteger(v) && v >= 0 ? v : 0; +} + +export type TokenBudget = { + /** effective limit, 0 = unlimited */ + limit: number; + source: "tenant" | "env"; + /** tenant override as stored (null = platform default) */ + tenantLimit: number | null; + used: number; + periodStart: string; + exceeded: boolean; +}; + +export function monthStart(now: Date): Date { + return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); +} + +export async function getTokenBudget(ctx: Pick, now: Date = new Date()): Promise { + const start = monthStart(now); + const [settings, usage] = await Promise.all([ + ctx.db.tenantSettings.findFirst({ select: { aiMonthlyTokenLimit: true } }), + ctx.db.aiGeneration.aggregate({ where: { createdAt: { gte: start } }, _sum: { inputTokens: true, outputTokens: true } }), + ]); + const tenantLimit = settings?.aiMonthlyTokenLimit ?? null; + const limit = tenantLimit ?? envMonthlyTokenLimit(); + const used = (usage._sum.inputTokens ?? 0) + (usage._sum.outputTokens ?? 0); + return { + limit, + source: tenantLimit === null ? "env" : "tenant", + tenantLimit, + used, + periodStart: start.toISOString(), + exceeded: limit > 0 && used >= limit, + }; +} + +/** Throws `blocked` (details.reason = "budget_exceeded") when the monthly budget is used up. */ +export async function assertTokenBudget(ctx: Pick, now: Date = new Date()): Promise { + const budget = await getTokenBudget(ctx, now); + if (budget.exceeded) { + throw new ServiceError("blocked", "monthly AI token budget exceeded", { reason: "budget_exceeded", limit: budget.limit, used: budget.used }); + } +} diff --git a/src/server/services/lotse/draft-report.ts b/src/server/services/lotse/draft-report.ts index 7c17042..3671850 100644 --- a/src/server/services/lotse/draft-report.ts +++ b/src/server/services/lotse/draft-report.ts @@ -10,6 +10,7 @@ import { contentOf, requireVisibleReport } from "@/server/services/reports/commo import { buildReportDraftInput } from "./minimize"; import { assertLotseEnabled, lotseVoice } from "./settings"; import { loadDraftNotes, loadMinimizationContext } from "./sources"; +import { assertTokenBudget } from "./budget"; export type LotseDeps = { provider: LotseAssistant | null; now?: () => Date }; export const defaultLotseDeps = (): LotseDeps => ({ provider: getLotseProvider() }); @@ -45,6 +46,7 @@ export async function prepareDraftInput(ctx: ServiceCtx, report: Report, content export async function draftReportWithLotse(ctx: ServiceCtx, reportId: string, deps: LotseDeps = defaultLotseDeps()) { const report = await requireDraftableReport(ctx, reportId); if (!deps.provider) throw new ServiceError("invalid", "lotse provider not configured", { reason: "not_configured" }); + await assertTokenBudget(ctx); // L10b: monthly token budget (blocked, reason budget_exceeded) const content = contentOf(report); const input = await prepareDraftInput(ctx, report, content); diff --git a/src/server/services/lotse/retention.ts b/src/server/services/lotse/retention.ts new file mode 100644 index 0000000..80fc599 --- /dev/null +++ b/src/server/services/lotse/retention.ts @@ -0,0 +1,50 @@ +import { Prisma } from "@prisma/client"; +import { writeAuditLog } from "@/server/audit"; +import { dbForTenant, prisma } from "@/server/db"; + +/** + * Retention of the AI log (Spec §31 „Aufbewahrung", L9 offener Punkt 4, L10b): + * after `AI_GENERATION_RETENTION_DAYS` (default 180) the CONTENT of an AiGeneration (minimised + * input sent to the provider, provider output) is deleted and the person reference is removed + * (`createdById = null`). Metadata (kind, provider, model, tokens, entity reference, time) stays + * for cost statistics and the reference from reports (`aiGenerationId`) keeps resolving. + * Runs daily in the craftvia worker (queue `ai-retention`); idempotent. + */ + +export const DEFAULT_AI_GENERATION_RETENTION_DAYS = 180; + +export function aiGenerationRetentionDays(): number { + const v = Number(process.env.AI_GENERATION_RETENTION_DAYS); + return Number.isInteger(v) && v > 0 ? v : DEFAULT_AI_GENERATION_RETENTION_DAYS; +} + +export type RetentionResult = { cutoff: string; days: number; tenants: number; pseudonymised: number }; + +export async function purgeExpiredAiGenerations(opts: { now?: Date; days?: number; tenantIds?: string[] } = {}): Promise { + const days = opts.days ?? aiGenerationRetentionDays(); + const cutoff = new Date((opts.now ?? new Date()).getTime() - days * 24 * 60 * 60 * 1000); + // tenant list = platform data (no tenant content); every content change runs through dbForTenant + const tenantIds = opts.tenantIds ?? (await prisma.tenant.findMany({ select: { id: true } })).map((t) => t.id); + + let pseudonymised = 0; + for (const tenantId of tenantIds) { + const db = dbForTenant(tenantId); + const res = await db.aiGeneration.updateMany({ + where: { + createdAt: { lt: cutoff }, + OR: [{ input: { not: Prisma.DbNull } }, { output: { not: Prisma.DbNull } }, { createdById: { not: null } }], + }, + data: { input: Prisma.DbNull, output: Prisma.DbNull, createdById: null }, + }); + if (res.count > 0) { + pseudonymised += res.count; + await writeAuditLog({ + tenantId, + action: "delete", + entity: "ai_generation_retention", + after: { count: res.count, cutoff: cutoff.toISOString(), retentionDays: days }, + }); + } + } + return { cutoff: cutoff.toISOString(), days, tenants: tenantIds.length, pseudonymised }; +} diff --git a/src/server/services/lotse/settings.ts b/src/server/services/lotse/settings.ts index 3526e9c..8320ecd 100644 --- a/src/server/services/lotse/settings.ts +++ b/src/server/services/lotse/settings.ts @@ -5,6 +5,7 @@ import { AI_MODEL, isAiConfigured } from "@/server/ai/client"; import { transcriptionConfig } from "@/server/ai/transcription/openai-compatible"; import { writeAuditLog } from "@/server/audit"; import { assertCan, inTransaction, ServiceError, type ServiceCtx } from "@/server/services/context"; +import { envMonthlyTokenLimit, getTokenBudget } from "./budget"; /** * Lotse settings per tenant (lane L9): on/off = module toggle `lotse` (TenantModule, missing row = on), @@ -38,19 +39,26 @@ export async function lotseVoice(ctx: Pick): Promise<{ address export async function getLotseSettings(ctx: ServiceCtx) { assertCan(ctx, "tenant:manage"); - const [enabled, s] = await Promise.all([isLotseEnabled(ctx), ctx.db.tenantSettings.findFirst({ select: { lotseAddressForm: true } })]); + const [enabled, s, budget] = await Promise.all([ + isLotseEnabled(ctx), + ctx.db.tenantSettings.findFirst({ select: { lotseAddressForm: true } }), + getTokenBudget(ctx), + ]); const transcription = transcriptionConfig(); return { enabled, addressForm: toAddressForm(s?.lotseAddressForm), draft: { configured: isAiConfigured(), provider: "Anthropic (Claude)", model: AI_MODEL }, transcription, + budget: { ...budget, platformLimit: envMonthlyTokenLimit() }, }; } export const lotseSettingsSchema = z.object({ enabled: z.boolean(), addressForm: z.enum(["sie", "du", "neutral"]), + /** L10b: tenant token budget per month; null = platform default, 0 = unlimited, undefined = unchanged */ + monthlyTokenLimit: z.number().int().min(0).max(1_000_000_000).nullable().optional(), }); export type LotseSettingsInput = z.input; @@ -59,10 +67,13 @@ export async function updateLotseSettings(ctx: ServiceCtx, raw: LotseSettingsInp const input = lotseSettingsSchema.parse(raw); const addressForm = input.addressForm === "neutral" ? null : input.addressForm; + const stored = await ctx.db.tenantSettings.findFirst({ select: { lotseAddressForm: true, aiMonthlyTokenLimit: true } }); const before = { enabled: await isLotseEnabled(ctx), - addressForm: (await ctx.db.tenantSettings.findFirst({ select: { lotseAddressForm: true } }))?.lotseAddressForm ?? null, + addressForm: stored?.lotseAddressForm ?? null, + monthlyTokenLimit: stored?.aiMonthlyTokenLimit ?? null, }; + const monthlyTokenLimit = input.monthlyTokenLimit === undefined ? before.monthlyTokenLimit : input.monthlyTokenLimit; await inTransaction(ctx, async (tx) => { await tx.db.tenantModule.upsert({ where: { tenantId_moduleKey: { tenantId: tx.tenantId, moduleKey: LOTSE_MODULE_KEY } }, @@ -70,14 +81,15 @@ export async function updateLotseSettings(ctx: ServiceCtx, raw: LotseSettingsInp create: { tenantId: tx.tenantId, moduleKey: LOTSE_MODULE_KEY, enabled: input.enabled }, }); const existing = await tx.db.tenantSettings.findFirst({ select: { id: true } }); + const data = { lotseAddressForm: addressForm, aiMonthlyTokenLimit: monthlyTokenLimit }; if (existing) { - await tx.db.tenantSettings.update({ where: { id: existing.id }, data: { lotseAddressForm: addressForm } }); + await tx.db.tenantSettings.update({ where: { id: existing.id }, data }); } else { const tenant = await tx.db.tenant.findUnique({ where: { id: tx.tenantId }, select: { name: true } }); - await tx.db.tenantSettings.create({ data: { tenantId: tx.tenantId, orgName: tenant?.name ?? "—", lotseAddressForm: addressForm } }); + await tx.db.tenantSettings.create({ data: { tenantId: tx.tenantId, orgName: tenant?.name ?? "—", ...data } }); } }); - const after = { enabled: input.enabled, addressForm }; + const after = { enabled: input.enabled, addressForm, monthlyTokenLimit }; await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "update", entity: "lotse_settings", entityId: ctx.tenantId, before, after }); return after; } diff --git a/src/server/services/lotse/voice.ts b/src/server/services/lotse/voice.ts index b66cd48..a39577b 100644 --- a/src/server/services/lotse/voice.ts +++ b/src/server/services/lotse/voice.ts @@ -7,6 +7,7 @@ import { createNote } from "@/server/services/field/notes"; import { workOrderScope } from "@/server/services/work-orders/visibility"; import { defaultLotseDeps, type LotseDeps } from "./draft-report"; import { scrubText } from "./minimize"; +import { assertTokenBudget } from "./budget"; import { assertLotseEnabled, lotseVoice } from "./settings"; import { loadMinimizationContext } from "./sources"; import { TRANSCRIPT_SEPARATOR } from "./transcription"; @@ -67,6 +68,7 @@ export async function summarizeVoiceNote(ctx: ServiceCtx, voiceNoteId: string, d await assertLotseEnabled(ctx); if (!voice.transcript?.trim()) throw new ServiceError("invalid", "voice note has no transcript", { reason: "no_transcript" }); if (!deps.provider) throw new ServiceError("invalid", "lotse provider not configured", { reason: "not_configured" }); + await assertTokenBudget(ctx); // L10b: monthly token budget const [minimization, lang] = await Promise.all([loadMinimizationContext(ctx, voice.workOrderId), lotseVoice(ctx)]); const transcript = scrubText(voice.transcript, minimization);