diff --git a/.env.example b/.env.example index 4cc4fbc..52389a5 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,14 @@ NEXT_PUBLIC_USE_MOCK_AUTH=false NEXT_PUBLIC_API_BASE_URL=https://your-api-gateway-url.example.com/prod NEXT_PUBLIC_COGNITO_USER_POOL_ID=eu-central-1_placeholder NEXT_PUBLIC_COGNITO_USER_POOL_CLIENT_ID=placeholder-client-id +NEXT_PUBLIC_COGNITO_CLIENT_ID=placeholder-client-id +NEXT_PUBLIC_COGNITO_DOMAIN= +NEXT_PUBLIC_COGNITO_REDIRECT_SIGN_IN=http://localhost:3000/auth/callback +NEXT_PUBLIC_COGNITO_REDIRECT_SIGN_OUT=http://localhost:3000/login +NEXT_PUBLIC_COGNITO_OAUTH_SCOPES=openid,email,profile AWS_REGION="eu-central-1" COGNITO_USER_POOL_ID="placeholder-only" COGNITO_CLIENT_ID="placeholder-only" DOCUMENTS_BUCKET_NAME="lifepilot-placeholder-documents" +OPENAI_API_KEY= +OPENAI_MODEL=gpt-4.1-mini diff --git a/README.md b/README.md index 8a487f7..aadca1f 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,111 @@ LifePilot is not positioned as "another AI app". It helps normal non-technical p This repository must not contain API keys, secrets, real user data, or real private documents. -## Current Milestone: LifePilot Memory Core MVP +## Current Milestone: Auth & Upload Simplicity MVP + +This sprint makes LifePilot easier for normal non-technical users: + +- Users can stay signed in across reloads through the existing Cognito/Amplify session store. The app checks the current session on startup and clears expired sessions cleanly. Passwords are never stored in `localStorage`. +- Mock auth/dev mode still works and stores only a fake local development session for reload convenience. +- Email/password login remains available. +- Google and Apple login buttons are visible, but only active when Cognito Hosted UI/OAuth public environment variables are configured. Otherwise the UI says the method is prepared but not activated. +- `/auth/callback` is prepared for redirect-based Cognito Hosted UI login and redirects to `/dashboard` after a real session is available. +- `/documents` now starts from selecting or dropping a file. LifePilot proposes a document name, uploads through the available flow, and starts deterministic analysis automatically where possible. +- TXT files are read locally and analyzed for possible deadlines and contract facts. +- Text-based PDFs are read locally when direct text is available. If no text is available, LifePilot uses the filename for naming/date hints and explains that OCR is needed later. +- After upload, `/documents` shows a Smart Brain assistant summary by default: what the document is, what matters, the next action, at most one question, and at most three prepared buttons. Raw text, all dates, facts, and technical metadata are hidden behind collapsed details. +- `/api/ai/document-brain` is server-only. If `OPENAI_API_KEY` is set, the route calls OpenAI with structured JSON and validates/sanitizes the result. If the key is missing or the provider fails, LifePilot returns the deterministic Brain fallback instead. +- The deterministic fallback recognizes employment termination letters such as `Kündigung Arbeitsverhältnis`, highlights the employment end date, the Agentur für Arbeit action, and the document date, and keeps unrelated dates such as a contract start date in hidden details. +- Images show honest prepared states. No fake OCR, AI, Gmail, Calendar, subscription, or payment flow is implemented in this sprint. +- Users can rename an uploaded document later without requiring the backend to be live. + +Required public Cognito Hosted UI variables for social login activation: + +```bash +NEXT_PUBLIC_COGNITO_DOMAIN= +NEXT_PUBLIC_COGNITO_CLIENT_ID= +NEXT_PUBLIC_COGNITO_REDIRECT_SIGN_IN=http://localhost:3000/auth/callback +NEXT_PUBLIC_COGNITO_REDIRECT_SIGN_OUT=http://localhost:3000/login +NEXT_PUBLIC_COGNITO_OAUTH_SCOPES=openid,email,profile +``` + +Do not put Google or Apple client secrets in frontend environment variables. + +Optional server-only Smart Brain variables: + +```bash +OPENAI_API_KEY= +OPENAI_MODEL=gpt-4.1-mini +``` + +Do not use `NEXT_PUBLIC_OPENAI_API_KEY`. OpenAI keys must never be exposed to the browser. + +What still needs Cognito console setup: + +1. Configure Cognito Hosted UI domain. +2. Configure Google provider in Cognito. +3. Configure Apple provider in Cognito. +4. Add local callback URL. +5. Add production callback URL. +6. Add logout URLs. +7. Add env variables in Vercel. +8. Test Google login locally. +9. Test Google login in production. +10. Test Apple login in production. +11. Confirm Cognito user id remains stable for saved contracts/reminders. + +Automatic document naming uses this priority: + +1. For TXT content, LifePilot looks for provider, document type, and date, for example `Vodafone Rechnung 12.06.2026`, `Allianz Versicherung 2026`, `Jobcenter Schreiben 15.07.2026`, or `Mietvertrag 2026`. +2. For text-based PDFs, LifePilot first tries direct PDF text extraction and then uses the same deterministic naming rules. +3. If only the file name is available, scanner-style names are cleaned and made human-readable. German dates like `17.4.25`, `21.4.25`, `17.04.2025`, and `21.04.2025` are normalized. +4. Invoice filenames can produce clean names such as `Rechnung 17.04.2025 bis 21.04.2025`. +5. If there is not enough information, LifePilot falls back to `Dokument vom `. + +Naming confidence is stored/displayed as `high`, `medium`, or `low`. + +Upload pipeline next: + +1. Live S3 upload validation. +2. Stronger PDF extraction validation across browser/device combinations. +3. OCR for photos/scans. +4. Connect prepared reminder/task buttons to real confirmed actions. +5. Persist Brain results server-side after AWS document storage is live. + +What works locally: + +- Mock/dev login, including reload-friendly fake session state. +- TXT naming and deterministic TXT analysis in the browser. +- Text-based PDF extraction in the browser when direct text is available. +- Filename-based fallback naming/date detection, including `17.4.25` and `21.4.25`. +- Smart Brain document summaries through server-only OpenAI when configured, with deterministic fallback when not configured. +- Local/dev upload fallback when the backend is unavailable. +- Browser-local analysis results, extracted facts, contract records, and reminders. + +What needs backend/AWS deploy: + +- Production S3 document storage validation. +- Durable document metadata and analysis records. +- Cross-device sync. +- Durable Brain result storage and backend OCR processing. +- Hosted UI provider activation in Cognito. + +Manual browser test steps: + +1. Start the web app with `NEXT_PUBLIC_USE_MOCK_AUTH=true NEXT_PUBLIC_USE_MOCKS=true pnpm --filter @lifepilot/web dev`. +2. Open `http://localhost:3000/login`. +3. Confirm Google and Apple show as prepared/bald verfügbar when Hosted UI env vars are missing. +4. Sign in with any email/password in mock mode. +5. Open `http://localhost:3000/documents`. +6. Confirm there is one primary upload area: `Dokument hochladen` and `Datei auswählen oder hier ablegen`. +7. Upload a TXT file containing dates like `fällig am 12.06.2026` and `Kündigungsfrist endet am 30.11.2026`. +8. Expected TXT result: document uploaded, suggested name, Smart Brain summary shown first, raw text/dates/facts hidden behind collapsed details. +9. Upload `Rechnung_B-25-016_AN_12904_GB_17.4.25_bis_21.4.25.pdf`. +10. Expected PDF result: suggested name like `Rechnung 17.04.2025 bis 21.04.2025`; if direct PDF text is available, `PDF-Text wurde erkannt`; if not, `PDF enthält keinen direkt lesbaren Text`; in both cases Smart Brain shows only the most important findings first. +11. For PNG/JPG scans, expected result is uploaded document plus OCR message: OCR is prepared but not active. +12. Upload or paste a termination letter containing `Kündigung Arbeitsverhältnis`, `Berlin, den 06.02.2026`, `fristgerecht zum 24.02.2026`, `innerhalb von drei Tagen nach Erhalt`, and `14.11.2025`. Expected Smart Brain result: title `Kündigung Arbeitsverhältnis erkannt`, main findings for `24.02.2026`, Arbeitsuchendmeldung within 3 days after receipt, and `06.02.2026`; question `Wann hast du dieses Schreiben erhalten?`; buttons `Erinnerung erstellen`, `Aufgabe erstellen`, `Details anzeigen`; `14.11.2025` only in hidden details. + +## Previous Milestone: LifePilot Memory Core MVP The web app now focuses on the real LifePilot loop: @@ -41,7 +145,7 @@ What works now: - The user can confirm a detected deadline as a local reminder. - `/dashboard` uses local knowledge data for contracts, missing facts, possible cancellations, and action suggestions. - `/reminders` shows locally confirmed reminders with complete/delete actions. -- PDF and photo/OCR paths show honest preparation states. They do not fake extraction. +- PDF and photo/OCR paths show honest states. Text-based PDFs may be read locally; scanned PDFs and photos still need OCR later. Local/dev scope: @@ -69,7 +173,7 @@ Next milestones: 1. AWS deploy and live DynamoDB validation. 2. Document Detail Page. -3. Real PDF text extraction. +3. Stronger PDF extraction validation. 4. OCR for photos/letters. 5. AI document explanation. 6. Calendar export / ICS. @@ -189,7 +293,8 @@ Important boundaries: - LifePilot must not repeatedly ask for known facts. - Cancellation drafts are only drafts. - Offer comparison is only planned metadata. -- No automatic cancellation, email sending, banking call, comparison portal call, or external AI call happens in this milestone. +- No automatic cancellation, email sending, banking call, comparison portal call, or browser-side external AI call happens in this milestone. +- The optional document Brain AI call is server-only and falls back deterministically when `OPENAI_API_KEY` is not configured. ## LifePilot Memory Core MVP diff --git a/apps/web/app/api/_lib/local-api.ts b/apps/web/app/api/_lib/local-api.ts index ac7e492..50cc847 100644 --- a/apps/web/app/api/_lib/local-api.ts +++ b/apps/web/app/api/_lib/local-api.ts @@ -171,13 +171,16 @@ export const createLocalDocument = ( const documentId = `document-local-${Date.now()}`; const document: Document = { addedAt: new Date().toISOString(), + autoNamed: input.autoNamed, category: input.category, id: documentId, name: input.name, + namingConfidence: input.namingConfidence, notes: input.notes, recommendedAction: - "Review this local API demo item before connecting real storage.", - securityNote: "Local API demo only. No real file was uploaded or stored.", + "Nächster Schritt: Angaben prüfen.", + securityNote: + "Lokaler Entwicklungsmodus: Es wurde keine echte Datei produktiv gespeichert.", status: input.status, }; diff --git a/apps/web/app/api/ai/document-brain/route.ts b/apps/web/app/api/ai/document-brain/route.ts new file mode 100644 index 0000000..7f4e234 --- /dev/null +++ b/apps/web/app/api/ai/document-brain/route.ts @@ -0,0 +1,259 @@ +import { NextResponse } from "next/server"; +import OpenAI from "openai"; +import type { + BrainProviderStatus, + DocumentBrainInput, + DocumentBrainResult, +} from "@lifepilot/shared"; + +import { + createDeterministicDocumentBrainResult, + documentBrainSystemPrompt, + sanitizeDocumentBrainResult, +} from "../../../../src/services/documents"; + +export const runtime = "nodejs"; + +const defaultOpenAiModel = "gpt-4.1-mini"; + +export async function GET() { + return NextResponse.json({ + model: process.env.OPENAI_MODEL ?? defaultOpenAiModel, + providerStatus: process.env.OPENAI_API_KEY ? "active" : "not_configured", + }); +} + +export async function POST(request: Request) { + const input = normalizeDocumentBrainInput(await request.json().catch(() => ({}))); + + if (!process.env.OPENAI_API_KEY) { + return NextResponse.json( + createDeterministicDocumentBrainResult(input, "not_configured"), + ); + } + + const fallback = createDeterministicDocumentBrainResult(input, "fallback"); + + try { + const client = new OpenAI({ + apiKey: process.env.OPENAI_API_KEY, + timeout: 12_000, + }); + const completion = await client.chat.completions.create({ + messages: [ + { + content: documentBrainSystemPrompt, + role: "system", + }, + { + content: JSON.stringify(input), + role: "user", + }, + ], + model: process.env.OPENAI_MODEL ?? defaultOpenAiModel, + response_format: { + json_schema: { + name: "lifepilot_document_brain_result", + schema: documentBrainResultJsonSchema, + strict: false, + }, + type: "json_schema", + }, + temperature: 0.2, + }); + const content = completion.choices[0]?.message?.content; + + if (!content) { + return NextResponse.json(createFallback(input, "invalid_output")); + } + + const parsed = JSON.parse(content) as Partial; + + return NextResponse.json( + sanitizeDocumentBrainResult({ + ...fallback, + ...parsed, + needsUserConfirmation: true, + provider: "openai", + providerStatus: "active", + }), + ); + } catch { + return NextResponse.json(createFallback(input, "error")); + } +} + +function createFallback( + input: DocumentBrainInput, + providerStatus: BrainProviderStatus, +): DocumentBrainResult { + return createDeterministicDocumentBrainResult(input, providerStatus); +} + +function normalizeDocumentBrainInput(value: unknown): DocumentBrainInput { + const candidate = isRecord(value) ? value : {}; + + return { + deterministicDates: Array.isArray(candidate.deterministicDates) + ? candidate.deterministicDates + : [], + deterministicFacts: isRecord(candidate.deterministicFacts) + ? (candidate.deterministicFacts as unknown as DocumentBrainInput["deterministicFacts"]) + : undefined, + extractedText: + typeof candidate.extractedText === "string" + ? candidate.extractedText.slice(0, 20_000) + : undefined, + filename: + typeof candidate.filename === "string" + ? candidate.filename.slice(0, 300) + : undefined, + locale: "de-DE", + mimeType: + typeof candidate.mimeType === "string" + ? candidate.mimeType.slice(0, 120) + : undefined, + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +const evidenceSchema = { + additionalProperties: false, + properties: { + dateIso: { type: "string" }, + field: { type: "string" }, + label: { type: "string" }, + snippet: { type: "string" }, + }, + required: ["label"], + type: "object", +}; + +const actionSchema = { + additionalProperties: false, + properties: { + explanation: { type: "string" }, + label: { type: "string" }, + requiresConfirmation: { const: true, type: "boolean" }, + sourceEvidence: evidenceSchema, + suggestedDate: { type: "string" }, + type: { + enum: [ + "create_reminder", + "create_task", + "save_document", + "save_contract", + "review_details", + ], + type: "string", + }, + }, + required: ["explanation", "label", "requiresConfirmation", "type"], + type: "object", +}; + +const documentBrainResultJsonSchema = { + additionalProperties: false, + properties: { + confidence: { enum: ["low", "medium", "high"], type: "string" }, + hiddenDetails: { + items: { + additionalProperties: false, + properties: { + label: { type: "string" }, + section: { + enum: [ + "raw_text", + "all_dates", + "all_facts", + "technical", + "source_evidence", + ], + type: "string", + }, + value: { type: "string" }, + }, + required: ["label", "section", "value"], + type: "object", + }, + type: "array", + }, + importantFindings: { + items: { + additionalProperties: false, + properties: { + label: { type: "string" }, + sourceEvidence: evidenceSchema, + value: { type: "string" }, + }, + required: ["label", "value"], + type: "object", + }, + maxItems: 3, + type: "array", + }, + intent: { + enum: [ + "employment_termination", + "invoice", + "contract", + "insurance", + "authority_letter", + "identity_document", + "general_document", + ], + type: "string", + }, + needsUserConfirmation: { type: "boolean" }, + optionalQuestion: { + additionalProperties: false, + properties: { + id: { type: "string" }, + label: { type: "string" }, + placeholder: { type: "string" }, + question: { type: "string" }, + required: { type: "boolean" }, + sourceEvidence: evidenceSchema, + }, + required: ["id", "label", "question", "required"], + type: "object", + }, + primaryButtons: { + items: actionSchema, + maxItems: 3, + type: "array", + }, + provider: { enum: ["deterministic", "openai"], type: "string" }, + providerStatus: { + enum: ["active", "fallback", "not_configured", "error", "invalid_output"], + type: "string", + }, + recommendedAction: actionSchema, + riskLevel: { enum: ["low", "medium", "high"], type: "string" }, + simpleSummary: { type: "string" }, + sourceEvidence: { + items: evidenceSchema, + type: "array", + }, + title: { type: "string" }, + }, + required: [ + "confidence", + "hiddenDetails", + "importantFindings", + "intent", + "needsUserConfirmation", + "primaryButtons", + "provider", + "providerStatus", + "recommendedAction", + "riskLevel", + "simpleSummary", + "sourceEvidence", + "title", + ], + type: "object", +}; diff --git a/apps/web/app/api/ai/life-brain/route.ts b/apps/web/app/api/ai/life-brain/route.ts new file mode 100644 index 0000000..08d359d --- /dev/null +++ b/apps/web/app/api/ai/life-brain/route.ts @@ -0,0 +1,464 @@ +import { NextResponse } from "next/server"; +import OpenAI from "openai"; +import type { LifeBrainResult } from "../../../../src/services/brain/brain-types"; +import { + analyzeLifeBrainLocally, + isValidLifeBrainResultCandidate, + normalizeLifeBrainInput, + sanitizeLifeBrainResult, +} from "../../../../src/services/brain/life-brain-orchestrator"; + +export const runtime = "nodejs"; + +const defaultOpenAiModel = "gpt-4.1-mini"; + +export async function GET() { + return NextResponse.json({ + model: process.env.OPENAI_MODEL ?? defaultOpenAiModel, + providerStatus: process.env.OPENAI_API_KEY ? "active" : "not_configured", + }); +} + +export async function POST(request: Request) { + const expectedTestCode = process.env.LIFEPILOT_BRAIN_TEST_CODE; + const providedTestCode = request.headers.get("x-lifepilot-brain-test-code"); + + if (!expectedTestCode) { + return Response.json( + { + error: + "LifePilot Brain ist aktuell nicht freigeschaltet. Test-Code fehlt auf dem Server.", + }, + { status: 503 }, + ); + } + + if (providedTestCode !== expectedTestCode) { + return Response.json( + { + error: + "LifePilot Brain ist aktuell nur für interne Tests freigeschaltet.", + }, + { status: 403 }, + ); + } + const input = normalizeLifeBrainInput(await request.json().catch(() => ({}))); + const fallback = analyzeLifeBrainLocally(input); + const model = process.env.OPENAI_MODEL ?? defaultOpenAiModel; + + if (!input.rawText.trim()) { + return NextResponse.json( + withFallbackDebug(fallback, "No input text provided"), + ); + } + + const apiKey = process.env.OPENAI_API_KEY; + + if (!apiKey) { + return NextResponse.json( + withFallbackDebug(fallback, "OPENAI_API_KEY missing"), + ); + } + + try { + const client = new OpenAI({ + apiKey, + timeout: 15_000, + }); + const response = await client.responses.create({ + input: JSON.stringify({ + fallbackGuidance: fallback, + input, + }), + instructions: lifeBrainSystemPrompt, + model, + store: false, + temperature: 0.1, + text: { + format: { + description: + "LifePilot Brain analysis result as one JSON object. No markdown or prose.", + name: "lifepilot_life_brain_result", + schema: lifeBrainResultJsonSchema, + strict: true, + type: "json_schema", + }, + }, + }); + const content = response.output_text; + + if (!content) { + return NextResponse.json( + withFallbackDebug(fallback, "Invalid OpenAI JSON"), + ); + } + + let parsed: Partial; + + try { + parsed = JSON.parse(content) as Partial; + } catch { + return NextResponse.json( + withFallbackDebug(fallback, "Invalid OpenAI JSON"), + ); + } + + if (!isValidLifeBrainResultCandidate(parsed)) { + return NextResponse.json( + withFallbackDebug(fallback, "Invalid OpenAI JSON"), + ); + } + + return NextResponse.json( + withOpenAiDebug(sanitizeLifeBrainResult(parsed, fallback), model), + ); + } catch (error) { + return NextResponse.json( + withFallbackDebug(fallback, getSafeOpenAiFallbackReason(error)), + ); + } +} + +function withFallbackDebug( + result: LifeBrainResult, + fallbackReason: string, +): LifeBrainResult { + return { + ...result, + analysisMode: "fallback", + fallbackReason, + modelUsed: undefined, + }; +} + +function withOpenAiDebug( + result: LifeBrainResult, + modelUsed: string, +): LifeBrainResult { + return { + ...result, + analysisMode: "openai", + fallbackReason: undefined, + modelUsed, + }; +} + +function getSafeOpenAiFallbackReason(error: unknown): string { + if (error instanceof Error && error.name) { + return `OpenAI request failed: ${error.name.slice(0, 80)}`; + } + + return "OpenAI request failed"; +} + +const lifeBrainSystemPrompt = ` +Du bist LifePilot Brain, die zentrale Intelligenzschicht für private Lebensadministration. +Du analysierst deutsche Alltagstexte wie Briefe, Rechnungen, Verträge, Kündigungen, Amtsschreiben, Jobcenter-Schreiben, E-Mails, Nachrichtenverläufe und Kontoauszüge. + +Antworte ausschließlich als JSON im vorgegebenen Schema. +Gib keine Markdown-Blöcke, keine Code-Fences, keine Erklärungen und keinen Text außerhalb des JSON-Objekts aus. +Sprich in einfachen, ruhigen deutschen Formulierungen. +Extrahiere nur nützliche reale Handlungen. +Erfinde keine Fristen, Beträge, Personen oder Organisationen. +Behandle nicht jedes Datum als Frist. +Trenne Kontextdaten von handlungsrelevanten Daten. +Gib genau einen starken empfohlenen nächsten Schritt. +Risikoreiche Handlungen werden nur vorbereitet und erfordern Bestätigung. +Keine externen Aktionen ausführen. + +False-Positive-Regeln: +- Kontoauszüge sind normalerweise nur zum Ablegen. Buchungsdaten sind keine Fristen. +- Verträge: Vertragsbeginn ist Kontext. Kündigungsfrist nur bei klarer Kündigungsformulierung. +- Rechnungen: Zahlungsfrist nur bei "zahlbar bis", "bitte zahlen Sie bis", "spätestens bis", "fällig am" oder ähnlicher Zahlungsformulierung. +- Amtsschreiben: Widerspruch, Einspruch, Unterlagen einreichen, Nachreichung, Rückmeldung und Termin sind handlungsrelevant. +- E-Mails: Erkenne Antwortbedarf, angeforderte Unterlagen, Fristen, Termine und ob ein Antwortentwurf hilfreich ist. + +Setze rawDetailsCollapsed immer auf true. +`.trim(); + +const prioritySchema = { + enum: ["low", "medium", "high", "critical"], + type: "string", +}; + +const lifeBrainResultJsonSchema = { + additionalProperties: false, + properties: { + actions: { + items: { + additionalProperties: false, + properties: { + description: { type: "string" }, + dueDate: { type: ["string", "null"] }, + id: { type: "string" }, + priority: prioritySchema, + requiresConfirmation: { type: "boolean" }, + time: { type: ["string", "null"] }, + title: { type: "string" }, + type: { + enum: [ + "pay", + "reply", + "attend_appointment", + "cancel_contract", + "create_reminder", + "create_task", + "review_document", + "provide_missing_info", + "archive_only", + "call_someone", + "send_document", + "wait", + "no_action", + ], + type: "string", + }, + }, + required: [ + "description", + "dueDate", + "id", + "priority", + "requiresConfirmation", + "time", + "title", + "type", + ], + type: "object", + }, + type: "array", + }, + appointments: { + items: { + additionalProperties: false, + properties: { + date: { type: "string" }, + description: { type: "string" }, + location: { type: ["string", "null"] }, + shouldGenerateIcs: { type: "boolean" }, + time: { type: ["string", "null"] }, + title: { type: "string" }, + }, + required: [ + "date", + "description", + "location", + "shouldGenerateIcs", + "time", + "title", + ], + type: "object", + }, + type: "array", + }, + brainVersion: { type: "string" }, + category: { type: "string" }, + clarificationQuestion: { type: ["string", "null"] }, + confidence: { type: "number" }, + deadlines: { + items: { + additionalProperties: false, + properties: { + date: { type: "string" }, + priority: prioritySchema, + reason: { type: "string" }, + shouldCreateReminder: { type: "boolean" }, + sourceText: { type: "string" }, + time: { type: ["string", "null"] }, + title: { type: "string" }, + }, + required: [ + "date", + "priority", + "reason", + "shouldCreateReminder", + "sourceText", + "time", + "title", + ], + type: "object", + }, + type: "array", + }, + detectedAmounts: { + items: { + additionalProperties: false, + properties: { + amount: { type: ["number", "null"] }, + context: { type: "string" }, + currency: { type: "string" }, + rawText: { type: "string" }, + }, + required: ["amount", "context", "currency", "rawText"], + type: "object", + }, + type: "array", + }, + detectedDates: { + items: { + additionalProperties: false, + properties: { + context: { type: "string" }, + isActionable: { type: "boolean" }, + isoDate: { type: ["string", "null"] }, + rawText: { type: "string" }, + reason: { type: "string" }, + time: { type: ["string", "null"] }, + }, + required: [ + "context", + "isActionable", + "isoDate", + "rawText", + "reason", + "time", + ], + type: "object", + }, + type: "array", + }, + detectedLanguage: { enum: ["de", "en", "unknown"], type: "string" }, + detectedOrganizations: { items: { type: "string" }, type: "array" }, + detectedPeople: { items: { type: "string" }, type: "array" }, + documentType: { + enum: [ + "invoice", + "contract", + "termination", + "bank_statement", + "official_letter", + "appointment_notice", + "payment_reminder", + "insurance", + "jobcenter_or_employment_agency", + "tax", + "rental", + "medical", + "legal_notice", + "email_request", + "email_thread", + "conversation", + "unknown", + ], + type: "string", + }, + importantFacts: { + items: { + additionalProperties: false, + properties: { + importance: { enum: ["low", "medium", "high"], type: "string" }, + label: { type: "string" }, + value: { type: "string" }, + }, + required: ["importance", "label", "value"], + type: "object", + }, + type: "array", + }, + inputType: { type: "string" }, + rawDetailsCollapsed: { enum: [true], type: "boolean" }, + recommendedNextStep: { + additionalProperties: false, + properties: { + actionType: { type: "string" }, + description: { type: "string" }, + title: { type: "string" }, + }, + required: ["actionType", "description", "title"], + type: "object", + }, + reminders: { + items: { + additionalProperties: false, + properties: { + reason: { type: "string" }, + remindAt: { type: "string" }, + title: { type: "string" }, + }, + required: ["reason", "remindAt", "title"], + type: "object", + }, + type: "array", + }, + requiresUserConfirmation: { type: "boolean" }, + riskLevel: { + enum: ["none", "low", "medium", "high", "critical"], + type: "string", + }, + shouldArchiveOnly: { type: "boolean" }, + shouldCreateReminder: { type: "boolean" }, + shouldCreateTask: { type: "boolean" }, + shouldGenerateIcs: { type: "boolean" }, + shortSummary: { type: "string" }, + source: { type: "string" }, + suggestedReply: { + anyOf: [ + { + additionalProperties: false, + properties: { + body: { type: "string" }, + subject: { type: "string" }, + tone: { enum: ["formal", "neutral", "friendly"], type: "string" }, + }, + required: ["body", "subject", "tone"], + type: "object", + }, + { type: "null" }, + ], + }, + tasks: { + items: { + additionalProperties: false, + properties: { + description: { type: "string" }, + dueDate: { type: ["string", "null"] }, + priority: prioritySchema, + title: { type: "string" }, + }, + required: ["description", "dueDate", "priority", "title"], + type: "object", + }, + type: "array", + }, + title: { type: "string" }, + urgency: { + enum: ["none", "low", "medium", "high", "critical"], + type: "string", + }, + userFriendlyExplanation: { type: "string" }, + }, + required: [ + "actions", + "appointments", + "brainVersion", + "category", + "clarificationQuestion", + "confidence", + "deadlines", + "detectedAmounts", + "detectedDates", + "detectedLanguage", + "detectedOrganizations", + "detectedPeople", + "documentType", + "importantFacts", + "inputType", + "rawDetailsCollapsed", + "recommendedNextStep", + "reminders", + "requiresUserConfirmation", + "riskLevel", + "shouldArchiveOnly", + "shouldCreateReminder", + "shouldCreateTask", + "shouldGenerateIcs", + "shortSummary", + "source", + "suggestedReply", + "tasks", + "title", + "urgency", + "userFriendlyExplanation", + ], + type: "object", +}; diff --git a/apps/web/app/auth/callback/page.tsx b/apps/web/app/auth/callback/page.tsx new file mode 100644 index 0000000..6b6d999 --- /dev/null +++ b/apps/web/app/auth/callback/page.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { CheckCircle2, Loader2, ShieldAlert } from "lucide-react"; + +import { authService } from "../../../src/services/auth"; + +type CallbackState = "loading" | "success" | "error"; + +export default function OAuthCallbackPage() { + const router = useRouter(); + const [state, setState] = useState("loading"); + const [message, setMessage] = useState("Anmeldung wird abgeschlossen..."); + + useEffect(() => { + let timeoutId: number | undefined; + + authService + .handleOAuthCallback() + .then(() => { + setState("success"); + setMessage("Anmeldung erfolgreich. Du wirst weitergeleitet."); + timeoutId = window.setTimeout(() => router.replace("/dashboard"), 700); + }) + .catch((error) => { + const errorMessage = + error instanceof Error + ? error.message + : "Anmeldung konnte nicht abgeschlossen werden."; + + setState("error"); + setMessage( + errorMessage.includes("noch nicht aktiviert") + ? "Diese Anmeldemethode ist noch nicht aktiviert." + : "Anmeldung konnte nicht abgeschlossen werden.", + ); + }); + + return () => { + if (timeoutId) { + window.clearTimeout(timeoutId); + } + }; + }, [router]); + + const Icon = + state === "success" ? CheckCircle2 : state === "error" ? ShieldAlert : Loader2; + + return ( +
+
+
+
+

+ {state === "loading" + ? "Anmeldung wird abgeschlossen..." + : state === "success" + ? "Anmeldung erfolgreich. Du wirst weitergeleitet." + : "Anmeldung konnte nicht abgeschlossen werden."} +

+

+ {message} +

+
+
+ ); +} diff --git a/apps/web/app/contracts/contracts-client.tsx b/apps/web/app/contracts/contracts-client.tsx index 7f2d59d..0c52ede 100644 --- a/apps/web/app/contracts/contracts-client.tsx +++ b/apps/web/app/contracts/contracts-client.tsx @@ -114,14 +114,18 @@ export function ContractsClient() { void refreshContracts(); }, []); + const safeContracts = useMemo(() => asArray(contracts), [contracts]); const filteredContracts = useMemo( - () => contracts.filter((contract) => matchesFilter(contract, activeFilter)), - [activeFilter, contracts], + () => + safeContracts.filter((contract) => + matchesFilter(contract, activeFilter), + ), + [activeFilter, safeContracts], ); - const missingCount = contracts.filter( - (contract) => contract.missingFacts.length > 0, + const missingCount = safeContracts.filter( + (contract) => asArray(contract.missingFacts).length > 0, ).length; - const cancellationSoonCount = contracts.filter( + const cancellationSoonCount = safeContracts.filter( (contract) => contract.lifecycleStatus === "cancellable-now" || contract.lifecycleStatus === "cancellation-window-upcoming", @@ -133,7 +137,7 @@ export function ContractsClient() { icon: CreditCard, label: "Unter Beobachtung", meta: "Aus geprüften Dokumenten", - value: String(contracts.length), + value: String(safeContracts.length), visual: "document", }, { @@ -157,7 +161,7 @@ export function ContractsClient() { icon: FileText, label: "Entwürfe", meta: "Nur lokal vorbereitet", - value: String(contracts.filter((contract) => contract.actionDraft).length), + value: String(safeContracts.filter((contract) => contract.actionDraft).length), visual: "sparkles", }, ] as const; @@ -165,7 +169,7 @@ export function ContractsClient() { const refreshContracts = async () => { const result = await listPersistedContracts(); - setContracts(result.data); + setContracts(asArray(result.data)); setPersistenceMessage(result.message); }; @@ -331,6 +335,7 @@ function ContractBrainCard({ }) { const [missingValues, setMissingValues] = useState>({}); const [draftBody, setDraftBody] = useState(contract.actionDraft?.body ?? ""); + const missingFacts = asArray(contract.missingFacts); useEffect(() => { setDraftBody(contract.actionDraft?.body ?? ""); @@ -339,7 +344,7 @@ function ContractBrainCard({ const saveMissingValues = async () => { let updatedContract: ContractRecord | null = contract; - contract.missingFacts.forEach((missingFact) => { + missingFacts.forEach((missingFact) => { const value = missingValues[missingFact.key]?.trim(); if (value) { @@ -386,7 +391,7 @@ function ContractBrainCard({ {contract.provider ?? contract.name} - {categoryLabels[contract.category]} + {categoryLabels[contract.category] ?? categoryLabels.other} Datenquelle:{" "} @@ -396,43 +401,43 @@ function ContractBrainCard({

- {actionLabels[contract.brain.recommendedAction]} + {actionLabels[contract.brain?.recommendedAction ?? "contract-review-needed"]}

- {lifecycleLabels[contract.lifecycleStatus]} + {lifecycleLabels[contract.lifecycleStatus] ?? lifecycleLabels.unknown}
- +
- {contract.missingFacts.length > 0 ? ( + {missingFacts.length > 0 ? (

Fehlende Angaben ergänzen

- {contract.missingFacts.map((missingFact) => ( + {missingFacts.map((missingFact) => (