From 0aafbf6e912ee6abf0c7585bd6d0ba684a4297bd Mon Sep 17 00:00:00 2001 From: Uli-Z <58443149+Uli-Z@users.noreply.github.com> Date: Sun, 16 Nov 2025 19:01:24 +0100 Subject: [PATCH 01/31] feat(import): add generic import infrastructure and registry - Introduce ImportFormat interface and in-memory registry for adapters - Add registry helper to detect formats and delegate parsing - Add file import builder to parse, collect errors, and compute participant summaries via balances - Establish clear types for parsed group meta (name, currency, participants) --- src/lib/imports/file-import.ts | 78 ++++++++++++++++++++++++++++++++++ src/lib/imports/registry.ts | 41 ++++++++++++++++++ src/lib/imports/types.ts | 62 +++++++++++++++++++++++++++ 3 files changed, 181 insertions(+) create mode 100644 src/lib/imports/file-import.ts create mode 100644 src/lib/imports/registry.ts create mode 100644 src/lib/imports/types.ts diff --git a/src/lib/imports/file-import.ts b/src/lib/imports/file-import.ts new file mode 100644 index 000000000..c3012c415 --- /dev/null +++ b/src/lib/imports/file-import.ts @@ -0,0 +1,78 @@ +import { getBalances } from '@/lib/balances' +import { parseWithDetectionInternal } from '@/lib/imports/registry' +import { ExpenseFormValues } from '@/lib/schemas' + +// Return type of the import builder. +export type ImportParticipantSummary = { name: string; balance: number } +export type ImportBuildResult = { + expenses: ExpenseFormValues[] + errors: { row: number; message: string }[] + participantSummaries: ImportParticipantSummary[] + group?: import('@/lib/imports/types').ImportParsedGroupInfo + format: { id: string; label: string } +} + +// Compute net balance per participant from internal expenses for preview purposes. +// +// This leverages the existing getBalances() algorithm to ensure consistency with +// app-wide balance logic (split modes, rounding behavior). We adapt the +// internal form values into the minimal structure expected by getBalances. +const buildParticipantSummariesFromInternal = ( + expenses: ExpenseFormValues[], + group?: import('@/lib/imports/types').ImportParsedGroupInfo, +) => { + // Adapt form values to the shape consumed by getBalances. + const previewExpenses = expenses.map((e) => ({ + amount: Math.round(e.amount), + splitMode: e.splitMode, + isReimbursement: e.isReimbursement, + paidBy: { id: e.paidBy }, + paidFor: e.paidFor.map((p) => ({ + participant: { id: p.participant }, + shares: Number(p.shares), + })), + })) as any + + const balances = getBalances(previewExpenses) + + // Build a mapping from external participant id -> display name if provided by adapter. + const idToName = new Map() + if (group?.participants) { + for (const p of group.participants) { + const pid = (p.id ?? '').trim() + const pname = p.name?.trim() + if (pid && pname) idToName.set(pid, pname) + } + } + + // Map balances by participant id to {name, balance} entries, prefer nice names. + return Object.entries(balances).map(([participantId, { total }]) => ({ + name: idToName.get(participantId) ?? participantId, + balance: total, + })) +} + +// Convert a parsed/normalized import file into ExpenseFormValues for a group. +// Design notes: +// - Focused on the creation flow. +// - Omits deduplication and category-mapping heuristics. +export async function buildExpensesFromFileImport( + fileContent: string, +): Promise { + const trimmed = fileContent.trim() + if (!trimmed) throw new Error('Uploaded file was empty.') + + const { expenses, errors, group, format } = + parseWithDetectionInternal(trimmed) + + return { + expenses, + errors, + participantSummaries: buildParticipantSummariesFromInternal( + expenses, + group, + ), + group, + format: { id: format.id, label: format.label }, + } +} diff --git a/src/lib/imports/registry.ts b/src/lib/imports/registry.ts new file mode 100644 index 000000000..1dd5bf9ad --- /dev/null +++ b/src/lib/imports/registry.ts @@ -0,0 +1,41 @@ +import { + ImportFormat, + importFormats, + type ImportParsedGroupInfo, +} from '@/lib/imports/types' +// Self-registering formats (side effect imports) +import '@/lib/imports/formats/debug-format' +import '@/lib/imports/spliit-json' + +// Registry entry points +// +// This module exposes small helpers that orchestrate the format registry. +// Responsibilities: +// - Collect all self‑registering formats +// - Run detection on content + context to find the best matching adapter +// - Delegate parsing to the selected adapter into internal ExpenseFormValues + +// Choose the most likely format for given content. +// Returns the selected ImportFormat instance or null if none match. +export function detectFormat(content: string): ImportFormat | null { + return importFormats.detect(content) +} + +// Parse a file into internal form values using the detected format. +// +// - Detection: calls detectFormat(...) to select an adapter based on content head and context. +// - Parsing: invokes the adapter's parseToInternal(...) to produce ExpenseFormValues. +// - Meta: adapters may optionally return meta information (e.g., source currency/name). +export function parseWithDetectionInternal(content: string): { + expenses: import('@/lib/schemas').ExpenseFormValues[] + format: ImportFormat + group?: ImportParsedGroupInfo + errors: { row: number; message: string }[] +} { + const format = detectFormat(content) + if (!format) throw new Error('Unsupported file format.') + if (!format.parseToInternal) + throw new Error('Format does not support internal parsing.') + const { expenses, group, errors } = format.parseToInternal(content) + return { expenses, group, format, errors: errors ?? [] } +} diff --git a/src/lib/imports/types.ts b/src/lib/imports/types.ts new file mode 100644 index 000000000..645ab95b1 --- /dev/null +++ b/src/lib/imports/types.ts @@ -0,0 +1,62 @@ +import type { ExpenseFormValues } from '@/lib/schemas' + +// Interface a format adapter must implement. +// +// Each adapter provides: +// - id/label/priority: identity + tie‑breaks +// - detect: structural detection on full content (0..1) +// - parseToInternal: full parse → ExpenseFormValues +export type ImportParsedGroupInfo = { + // Optional group name from the file (if present) + name?: string + // Display currency symbol (e.g., "€") and ISO code (e.g., "EUR") if available + currency?: string + currencyCode?: string + // Optional participant list provided by the file (names preferred for display) + participants?: { id?: string; name: string }[] +} + +export interface ImportFormat { + // Unique, stable identifier (e.g. "spliit-json"). + id: string + // Human-readable label (e.g. "Spliit JSON"). + label: string + // Priority used to break ties between formats with equal detection scores. + priority: number + // Detection on the full content. Return 0 to opt out. + // Implementations may do a fast check or a full parse depending on format. + detect(content: string): number + // Parse full content into internal values. + parseToInternal?: (content: string) => { + expenses: ExpenseFormValues[] + group?: ImportParsedGroupInfo + errors?: { row: number; message: string }[] + } +} + +// Simple in-memory registry of available formats. +export class ImportFormatRegistry { + private formats: ImportFormat[] = [] + register(format: ImportFormat) { + this.formats.push(format) + } + list(): ImportFormat[] { + return [...this.formats].sort((a, b) => b.priority - a.priority) + } + // Selection strategy for detect(): + // - Provide full content to each format. + // - Each format returns a score in [0..1]; 0 means "not a candidate". + // - Rank by score (desc), then by format.priority (desc) for deterministic tie‑breaks. + // - Return the top candidate or null if no format opts in. + detect(content: string) { + const candidates = this.list() + .map((f) => ({ format: f, score: f.detect(content) })) + .filter((x) => x.score > 0) + .sort( + (a, b) => b.score - a.score || b.format.priority - a.format.priority, + ) + return candidates[0]?.format ?? null + } +} + +export const importFormats = new ImportFormatRegistry() From 74eec04f516be1dec6a38ce8db5b00e4ab81da08 Mon Sep 17 00:00:00 2001 From: Uli-Z <58443149+Uli-Z@users.noreply.github.com> Date: Sun, 16 Nov 2025 19:01:24 +0100 Subject: [PATCH 02/31] feat(format): add Spliit-JSON adapter with detection and parsing - Implement robust detection on full JSON payload with minimal structure checks - Parse export into ExpenseFormValues; coerce amounts/dates and validate against schema - Aggregate per-row errors and expose optional group meta (name, currency, participants) - Self-register adapter in the global registry --- src/lib/imports/spliit-json.ts | 280 +++++++++++++++++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 src/lib/imports/spliit-json.ts diff --git a/src/lib/imports/spliit-json.ts b/src/lib/imports/spliit-json.ts new file mode 100644 index 000000000..7bfd3ac74 --- /dev/null +++ b/src/lib/imports/spliit-json.ts @@ -0,0 +1,280 @@ +import { importFormats, type ImportFormat } from '@/lib/imports/types' +import { expenseFormSchema, type ExpenseFormValues } from '@/lib/schemas' + +// Detection on full content: parse JSON and validate minimal structure. +// Requirements: +// - top-level object with arrays: participants[], expenses[] (arrays can be empty) +// - participant objects should have at least one of: name or id (string) +// - expense objects should include: paidById (string), paidFor (array), amount, expenseDate, title +export const looksLikeSpliitJson = (content: string) => { + try { + const root = JSON.parse(content) + if (!root || typeof root !== 'object') return false + const participants = (root as any).participants + const expenses = (root as any).expenses + if (!Array.isArray(participants) || !Array.isArray(expenses)) return false + + // Check a few participant entries (or pass if empty) + if (participants.length > 0) { + const p: any = participants[0] + const hasName = typeof p?.name === 'string' + const hasId = typeof p?.id === 'string' + if (!(hasName || hasId)) return false + } + + // Check a few expense entries (or pass if empty) + if (expenses.length > 0) { + const e: any = expenses[0] + const ok = + typeof e?.paidById === 'string' && + Array.isArray(e?.paidFor) && + (typeof e?.amount === 'number' || typeof e?.amount === 'string') && + (typeof e?.expenseDate === 'string' || + typeof e?.expenseDate === 'number') && + typeof e?.title === 'string' + if (!ok) return false + } + return true + } catch { + return false + } +} + +// Small helpers for coercion with clear error messages (used within parseToInternal) +const coerceNumber = (value: unknown, label: string): number => { + const n = typeof value === 'string' ? Number(value) : value + if (typeof n !== 'number' || !Number.isFinite(n)) { + throw new Error(`Invalid ${label} value in export`) + } + return n +} +const normalize = (value: unknown) => + String(value ?? '') + .trim() + .toLowerCase() + +// Seeded category ids (see prisma/migrations/20240108194443_add_categories/migration.sql) +const CATEGORY_LOOKUP: Record = { + 'uncategorized|general': 0, + 'uncategorized|payment': 1, + 'entertainment|entertainment': 2, + 'entertainment|games': 3, + 'entertainment|movies': 4, + 'entertainment|music': 5, + 'entertainment|sports': 6, + 'food and drink|food and drink': 7, + 'food and drink|dining out': 8, + 'food and drink|groceries': 9, + 'food and drink|liquor': 10, + 'home|home': 11, + 'home|electronics': 12, + 'home|furniture': 13, + 'home|household supplies': 14, + 'home|maintenance': 15, + 'home|mortgage': 16, + 'home|pets': 17, + 'home|rent': 18, + 'home|services': 19, + 'life|childcare': 20, + 'life|clothing': 21, + 'life|education': 22, + 'life|gifts': 23, + 'life|insurance': 24, + 'life|medical expenses': 25, + 'life|taxes': 26, + 'transportation|transportation': 27, + 'transportation|bicycle': 28, + 'transportation|bus/train': 29, + 'transportation|car': 30, + 'transportation|gas/fuel': 31, + 'transportation|hotel': 32, + 'transportation|parking': 33, + 'transportation|plane': 34, + 'transportation|taxi': 35, + 'utilities|utilities': 36, + 'utilities|cleaning': 37, + 'utilities|electricity': 38, + 'utilities|heat/gas': 39, + 'utilities|trash': 40, + 'utilities|tv/phone/internet': 41, + 'utilities|water': 42, + 'life|donation': 43, // see follow-up migration 20250308000000_add_category_donation +} + +const resolveCategoryId = (value: unknown): number => { + // Numeric ids pass through. + if (typeof value === 'number' && Number.isFinite(value)) + return Math.max(0, Math.trunc(value)) + + // Strings that are numeric -> numeric id. + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Number(value) + if (Number.isFinite(parsed)) return Math.max(0, Math.trunc(parsed)) + } + + // Object shape with grouping/name (as exported by Spliit). + if (value && typeof value === 'object') { + const grouping = normalize((value as any).grouping) + const name = normalize((value as any).name) + const key = `${grouping}|${name}` + if (CATEGORY_LOOKUP[key] !== undefined) return CATEGORY_LOOKUP[key] + // Fallback: try by name only + const byName = Object.entries(CATEGORY_LOOKUP).find( + ([k]) => k.split('|')[1] === name, + ) + if (byName) return byName[1] + } + + // Default: uncategorized + return 0 +} +const coerceDate = (value: unknown): Date => { + if (value instanceof Date) return value + const str = String(value ?? '').trim() + if (!str) throw new Error('Missing expense date in export') + const date = new Date(str) + if (Number.isNaN(date.getTime())) + throw new Error('Invalid expense date in export') + return date +} + +export class SpliitJsonFormat implements ImportFormat { + id = 'spliit-json' + label = 'Spliit JSON' + priority = 100 + + detect(content: string): number { + // Parse and validate minimal structure using the full content. + return looksLikeSpliitJson(content) ? 0.95 : 0 + } + + // Convert parsed export into internal ExpenseFormValues. + // Participant ids remain as synthesized participant- from parseSpliitJson. + parseToInternal(content: string): { + expenses: ExpenseFormValues[] + group?: import('@/lib/imports/types').ImportParsedGroupInfo + errors?: { row: number; message: string }[] + } { + // Parse complete JSON + let raw: any + try { + raw = JSON.parse(content) + } catch { + throw new Error('Invalid JSON: unable to parse file contents.') + } + + // Assume a well-formed export and avoid strict participant validation here. + // Any malformed expense will be skipped and reported. + + const expenses: ExpenseFormValues[] = [] + const errors: { row: number; message: string }[] = [] + + const rawExpenses = Array.isArray(raw?.expenses) ? raw.expenses : [] + + // Build a lookup from external participant id -> display name. + // Fallbacks ensure we always get a stable, human-readable name. + const externalIdToName = new Map() + const participantNames: string[] = [] + if (Array.isArray(raw?.participants)) { + raw.participants.forEach((p: any, i: number) => { + const id = typeof p?.id === 'string' ? p.id.trim() : '' + const nameRaw = typeof p?.name === 'string' ? p.name.trim() : '' + const name = nameRaw || id || `Participant ${i + 1}` + participantNames.push(name) + if (id) externalIdToName.set(id, name) + }) + } + + // Build paidFor list from raw entries. + // Rules: + // - Keep only the first entry per participantId (ignore duplicates) + // - Coerce shares to positive integers (min 1) + const parsePaidFor = (entries: any[]): ExpenseFormValues['paidFor'] => { + const seen = new Set() + const paidFor: ExpenseFormValues['paidFor'] = [] + entries.forEach((pf: any) => { + const rawId = String(pf?.participantId ?? '').trim() + if (!rawId) throw new Error('paidFor without participantId') + if (seen.has(rawId)) return + seen.add(rawId) + const shares = Math.max( + 1, + Math.trunc(coerceNumber(pf?.shares ?? 1, 'shares')), + ) + const name = externalIdToName.get(rawId) ?? rawId + paidFor.push({ participant: name, shares, originalAmount: undefined }) + }) + return paidFor + } + + // Build the minimal ExpenseFormValues input for schema parsing. + // Applies light coercion + sensible defaults; schema handles the rest. + const toFormBase = ( + e: any, + index: number, + paidBy: string, + paidFor: ExpenseFormValues['paidFor'], + ) => ({ + expenseDate: coerceDate(e?.expenseDate), + title: String(e?.title ?? '').trim() || `Expense ${index + 1}`, + category: 0, + amount: Math.round(coerceNumber(e?.amount, 'amount')), + originalAmount: + e?.originalAmount != null + ? coerceNumber(e.originalAmount, 'originalAmount') + : undefined, + originalCurrency: e?.originalCurrency ?? undefined, + conversionRate: + e?.conversionRate != null + ? coerceNumber(e.conversionRate, 'conversionRate') + : undefined, + paidBy, + paidFor, + splitMode: (e?.splitMode as ExpenseFormValues['splitMode']) ?? 'EVENLY', + saveDefaultSplittingOptions: false, + isReimbursement: e?.isReimbursement ?? false, + documents: [], + notes: e?.notes ?? undefined, + recurrenceRule: + (e?.recurrenceRule as ExpenseFormValues['recurrenceRule']) ?? 'NONE', + }) + + rawExpenses.forEach((e: any, index: number) => { + try { + const paidByRaw = String(e?.paidById ?? '').trim() + if (!paidByRaw) throw new Error('Missing paidById') + const paidBy = externalIdToName.get(paidByRaw) ?? paidByRaw + + const paidFor = parsePaidFor(Array.isArray(e?.paidFor) ? e.paidFor : []) + const base = toFormBase(e, index, paidBy, paidFor) + const result = expenseFormSchema.safeParse(base) + if (result.success) expenses.push(result.data) + else { + const message = + result.error.issues.map((i) => i.message).join(', ') || + 'Invalid expense' + errors.push({ row: index + 1, message }) + } + } catch (err: any) { + errors.push({ + row: index + 1, + message: String(err?.message ?? 'Invalid expense'), + }) + } + }) + + const group = { + name: raw?.name ?? undefined, + currency: raw?.currency ?? undefined, + currencyCode: raw?.currencyCode ?? undefined, + participants: participantNames.length + ? participantNames.map((name) => ({ name })) + : undefined, + } + + return { expenses, group, errors } + } +} + +// Self-register the format with the global registry. +importFormats.register(new SpliitJsonFormat()) From 742137f0e6e3642def8c7de2c8fad9e691baf76a Mon Sep 17 00:00:00 2001 From: Uli-Z <58443149+Uli-Z@users.noreply.github.com> Date: Sun, 16 Nov 2025 19:01:24 +0100 Subject: [PATCH 03/31] feat(format): add Debug adapter and fixtures for error-path testing - Add marker-based debug format (DEBUG_IMPORT/DEBUG_ERRORS) with unambiguous detection - Emit one error per line for quick UI testing of failure paths - Include simple fixture file for manual verification - Register debug adapter with registry at low priority --- src/lib/imports/fixtures/debug-errors.txt | 6 +++ src/lib/imports/formats/debug-format.ts | 61 +++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 src/lib/imports/fixtures/debug-errors.txt create mode 100644 src/lib/imports/formats/debug-format.ts diff --git a/src/lib/imports/fixtures/debug-errors.txt b/src/lib/imports/fixtures/debug-errors.txt new file mode 100644 index 000000000..6ea9947a0 --- /dev/null +++ b/src/lib/imports/fixtures/debug-errors.txt @@ -0,0 +1,6 @@ +DEBUG_ERRORS + +Invalid amount in row 3 +Missing paidById +Invalid expense date: 2024-99-99 +Participant ID not found: user-xyz diff --git a/src/lib/imports/formats/debug-format.ts b/src/lib/imports/formats/debug-format.ts new file mode 100644 index 000000000..497b78dda --- /dev/null +++ b/src/lib/imports/formats/debug-format.ts @@ -0,0 +1,61 @@ +import { importFormats, type ImportFormat } from '@/lib/imports/types' + +// Extremely simple debug format to aid manual testing. +// +// Expected content: +// DEBUG_IMPORT\n +// { ...Spliit-JSON payload... } +// +// Rationale: +// - Keeps detection unambiguous (hard prefix), avoiding conflicts with real formats +// - Reuses Spliit-JSON parsing for convenience when a JSON payload follows the marker +// - Allows devs to quickly craft debug fixtures without changing the main adapters + +export class DebugImportFormat implements ImportFormat { + id = 'debug' + label = 'Debug Import' + priority = 5 + + private stripPrefix(content: string) { + const trimmed = content.trimStart() + const prefixes = ['DEBUG_IMPORT', 'DEBUG_ERRORS'] + for (const prefix of prefixes) { + if (trimmed.startsWith(prefix)) { + const idx = trimmed.indexOf('\n') + return idx >= 0 ? trimmed.slice(idx + 1) : '' + } + } + return content + } + + detect(content: string): number { + const trimmed = content.trimStart() + if ( + trimmed.startsWith('DEBUG_IMPORT') || + trimmed.startsWith('DEBUG_ERRORS') + ) + return 0.99 + return 0 + } + + parseToInternal(content: string) { + // Debug format: only emit errors from payload lines after marker. + // Usage: + // DEBUG_ERRORS\n + // + // or: + // DEBUG_IMPORT\n + // + const body = this.stripPrefix(content) + const errors = body + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((message, index) => ({ row: index + 1, message })) + + return { expenses: [], group: undefined, errors } + } +} + +// Self-register +importFormats.register(new DebugImportFormat()) From 08af1c1ea0dcc332b1de3a07e0cc0f42b2f5556b Mon Sep 17 00:00:00 2001 From: Uli-Z <58443149+Uli-Z@users.noreply.github.com> Date: Sun, 16 Nov 2025 19:01:24 +0100 Subject: [PATCH 04/31] feat(trpc): add import endpoints (preview, start, run-chunk, cancel, finalize) - Expose preview endpoint to parse and summarize uploaded file before import - Implement job-based create flow with chunked processing and progress reporting - Provide cancel/cleanup and finalize endpoints to control lifecycle - Register endpoints under groups router --- .../groups/import/cancel-job.procedure.ts | 24 ++++++ .../groups/import/finalize-job.procedure.ts | 16 ++++ .../groups/import/import-group.procedure.ts | 76 ++++++++++++++++ src/trpc/routers/groups/import/index.ts | 11 +++ .../groups/import/preview.procedure.ts | 22 +++++ .../groups/import/run-chunk.procedure.ts | 63 ++++++++++++++ src/trpc/routers/groups/import/shared.ts | 39 +++++++++ .../groups/import/start-job.procedure.ts | 86 +++++++++++++++++++ src/trpc/routers/groups/index.ts | 14 +++ 9 files changed, 351 insertions(+) create mode 100644 src/trpc/routers/groups/import/cancel-job.procedure.ts create mode 100644 src/trpc/routers/groups/import/finalize-job.procedure.ts create mode 100644 src/trpc/routers/groups/import/import-group.procedure.ts create mode 100644 src/trpc/routers/groups/import/index.ts create mode 100644 src/trpc/routers/groups/import/preview.procedure.ts create mode 100644 src/trpc/routers/groups/import/run-chunk.procedure.ts create mode 100644 src/trpc/routers/groups/import/shared.ts create mode 100644 src/trpc/routers/groups/import/start-job.procedure.ts diff --git a/src/trpc/routers/groups/import/cancel-job.procedure.ts b/src/trpc/routers/groups/import/cancel-job.procedure.ts new file mode 100644 index 000000000..e0a5b9d99 --- /dev/null +++ b/src/trpc/routers/groups/import/cancel-job.procedure.ts @@ -0,0 +1,24 @@ +import { prisma } from '@/lib/prisma' +import { baseProcedure } from '@/trpc/init' +import { z } from 'zod' +import { createImportJobs } from './shared' + +// Cancels a running job: deletes all created expenses and the newly created group. +export const cancelCreateImportFromFileProcedure = baseProcedure + .input(z.object({ jobId: z.string().min(1) })) + .mutation(async ({ input: { jobId } }) => { + const job = createImportJobs.get(jobId) + if (!job) throw new Error('Import job not found or already completed.') + // Fast cancellation: remove all expenses of the group in one go, then drop the group. + await prisma.expense.deleteMany({ where: { groupId: job.groupId } }) + await prisma.group.delete({ where: { id: job.groupId } }) + createImportJobs.delete(jobId) + // We still respond with a synthetic result for the client + return { + resultId: jobId, + processed: job.nextIndex, + total: job.expenses.length, + groupId: job.groupId, + groupName: job.groupName, + } + }) diff --git a/src/trpc/routers/groups/import/finalize-job.procedure.ts b/src/trpc/routers/groups/import/finalize-job.procedure.ts new file mode 100644 index 000000000..780a1a753 --- /dev/null +++ b/src/trpc/routers/groups/import/finalize-job.procedure.ts @@ -0,0 +1,16 @@ +import { baseProcedure } from '@/trpc/init' +import { z } from 'zod' +import { createImportResults, lookupCreateImportRecord } from './shared' + +// Finalizes a completed job so the UI can navigate to the new group safely. +export const finalizeCreateImportFromFileProcedure = baseProcedure + .input(z.object({ resultId: z.string().min(1) })) + .mutation(({ input: { resultId } }) => { + const record = lookupCreateImportRecord(resultId) + createImportResults.delete(resultId) + return { + success: true, + groupId: record.groupId, + groupName: record.groupName, + } + }) diff --git a/src/trpc/routers/groups/import/import-group.procedure.ts b/src/trpc/routers/groups/import/import-group.procedure.ts new file mode 100644 index 000000000..536a7a066 --- /dev/null +++ b/src/trpc/routers/groups/import/import-group.procedure.ts @@ -0,0 +1,76 @@ +import { createExpense, createGroup } from '@/lib/api' +import { buildExpensesFromFileImport } from '@/lib/imports/file-import' +import { baseProcedure } from '@/trpc/init' +import { z } from 'zod' + +// Creates a new group from a file in a single request (no progress tracking). +// Kept for potential future use; the UI currently prefers the chunked job flow. +export const importGroupFromFileProcedure = baseProcedure + .input( + z.object({ + fileContent: z.string().min(1), + groupName: z.string().trim().optional(), + fileName: z.string().trim().optional(), + }), + ) + .mutation(async ({ input: { fileContent, groupName } }) => { + const trimmed = fileContent.trim() + if (!trimmed) { + throw new Error('Uploaded file was empty.') + } + + // Parse the file into internal expenses first. + const result = await buildExpensesFromFileImport(trimmed) + + if (result.errors.length > 0) { + throw new Error( + 'Unable to import file. Please fix the reported issues and try again.', + ) + } + + // Prefer participants supplied by the adapter; otherwise derive from expenses. + let participantNames: string[] | undefined = + result.group?.participants?.map((p) => p.name) + if (!participantNames || participantNames.length === 0) { + const set = new Set() + for (const e of result.expenses) { + set.add(e.paidBy) + for (const pf of e.paidFor) set.add(pf.participant) + } + participantNames = Array.from(set) + } + if (participantNames.length === 0) + throw new Error('No participants found in file.') + + const currency = + (result.group?.currency as string | undefined)?.trim() || '€' + const currencyCode = + (result.group?.currencyCode as string | undefined)?.trim() || '' + const inferredName = (result.group?.name as string | undefined)?.trim() + const group = await createGroup({ + name: groupName?.trim() || inferredName || 'Imported group', + information: undefined, + currency, + currencyCode, + participants: participantNames.map((name) => ({ name })), + }) + + // Simple name-based remapping: adapters set participants to display names. + const createdNameToId = new Map( + group.participants.map((p) => [p.name, p.id] as const), + ) + const remapExpense = (exp: (typeof result.expenses)[number]) => ({ + ...exp, + paidBy: createdNameToId.get(exp.paidBy) ?? exp.paidBy, + paidFor: exp.paidFor.map((pf) => ({ + ...pf, + participant: createdNameToId.get(pf.participant) ?? pf.participant, + })), + }) + + for (const expense of result.expenses.map(remapExpense)) { + await createExpense(expense, group.id) + } + + return { groupId: group.id, groupName: group.name } + }) diff --git a/src/trpc/routers/groups/import/index.ts b/src/trpc/routers/groups/import/index.ts new file mode 100644 index 000000000..a657d5e84 --- /dev/null +++ b/src/trpc/routers/groups/import/index.ts @@ -0,0 +1,11 @@ +export { importGroupFromFileProcedure } from './import-group.procedure' + +export { previewImportGroupFromFileProcedure } from './preview.procedure' + +export { startCreateImportFromFileProcedure } from './start-job.procedure' + +export { runCreateImportFromFileChunkProcedure } from './run-chunk.procedure' + +export { cancelCreateImportFromFileProcedure } from './cancel-job.procedure' + +export { finalizeCreateImportFromFileProcedure } from './finalize-job.procedure' diff --git a/src/trpc/routers/groups/import/preview.procedure.ts b/src/trpc/routers/groups/import/preview.procedure.ts new file mode 100644 index 000000000..d5876729d --- /dev/null +++ b/src/trpc/routers/groups/import/preview.procedure.ts @@ -0,0 +1,22 @@ +import { buildExpensesFromFileImport } from '@/lib/imports/file-import' +import { baseProcedure } from '@/trpc/init' +import { z } from 'zod' + +// Parses the upload and returns a normalized preview. +// The client shows warnings and totals before starting the import job. +export const previewImportGroupFromFileProcedure = baseProcedure + .input( + z.object({ + fileContent: z.string().min(1), + fileName: z.string().trim().optional(), + }), + ) + .mutation(async ({ input: { fileContent } }) => { + const trimmed = fileContent.trim() + if (!trimmed) { + throw new Error('Uploaded file was empty.') + } + + // Parse directly into internal expenses and compute summaries. + return await buildExpensesFromFileImport(trimmed) + }) diff --git a/src/trpc/routers/groups/import/run-chunk.procedure.ts b/src/trpc/routers/groups/import/run-chunk.procedure.ts new file mode 100644 index 000000000..983c444cf --- /dev/null +++ b/src/trpc/routers/groups/import/run-chunk.procedure.ts @@ -0,0 +1,63 @@ +import { createExpense } from '@/lib/api' +import { baseProcedure } from '@/trpc/init' +import { z } from 'zod' +import { + createImportJobs, + createImportResults, + getImportChunkSize, +} from './shared' + +// Consumes the next chunk of expenses and creates them in the DB. +export const runCreateImportFromFileChunkProcedure = baseProcedure + .input(z.object({ jobId: z.string().min(1) })) + .mutation(async ({ input: { jobId } }) => { + const job = createImportJobs.get(jobId) + if (!job) throw new Error('Import job not found or already completed.') + + // Fixed chunk size to keep each request short and predictable + const step = Math.max(1, getImportChunkSize()) + const startIndex = job.nextIndex + const endIndex = Math.min(job.nextIndex + step, job.expenses.length) + + let nextIndex = startIndex + try { + for (let index = startIndex; index < endIndex; index++) { + const expense = job.expenses[index] + if (expense) { + const createdExpense = await createExpense(expense, job.groupId) + job.createdExpenseIds.push(createdExpense.id) + } + nextIndex = index + 1 + } + } catch (error) { + createImportJobs.delete(jobId) + throw error + } + + job.nextIndex = nextIndex + const done = job.nextIndex >= job.expenses.length + let resultId: string | undefined + if (done) { + createImportResults.set(jobId, { + id: jobId, + groupId: job.groupId, + groupName: job.groupName, + expenseIds: [...job.createdExpenseIds], + totalExpenses: job.expenses.length, + processedExpenses: job.nextIndex, + status: 'completed', + }) + createImportJobs.delete(jobId) + resultId = jobId + } + + return { + processed: job.nextIndex, + total: job.expenses.length, + remaining: Math.max(job.expenses.length - job.nextIndex, 0), + done, + resultId, + groupId: job.groupId, + groupName: job.groupName, + } + }) diff --git a/src/trpc/routers/groups/import/shared.ts b/src/trpc/routers/groups/import/shared.ts new file mode 100644 index 000000000..85b434e95 --- /dev/null +++ b/src/trpc/routers/groups/import/shared.ts @@ -0,0 +1,39 @@ +import { buildExpensesFromFileImport } from '@/lib/imports/file-import' + +export type CreateImportJob = { + id: string + groupId: string + groupName: string + expenses: Awaited>['expenses'] + nextIndex: number + createdExpenseIds: string[] +} + +export type CreateImportRecordStatus = 'completed' | 'cancelled' +export type CreateImportRecord = { + id: string + groupId: string + groupName: string + expenseIds: string[] + totalExpenses: number + processedExpenses: number + status: CreateImportRecordStatus +} + +export const createImportJobs = new Map() +export const createImportResults = new Map() + +export const DEFAULT_IMPORT_CHUNK_SIZE = 50 +export const getImportChunkSize = () => { + const envValue = process.env.IMPORT_CHUNK_SIZE + if (!envValue) return DEFAULT_IMPORT_CHUNK_SIZE + const parsed = Number(envValue) + if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_IMPORT_CHUNK_SIZE + return Math.floor(parsed) +} + +export const lookupCreateImportRecord = (resultId: string) => { + const record = createImportResults.get(resultId) + if (!record) throw new Error('Import result not found or already handled.') + return record +} diff --git a/src/trpc/routers/groups/import/start-job.procedure.ts b/src/trpc/routers/groups/import/start-job.procedure.ts new file mode 100644 index 000000000..c8a7c8bba --- /dev/null +++ b/src/trpc/routers/groups/import/start-job.procedure.ts @@ -0,0 +1,86 @@ +import { createGroup } from '@/lib/api' +import { buildExpensesFromFileImport } from '@/lib/imports/file-import' +import { baseProcedure } from '@/trpc/init' +import { nanoid } from 'nanoid' +import { z } from 'zod' +import { createImportJobs } from './shared' + +// Starts a new import job: creates the group and stages expenses. +export const startCreateImportFromFileProcedure = baseProcedure + .input( + z.object({ + fileContent: z.string().min(1), + groupName: z.string().trim().optional(), + fileName: z.string().trim().optional(), + }), + ) + .mutation(async ({ input: { fileContent, groupName } }) => { + const trimmed = fileContent.trim() + if (!trimmed) throw new Error('Uploaded file was empty.') + const result = await buildExpensesFromFileImport(trimmed) + + if (result.errors.length > 0) { + throw new Error( + 'Cannot import while the file contains blocking errors. Please fix them first.', + ) + } + + // Prefer participants supplied by the adapter; otherwise derive from expenses. + let participantNames: string[] | undefined = + result.group?.participants?.map((p) => p.name) + if (!participantNames || participantNames.length === 0) { + const set = new Set() + for (const e of result.expenses) { + set.add(e.paidBy) + for (const pf of e.paidFor) set.add(pf.participant) + } + participantNames = Array.from(set) + } + if (participantNames.length === 0) + throw new Error('No participants found in file.') + + const currency = + (result.group?.currency as string | undefined)?.trim() || '€' + const currencyCode = + (result.group?.currencyCode as string | undefined)?.trim() || '' + const inferredName = (result.group?.name as string | undefined)?.trim() + const group = await createGroup({ + name: groupName?.trim() || inferredName || 'Imported group', + information: undefined, + currency, + currencyCode, + participants: participantNames.map((name) => ({ name })), + }) + + // Map names to DB IDs; adapters use participant names directly in expenses. + const createdNameToId = new Map( + group.participants.map((p) => [p.name, p.id] as const), + ) + const remapExpense = (exp: (typeof result.expenses)[number]) => ({ + ...exp, + paidBy: createdNameToId.get(exp.paidBy) ?? exp.paidBy, + paidFor: exp.paidFor.map((pf) => ({ + ...pf, + participant: createdNameToId.get(pf.participant) ?? pf.participant, + })), + }) + + const remappedExpenses = result.expenses.map(remapExpense) + + const jobId = nanoid() + createImportJobs.set(jobId, { + id: jobId, + groupId: group.id, + groupName: group.name, + expenses: remappedExpenses, + nextIndex: 0, + createdExpenseIds: [], + }) + + return { + jobId, + totalExpenses: result.expenses.length, + groupId: group.id, + groupName: group.name, + } + }) diff --git a/src/trpc/routers/groups/index.ts b/src/trpc/routers/groups/index.ts index 132228835..30ba4b280 100644 --- a/src/trpc/routers/groups/index.ts +++ b/src/trpc/routers/groups/index.ts @@ -7,6 +7,14 @@ import { getGroupProcedure } from '@/trpc/routers/groups/get.procedure' import { groupStatsRouter } from '@/trpc/routers/groups/stats' import { updateGroupProcedure } from '@/trpc/routers/groups/update.procedure' import { getGroupDetailsProcedure } from './getDetails.procedure' +import { + cancelCreateImportFromFileProcedure, + finalizeCreateImportFromFileProcedure, + importGroupFromFileProcedure, + previewImportGroupFromFileProcedure, + runCreateImportFromFileChunkProcedure, + startCreateImportFromFileProcedure, +} from './import' import { listGroupsProcedure } from './list.procedure' export const groupsRouter = createTRPCRouter({ @@ -19,5 +27,11 @@ export const groupsRouter = createTRPCRouter({ getDetails: getGroupDetailsProcedure, list: listGroupsProcedure, create: createGroupProcedure, + importFromFile: importGroupFromFileProcedure, + importFromFilePreview: previewImportGroupFromFileProcedure, + importFromFileStartJob: startCreateImportFromFileProcedure, + importFromFileRunChunk: runCreateImportFromFileChunkProcedure, + importFromFileCancelJob: cancelCreateImportFromFileProcedure, + importFromFileFinalize: finalizeCreateImportFromFileProcedure, update: updateGroupProcedure, }) From 8f1d5e3d52c3e42d72611a0df03eac4fb676a136 Mon Sep 17 00:00:00 2001 From: Uli-Z <58443149+Uli-Z@users.noreply.github.com> Date: Sun, 16 Nov 2025 19:01:24 +0100 Subject: [PATCH 05/31] feat(ui): add import components (dropzone, analysis, progress, result) - Dropzone with drag-and-drop and accessible labeling - Analysis panel to display detected format, totals and errors - Progress view for chunked import with visual bar - Result view to confirm completion or cancellation --- .../import/import-analysis-panel.tsx | 212 ++++++++++++++++++ .../import/import-progress-view.tsx | 50 +++++ src/components/import/import-result-view.tsx | 37 +++ src/components/import/upload-dropzone.tsx | 72 ++++++ 4 files changed, 371 insertions(+) create mode 100644 src/components/import/import-analysis-panel.tsx create mode 100644 src/components/import/import-progress-view.tsx create mode 100644 src/components/import/import-result-view.tsx create mode 100644 src/components/import/upload-dropzone.tsx diff --git a/src/components/import/import-analysis-panel.tsx b/src/components/import/import-analysis-panel.tsx new file mode 100644 index 000000000..44825313d --- /dev/null +++ b/src/components/import/import-analysis-panel.tsx @@ -0,0 +1,212 @@ +'use client' + +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { + Currency as CurrencyType, + getCurrency as lookupCurrency, +} from '@/lib/currency' +import { ImportBuildResult } from '@/lib/imports/file-import' +import { formatCurrency } from '@/lib/utils' +import { useLocale, useTranslations } from 'next-intl' + +export function ImportAnalysisPanel({ + previewResult, + previewError, +}: { + previewResult: ImportBuildResult | null + previewError: string | null +}) { + const t = useTranslations('FileImport') + const locale = useLocale() + + const totalRows = + (previewResult?.expenses.length ?? 0) + (previewResult?.errors.length ?? 0) + const headerErrors: string[] = [] + const generalWarnings: string[] = [] + const rowErrors = previewResult?.errors ?? [] + const categoryWarnings: { row: number; label: string; message: string }[] = [] + const participantSummaries = previewResult?.participantSummaries ?? [] + + let currency: CurrencyType | null = null + const currencyCode = previewResult?.group?.currencyCode + const currencySymbol = previewResult?.group?.currency + if (currencyCode) { + try { + currency = lookupCurrency(currencyCode) + } catch { + currency = null + } + } else if (currencySymbol) { + currency = { + name: 'Custom', + symbol_native: currencySymbol, + symbol: currencySymbol, + code: '', + name_plural: '', + rounding: 0, + decimal_digits: 2, + } + } + + const formatSignedAmount = (amount: number) => { + if (!currency) return t('generalInfoUnknown') + if (amount === 0) return formatCurrency(currency, amount, locale) + const formatted = formatCurrency(currency, Math.abs(amount), locale) + const sign = amount > 0 ? '+' : '-' + return `${sign}${formatted}` + } + + const participantList = participantSummaries.map((participant, index) => ( + + {participant.name}{' '} + = 0 ? 'text-emerald-600' : 'text-destructive' + } + > + ({formatSignedAmount(participant.balance)}) + + {index < participantSummaries.length - 1 ? , : null} + + )) + + const totalAmount = + previewResult?.expenses.reduce((sum, e) => sum + e.amount, 0) ?? 0 + const formattedTotalAmount = + currency && previewResult + ? formatCurrency(currency, totalAmount, locale) + : t('generalInfoUnknown') + + return ( +
+ {previewResult ? ( + <> +
+
+

{t('generalInfoTitle')}

+
+
+
+ {t('generalInfoFormat')} +
+
+ {previewResult.format?.label ?? t('generalInfoUnknown')} +
+
+ {/* Language not available in simplified import result */} +
+
+ {t('generalInfoRows')} +
+
{totalRows}
+
+
+
+ {t('generalInfoInvalidRows')} +
+
{rowErrors.length}
+
+
+
+ {t('generalInfoTotal')} +
+
+ {formattedTotalAmount} +
+
+
+
+
+

+ {t('generalInfoParticipants')} +

+ {participantSummaries.length > 0 ? ( +

+ {participantList} +

+ ) : ( +

+ {t('generalInfoParticipantsEmpty')} +

+ )} +
+
+ + {headerErrors.length > 0 && ( + + {t('analysisHeaderTitle')} + +
    + {headerErrors.map((error) => ( +
  • {error}
  • + ))} +
+
+
+ )} + + {generalWarnings.length > 0 && ( + + + {t('analysisGeneralWarningsTitle')} + + +
    + {generalWarnings.map((warning, index) => ( +
  • {warning}
  • + ))} +
+
+
+ )} + + {categoryWarnings.length > 0 && ( + + {t('analysisCategoryTitle')} + +
    + {categoryWarnings.map((warning) => ( +
  • + {warning.label}: {warning.message} +
  • + ))} +
+
+
+ )} + + {rowErrors.length > 0 && ( + + + {t('analysisErrorsTitle')} + + +
    + {rowErrors.map((error) => ( +
  • + #{error.row}: {error.message} +
  • + ))} +
+

+ {t('analysisFatalHint')} +

+
+
+ )} + + ) : ( + + + {previewError ? t('previewErrorTitle') : t('analysisAwaiting')} + + +

+ {previewError ? previewError : t('analysisExplanation')} +

+
+
+ )} +
+ ) +} diff --git a/src/components/import/import-progress-view.tsx b/src/components/import/import-progress-view.tsx new file mode 100644 index 000000000..676110028 --- /dev/null +++ b/src/components/import/import-progress-view.tsx @@ -0,0 +1,50 @@ +'use client' + +import { Button } from '@/components/ui/button' + +export function ImportProgressView({ + label, + importingText, + processed, + total, + onCancel, + cancelLabel, + cancelling, +}: { + label: string + importingText: string + processed: number + total: number + onCancel: () => void + cancelLabel: string + cancelling: boolean +}) { + const percent = Math.min(100, (processed / Math.max(total, 1)) * 100) + return ( +
+
+

{label}

+

+ {processed}/{total} +

+
+
+
+
+
+

{importingText}

+
+ +
+ ) +} diff --git a/src/components/import/import-result-view.tsx b/src/components/import/import-result-view.tsx new file mode 100644 index 000000000..6c0c4356c --- /dev/null +++ b/src/components/import/import-result-view.tsx @@ -0,0 +1,37 @@ +'use client' + +import { Button } from '@/components/ui/button' + +export type ImportResultStatus = 'completed' | 'cancelled' + +export function ImportResultView({ + status, + title, + confirmLabel, + onConfirm, + confirmDisabled, +}: { + status: ImportResultStatus + title: string + confirmLabel: string + onConfirm: () => void + confirmDisabled?: boolean +}) { + return ( +
+
+

{title}

+
+
+ +
+
+ ) +} diff --git a/src/components/import/upload-dropzone.tsx b/src/components/import/upload-dropzone.tsx new file mode 100644 index 000000000..2c7e1a25e --- /dev/null +++ b/src/components/import/upload-dropzone.tsx @@ -0,0 +1,72 @@ +'use client' + +import { Label } from '@/components/ui/label' +import { Upload } from 'lucide-react' +import { useRef, useState } from 'react' + +export function UploadDropzone({ + inputId, + label, + title, + description, + accept = '.csv,.json,text/csv,application/json', + onSelect, +}: { + inputId: string + label: string + title: string + description?: string + accept?: string + onSelect: (file: File) => void +}) { + const inputRef = useRef(null) + const [isDragging, setIsDragging] = useState(false) + + const handleFileChange: React.ChangeEventHandler = (e) => { + const file = e.target.files?.[0] + if (file) onSelect(file) + e.currentTarget.value = '' + } + + return ( +
+ + +
inputRef.current?.click()} + onDragOver={(e) => { + e.preventDefault() + setIsDragging(true) + }} + onDragLeave={(e) => { + e.preventDefault() + setIsDragging(false) + }} + onDrop={(e) => { + e.preventDefault() + setIsDragging(false) + const file = e.dataTransfer.files?.[0] + if (file) onSelect(file) + }} + className={`flex cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed p-6 text-center transition ${ + isDragging + ? 'border-primary bg-primary/10' + : 'border-muted bg-muted/20' + }`} + > + +

{title}

+ {description ? ( +

{description}

+ ) : null} +
+
+ ) +} From b6b7f2086eb833150990f3995e6e7bafb78998d5 Mon Sep 17 00:00:00 2001 From: Uli-Z <58443149+Uli-Z@users.noreply.github.com> Date: Sun, 16 Nov 2025 19:01:24 +0100 Subject: [PATCH 06/31] feat(ui): add file import modal wiring all steps - Combine upload + preview + scroll-to-confirm + chunked import in one dialog - Handle cancel/finalize flows with toasts and resilient state reset - Support optional prefill of group name from parsed file meta - Integrate TRPC mutations with defensive error handling --- src/components/file-import-modal.tsx | 585 +++++++++++++++++++++++++++ 1 file changed, 585 insertions(+) create mode 100644 src/components/file-import-modal.tsx diff --git a/src/components/file-import-modal.tsx b/src/components/file-import-modal.tsx new file mode 100644 index 000000000..bb24f95c6 --- /dev/null +++ b/src/components/file-import-modal.tsx @@ -0,0 +1,585 @@ +'use client' + +// File import modal used from the Groups overview. +// It provides: +// - Upload + drag/drop area for a Spliit JSON export +// - Preview (detected format, totals, warnings) +// - Scroll-to-confirm gate before import +// - Chunked import with a progress bar +// - Cancel deletes the created group and any imported expenses +// - Finalize returns the new group id/name to the caller for navigation + +import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react' + +import { ImportAnalysisPanel } from '@/components/import/import-analysis-panel' +import { UploadDropzone } from '@/components/import/upload-dropzone' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog' +import { Label } from '@/components/ui/label' +import { useToast } from '@/components/ui/use-toast' +import { type ImportBuildResult } from '@/lib/imports/file-import' +import { trpc } from '@/trpc/client' +import { Loader2, Upload } from 'lucide-react' +import { useTranslations } from 'next-intl' +import { Input } from './ui/input' + +type UploadState = { + csv: string + fileName: string | null +} + +// The server advances progress in ~10% increments per call. + +// Minimal shape of the result state used by the UI. +type ImportResultState = null | { + status: 'completed' | 'cancelled' + created: number + total: number + resultId: string +} + +export function FileImportModal({ + open: controlledOpen, + onOpenChange, + hideTrigger, + onCreateSuccess, +}: { + open?: boolean + onOpenChange?: (open: boolean) => void + hideTrigger?: boolean + onCreateSuccess?: (result: { groupId: string; groupName: string }) => void +}) { + const t = useTranslations('FileImport') + const tErrors = useTranslations('FileImportErrors') + // i18n strings are provided via t(); formatting handled in subcomponents + const [groupName, setGroupName] = useState('') + const [dialogOpen, setDialogOpen] = useState(controlledOpen ?? false) + const setOpen = onOpenChange ?? setDialogOpen + const open = controlledOpen ?? dialogOpen + const { toast } = useToast() + const [uploadState, setUploadState] = useState({ + csv: '', + fileName: null, + }) + const [previewResult, setPreviewResult] = useState( + null, + ) + const [previewError, setPreviewError] = useState(null) + const scrollContainerRef = useRef(null) + const [hasReachedBottom, setHasReachedBottom] = useState(false) + const [isDraggingFile, setIsDraggingFile] = useState(false) + const [isProcessing, setIsProcessing] = useState(false) + const [currentJobId, setCurrentJobId] = useState(null) + const cancelRequestedRef = useRef(false) + const [isCancellingImport, setIsCancellingImport] = useState(false) + const [importResult, setImportResult] = useState(null) + const [resultActionLoading, setResultActionLoading] = useState(false) + const [importProgress, setImportProgress] = useState<{ + processed: number + total: number + }>({ + processed: 0, + total: 0, + }) + + const localizeErrorMessage = useCallback( + (message: string) => { + const normalized = message.toLowerCase() + if (normalized.includes('no participants')) + return tErrors('noParticipants') + if (normalized.includes('uploaded file was empty')) + return tErrors('fileEmpty') + if (normalized.includes('invalid amount')) return tErrors('invalidAmount') + if (normalized.includes('invalid expense date')) + return tErrors('invalidDate') + return message + }, + [tErrors], + ) + + const utils = trpc.useUtils() + + // Step 1: Preview the uploaded file to show warnings and totals before importing. + const previewMutation = trpc.groups.importFromFilePreview.useMutation({ + onSuccess(result) { + setPreviewResult(result) + setPreviewError(null) + setIsProcessing(false) + }, + onError(error) { + setPreviewResult(null) + setPreviewError(localizeErrorMessage(error.message)) + setIsProcessing(false) + }, + }) + + // Import job mutations (create a new group from file) + const startCreateImportMutation = + trpc.groups.importFromFileStartJob.useMutation({ + onError(error) { + setIsProcessing(false) + toast({ + title: t('errorTitle'), + description: error.message, + variant: 'destructive', + }) + }, + }) + const runCreateImportChunkMutation = + trpc.groups.importFromFileRunChunk.useMutation({ + onError(error) { + setIsProcessing(false) + toast({ + title: t('errorTitle'), + description: error.message, + variant: 'destructive', + }) + }, + }) + const cancelCreateImportMutation = + trpc.groups.importFromFileCancelJob.useMutation({ + onError(error) { + setIsCancellingImport(false) + toast({ + title: t('errorTitle'), + description: error.message, + variant: 'destructive', + }) + }, + }) + const finalizeCreateImportMutation = + trpc.groups.importFromFileFinalize.useMutation({ + onError(error) { + toast({ + title: t('errorTitle'), + description: error.message, + variant: 'destructive', + }) + }, + }) + + const analyzeCsv = useCallback( + (content: string) => { + if (!content.trim()) { + setPreviewResult(null) + return + } + + setPreviewResult(null) + setPreviewError(null) + setIsProcessing(true) + previewMutation.mutate({ + fileContent: content, + fileName: uploadState.fileName ?? undefined, + }) + }, + [previewMutation, uploadState.fileName], + ) + + const handleFileRead = (file: File) => { + const reader = new FileReader() + reader.onload = () => { + const content = String(reader.result ?? '') + setUploadState({ csv: content, fileName: file.name }) + analyzeCsv(content) + } + reader.onerror = () => { + setPreviewError(localizeErrorMessage(t('fileReadError'))) + } + reader.readAsText(file, 'utf-8') + } + + const handleFileChange = (event: ChangeEvent) => { + const file = event.target.files?.[0] + if (!file) return + handleFileRead(file) + event.target.value = '' + } + + const handleDropSelect = (file: File) => handleFileRead(file) + + const totalRows = + (previewResult?.expenses.length ?? 0) + (previewResult?.errors.length ?? 0) + const hasFatalErrors = (previewResult?.errors.length ?? 0) > 0 + const canImport = Boolean(previewResult) && !hasFatalErrors + const jobRunning = Boolean(currentJobId) + const importLoading = + jobRunning || + startCreateImportMutation.status === 'pending' || + runCreateImportChunkMutation.status === 'pending' || + cancelCreateImportMutation.status === 'pending' + + // Keep a stable reference to the mutation reset function so the cleanup + // effect can call it without triggering new renders when the mutation object + // identity changes. + const previewResetRef = useRef(previewMutation.reset) + + useEffect(() => { + previewResetRef.current = previewMutation.reset + }, [previewMutation.reset]) + + useEffect(() => { + if (!open) { + setUploadState({ csv: '', fileName: null }) + setPreviewResult(null) + setPreviewError(null) + setHasReachedBottom(false) + setImportProgress({ processed: 0, total: 0 }) + setIsProcessing(false) + setCurrentJobId(null) + cancelRequestedRef.current = false + setIsCancellingImport(false) + setImportResult(null) + setGroupName('') + previewResetRef.current() + } + }, [open]) + + useEffect(() => { + setHasReachedBottom(false) + }, [previewResult]) + + useEffect(() => { + if (!previewResult) return + if (!groupName && previewResult.group?.name) { + setGroupName(previewResult.group.name) + } + }, [groupName, previewResult]) + + // Participant matching is not part of this modal + + const handleStartImport = useCallback(async () => { + if (!canImport || importLoading) return + cancelRequestedRef.current = false + setIsCancellingImport(false) + setImportResult(null) + setIsProcessing(true) + try { + const start = await startCreateImportMutation.mutateAsync({ + fileContent: uploadState.csv, + groupName: groupName.trim() || undefined, + fileName: uploadState.fileName ?? undefined, + }) + setCurrentJobId(start.jobId) + setImportProgress({ processed: 0, total: start.totalExpenses }) + + let finalResult: ImportResultState = null + while (!cancelRequestedRef.current) { + const chunk = await runCreateImportChunkMutation.mutateAsync({ + jobId: start.jobId, + }) + setImportProgress({ processed: chunk.processed, total: chunk.total }) + if (chunk.done && chunk.resultId) { + finalResult = { + status: 'completed', + created: chunk.processed, + total: chunk.total, + resultId: chunk.resultId, + } + ;(finalResult as any).groupId = chunk.groupId + ;(finalResult as any).groupName = chunk.groupName + break + } + } + + if (cancelRequestedRef.current && !finalResult) { + setIsCancellingImport(true) + const cancel = await cancelCreateImportMutation.mutateAsync({ + jobId: start.jobId, + }) + finalResult = { + status: 'cancelled', + created: cancel.processed, + total: cancel.total, + resultId: cancel.resultId, + } + ;(finalResult as any).groupId = cancel.groupId + ;(finalResult as any).groupName = cancel.groupName + } + + if (finalResult) { + setImportResult(finalResult) + } + } catch (error) { + if (error instanceof Error) { + toast({ + title: t('errorTitle'), + description: error.message, + variant: 'destructive', + }) + } + setImportProgress({ processed: 0, total: 0 }) + } finally { + cancelRequestedRef.current = false + setCurrentJobId(null) + setIsCancellingImport(false) + setIsProcessing(false) + } + }, [ + canImport, + importLoading, + startCreateImportMutation, + runCreateImportChunkMutation, + cancelCreateImportMutation, + uploadState.csv, + uploadState.fileName, + groupName, + t, + toast, + ]) + + const handleScroll = useCallback(() => { + const element = scrollContainerRef.current + if (!element) return + const reachedBottom = + element.scrollTop + element.clientHeight >= element.scrollHeight - 8 + setHasReachedBottom(reachedBottom) + }, []) + + const handleCancelImport = useCallback(() => { + if (!currentJobId) return + cancelRequestedRef.current = true + setIsCancellingImport(true) + }, [currentJobId]) + + // When cancel is requested (via X or button), we only set the cancel flag here. + // The running import loop will notice it, finish the current chunk, then perform + // a single cancel request to the server. This avoids race conditions where the + // group gets deleted while a chunk is still creating expenses. + const requestCancel = useCallback(() => { + if (!currentJobId) return + cancelRequestedRef.current = true + setIsCancellingImport(true) + }, [currentJobId]) + + // Finalize/undo is not included here + + useEffect(() => { + handleScroll() + }, [handleScroll, previewResult, importProgress]) + + useEffect(() => { + const anyPending = + previewMutation.status === 'pending' || + startCreateImportMutation.status === 'pending' || + runCreateImportChunkMutation.status === 'pending' || + cancelCreateImportMutation.status === 'pending' + + if (!anyPending) { + setIsProcessing(false) + } + }, [ + previewMutation.status, + startCreateImportMutation?.status, + runCreateImportChunkMutation?.status, + cancelCreateImportMutation?.status, + ]) + + const renderContent = () => { + if (jobRunning) { + return ( +
+
+

{t('importProgressLabel')}

+

+ {importProgress.processed}/{importProgress.total} +

+
+
+
+
+
+

{t('importing')}

+
+ +
+ ) + } + + if (importResult) { + return ( +
+
+

+ {importResult.status === 'completed' + ? t('importResultCompleted', { + created: importResult.created, + total: importResult.total, + }) + : t('importResultCancelledSimple')} +

+
+
+ {importResult.status === 'completed' && ( + + )} + {importResult.status === 'cancelled' && ( + + )} +
+
+ ) + } + + // jobRunning branch above covers both existing and create flows + + return ( +
+ + + {previewResult && ( +
+ + setGroupName(event.target.value)} + /> +
+ )} + + + +
+ +
+
+ ) + } + return ( + { + if (!next && jobRunning) { + // Request cancel and keep the dialog open until the loop completes and shows the result. + requestCancel() + return + } + setOpen(next) + }} + > + {!hideTrigger && ( + + + + )} + { + // Do not allow closing by clicking outside while a job runs + if (jobRunning) e.preventDefault() + }} + onEscapeKeyDown={(e) => { + // Block ESC close while importing; users should press the header X to cancel + if (jobRunning) e.preventDefault() + }} + > + + {t('title')} + +
+ {previewMutation.status === 'pending' && + !jobRunning && + !importResult && ( +
+
+ + {t('processing')} +
+
+ )} +
+ {renderContent()} +
+
+
+
+ ) +} From c7110a5b9001f367a27e4ba7ea1ff9733d8f4da1 Mon Sep 17 00:00:00 2001 From: Uli-Z <58443149+Uli-Z@users.noreply.github.com> Date: Sun, 16 Nov 2025 19:01:37 +0100 Subject: [PATCH 07/31] feat(ui): integrate file import into groups overview - Add Import from file option to create menu - Mount FileImportModal and navigate to new group on success - Persist created group to recent list and refresh view --- src/app/groups/recent-group-list.tsx | 50 ++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/src/app/groups/recent-group-list.tsx b/src/app/groups/recent-group-list.tsx index 3d6465e67..a7f8dd184 100644 --- a/src/app/groups/recent-group-list.tsx +++ b/src/app/groups/recent-group-list.tsx @@ -5,14 +5,23 @@ import { getArchivedGroups, getRecentGroups, getStarredGroups, + saveRecentGroup, } from '@/app/groups/recent-groups-helpers' +import { FileImportModal } from '@/components/file-import-modal' import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' import { getGroups } from '@/lib/api' import { trpc } from '@/trpc/client' import { AppRouterOutput } from '@/trpc/routers/_app' -import { Loader2 } from 'lucide-react' +import { ChevronDown, Loader2 } from 'lucide-react' import { useTranslations } from 'next-intl' import Link from 'next/link' +import { useRouter } from 'next/navigation' import { PropsWithChildren, useEffect, useState } from 'react' import { RecentGroupListCard } from './recent-group-list-card' @@ -222,6 +231,8 @@ function GroupsPage({ reload, }: PropsWithChildren<{ reload: () => void }>) { const t = useTranslations('Groups') + const router = useRouter() + const [importOpen, setImportOpen] = useState(false) return ( <>
@@ -230,14 +241,39 @@ function GroupsPage({
- +
+ + + + + + + setImportOpen(true)}> + {t('CreateOptions.importFromFile')} + + + +
+ { + saveRecentGroup({ id: groupId, name: groupName }) + reload() + router.push(`/groups/${groupId}`) + }} + />
{children}
) From 907bd3a480c934d8af698622d38ff0b8eb4def8b Mon Sep 17 00:00:00 2001 From: Uli-Z <58443149+Uli-Z@users.noreply.github.com> Date: Sun, 16 Nov 2025 19:01:37 +0100 Subject: [PATCH 08/31] feat(i18n): add translations for file import flow - Add strings for upload, preview errors, progress, and results - Provide German, English, Spanish and French localizations - Wire keys used across import components and modal --- messages/de-DE.json | 52 ++++++++++++++++++++++++++++++++++ messages/en-US.json | 52 ++++++++++++++++++++++++++++++++++ messages/es.json | 52 ++++++++++++++++++++++++++++++++++ messages/fr-FR.json | 52 ++++++++++++++++++++++++++++++++++ src/lib/imports/spliit-json.ts | 2 +- 5 files changed, 209 insertions(+), 1 deletion(-) diff --git a/messages/de-DE.json b/messages/de-DE.json index 9dace19fb..e091a033b 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -52,6 +52,15 @@ "myGroups": "Meine Gruppen", "create": "Erstellen", "loadingRecent": "Lade letzte Gruppen…", + "CreateOptions": { + "openMenu": "Erstelloptionen öffnen", + "newGroup": "Neue Gruppe", + "importFromFile": "Aus Datei importieren" + }, + "ImportDialog": { + "successTitle": "Gruppe erstellt", + "successDescription": "Gruppe {name} wurde aus dem Import erstellt." + }, "NoRecent": { "description": "Du hast in der letzten Zeit keine Gruppe besucht.", "create": "Erstelle eine", @@ -456,4 +465,47 @@ "heading": "Geläufigste" } } + , + "FileImport": { + "title": "Gruppe aus Datei importieren", + "buttonLabel": "Import-Dialog öffnen", + "errorTitle": "Fehler", + "processing": "Verarbeite…", + "fileLabel": "Datei auswählen", + "uploadDragTitle": "Datei hier ablegen oder klicken, um auszuwählen", + "uploadDragDescription": "Unterstützt: JSON (Spliit-JSON) und Debug-Dateien", + "fileReadError": "Die ausgewählte Datei konnte nicht gelesen werden.", + "newGroupNameLabel": "Name der neuen Gruppe", + "newGroupNamePlaceholder": "Gruppennamen eingeben (optional)", + "analysisAwaiting": "Warte auf Datei-Analyse…", + "analysisExplanation": "Wähle eine Datei, um sie vor dem Import zu analysieren.", + "previewErrorTitle": "Die Datei konnte nicht analysiert werden", + "generalInfoTitle": "Allgemeine Informationen", + "generalInfoFormat": "Format", + "generalInfoRows": "Zeilen", + "generalInfoInvalidRows": "Ungültige Zeilen", + "generalInfoTotal": "Gesamtbetrag", + "generalInfoParticipants": "Teilnehmer und Salden", + "generalInfoParticipantsEmpty": "Keine Teilnehmerinformationen erkannt.", + "generalInfoUnknown": "Unbekannt", + "analysisHeaderTitle": "Probleme im Header", + "analysisGeneralWarningsTitle": "Allgemeine Warnungen", + "analysisCategoryTitle": "Kategorie-Zuordnungswarnungen", + "analysisErrorsTitle": "Zeilenfehler", + "analysisFatalHint": "Behebe diese Fehler, um den Import zu aktivieren.", + "import": "Importieren", + "importing": "Importiere…", + "importProgressLabel": "Importiere Ausgaben", + "importCancel": "Import abbrechen", + "importCanceling": "Breche ab…", + "importResultCompleted": "{created} von {total} Zeilen importiert", + "importResultCancelledSimple": "Import abgebrochen", + "importResultConfirm": "Weiter" + }, + "FileImportErrors": { + "noParticipants": "Keine Teilnehmer in der Datei definiert.", + "fileEmpty": "Die hochgeladene Datei war leer.", + "invalidAmount": "Ungültiger Betrag in einer oder mehreren Zeilen.", + "invalidDate": "Ungültiges Ausgabedatum in einer oder mehreren Zeilen." + } } diff --git a/messages/en-US.json b/messages/en-US.json index 10f5b7464..3eb7f4355 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -52,6 +52,15 @@ "myGroups": "My groups", "create": "Create", "loadingRecent": "Loading recent groups…", + "CreateOptions": { + "openMenu": "Open create options", + "newGroup": "New group", + "importFromFile": "Import from file" + }, + "ImportDialog": { + "successTitle": "Group created", + "successDescription": "Group {name} was created from import." + }, "NoRecent": { "description": "You have not visited any group recently.", "create": "Create one", @@ -449,4 +458,47 @@ "heading": "Other currencies" } } + , + "FileImport": { + "title": "Import group from file", + "buttonLabel": "Open import dialog", + "errorTitle": "Error", + "processing": "Processing…", + "fileLabel": "Select a file", + "uploadDragTitle": "Drop a file here or click to select", + "uploadDragDescription": "Supported: JSON (Spliit-JSON) and debug files", + "fileReadError": "Could not read the selected file.", + "newGroupNameLabel": "New group name", + "newGroupNamePlaceholder": "Enter a group name (optional)", + "analysisAwaiting": "Awaiting file analysis…", + "analysisExplanation": "Select a file to analyze its content before importing.", + "previewErrorTitle": "We couldn't analyze your file", + "generalInfoTitle": "General information", + "generalInfoFormat": "Format", + "generalInfoRows": "Rows", + "generalInfoInvalidRows": "Invalid rows", + "generalInfoTotal": "Total amount", + "generalInfoParticipants": "Participants and balances", + "generalInfoParticipantsEmpty": "No participant information detected.", + "generalInfoUnknown": "Unknown", + "analysisHeaderTitle": "Header issues", + "analysisGeneralWarningsTitle": "General warnings", + "analysisCategoryTitle": "Category mapping warnings", + "analysisErrorsTitle": "Row errors", + "analysisFatalHint": "Fix these errors to enable import.", + "import": "Import", + "importing": "Importing…", + "importProgressLabel": "Importing expenses", + "importCancel": "Cancel import", + "importCanceling": "Cancelling…", + "importResultCompleted": "Imported {created} of {total} rows", + "importResultCancelledSimple": "Import cancelled", + "importResultConfirm": "Continue" + }, + "FileImportErrors": { + "noParticipants": "No participants defined in file.", + "fileEmpty": "The uploaded file was empty.", + "invalidAmount": "Invalid amount in one or more rows.", + "invalidDate": "Invalid expense date in one or more rows." + } } diff --git a/messages/es.json b/messages/es.json index 6b5a36114..9b968a139 100644 --- a/messages/es.json +++ b/messages/es.json @@ -52,6 +52,15 @@ "myGroups": "Mis grupos", "create": "Crear", "loadingRecent": "Cargando grupos recientes…", + "CreateOptions": { + "openMenu": "Abrir opciones de creación", + "newGroup": "Nuevo grupo", + "importFromFile": "Importar desde archivo" + }, + "ImportDialog": { + "successTitle": "Grupo creado", + "successDescription": "El grupo {name} se creó a partir de la importación." + }, "NoRecent": { "description": "No has visitado ningun grupo recientemente.", "create": "Crea uno", @@ -448,4 +457,47 @@ "heading": "Otras monedas" } } + , + "FileImport": { + "title": "Importar grupo desde archivo", + "buttonLabel": "Abrir diálogo de importación", + "errorTitle": "Error", + "processing": "Procesando…", + "fileLabel": "Seleccionar un archivo", + "uploadDragTitle": "Suelta un archivo aquí o haz clic para seleccionar", + "uploadDragDescription": "Compatible: JSON (Spliit‑JSON) y archivos de depuración", + "fileReadError": "No se pudo leer el archivo seleccionado.", + "newGroupNameLabel": "Nombre del nuevo grupo", + "newGroupNamePlaceholder": "Introduce un nombre de grupo (opcional)", + "analysisAwaiting": "Esperando el análisis del archivo…", + "analysisExplanation": "Selecciona un archivo para analizar su contenido antes de importar.", + "previewErrorTitle": "No pudimos analizar tu archivo", + "generalInfoTitle": "Información general", + "generalInfoFormat": "Formato", + "generalInfoRows": "Filas", + "generalInfoInvalidRows": "Filas inválidas", + "generalInfoTotal": "Monto total", + "generalInfoParticipants": "Participantes y saldos", + "generalInfoParticipantsEmpty": "No se detectó información de participantes.", + "generalInfoUnknown": "Desconocido", + "analysisHeaderTitle": "Problemas en el encabezado", + "analysisGeneralWarningsTitle": "Advertencias generales", + "analysisCategoryTitle": "Advertencias de categorías", + "analysisErrorsTitle": "Errores por fila", + "analysisFatalHint": "Corrige estos errores para habilitar la importación.", + "import": "Importar", + "importing": "Importando…", + "importProgressLabel": "Importando gastos", + "importCancel": "Cancelar importación", + "importCanceling": "Cancelando…", + "importResultCompleted": "{created} de {total} filas importadas", + "importResultCancelledSimple": "Importación cancelada", + "importResultConfirm": "Continuar" + }, + "FileImportErrors": { + "noParticipants": "No hay participantes definidos en el archivo.", + "fileEmpty": "El archivo subido estaba vacío.", + "invalidAmount": "Monto inválido en una o más filas.", + "invalidDate": "Fecha de gasto inválida en una o más filas." + } } diff --git a/messages/fr-FR.json b/messages/fr-FR.json index 3e69f676b..4fc48c4b1 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -52,6 +52,15 @@ "myGroups": "Mes groupes", "create": "Créer", "loadingRecent": "Chargement des groupes récents…", + "CreateOptions": { + "openMenu": "Ouvrir les options de création", + "newGroup": "Nouveau groupe", + "importFromFile": "Importer depuis un fichier" + }, + "ImportDialog": { + "successTitle": "Groupe créé", + "successDescription": "Le groupe {name} a été créé à partir de l'import." + }, "NoRecent": { "description": "Vous n'avez visité aucun groupe récemment.", "create": "Créer un groupe", @@ -456,4 +465,47 @@ "heading": "Autres devises" } } + , + "FileImport": { + "title": "Importer un groupe depuis un fichier", + "buttonLabel": "Ouvrir la boîte d'import", + "errorTitle": "Erreur", + "processing": "Traitement…", + "fileLabel": "Sélectionner un fichier", + "uploadDragTitle": "Déposez un fichier ici ou cliquez pour sélectionner", + "uploadDragDescription": "Pris en charge : JSON (Spliit‑JSON) et fichiers de débogage", + "fileReadError": "Impossible de lire le fichier sélectionné.", + "newGroupNameLabel": "Nom du nouveau groupe", + "newGroupNamePlaceholder": "Entrez un nom de groupe (optionnel)", + "analysisAwaiting": "En attente de l'analyse du fichier…", + "analysisExplanation": "Sélectionnez un fichier pour analyser son contenu avant l'import.", + "previewErrorTitle": "Nous n'avons pas pu analyser votre fichier", + "generalInfoTitle": "Informations générales", + "generalInfoFormat": "Format", + "generalInfoRows": "Lignes", + "generalInfoInvalidRows": "Lignes invalides", + "generalInfoTotal": "Montant total", + "generalInfoParticipants": "Participants et soldes", + "generalInfoParticipantsEmpty": "Aucune information de participant détectée.", + "generalInfoUnknown": "Inconnu", + "analysisHeaderTitle": "Problèmes d'en‑tête", + "analysisGeneralWarningsTitle": "Avertissements généraux", + "analysisCategoryTitle": "Avertissements de catégorisation", + "analysisErrorsTitle": "Erreurs de lignes", + "analysisFatalHint": "Corrigez ces erreurs pour activer l'import.", + "import": "Importer", + "importing": "Importation…", + "importProgressLabel": "Import des dépenses", + "importCancel": "Annuler l'import", + "importCanceling": "Annulation…", + "importResultCompleted": "{created} sur {total} lignes importées", + "importResultCancelledSimple": "Import annulé", + "importResultConfirm": "Continuer" + }, + "FileImportErrors": { + "noParticipants": "Aucun participant défini dans le fichier.", + "fileEmpty": "Le fichier téléchargé était vide.", + "invalidAmount": "Montant invalide dans une ou plusieurs lignes.", + "invalidDate": "Date de dépense invalide dans une ou plusieurs lignes." + } } diff --git a/src/lib/imports/spliit-json.ts b/src/lib/imports/spliit-json.ts index 7bfd3ac74..fd967f8fa 100644 --- a/src/lib/imports/spliit-json.ts +++ b/src/lib/imports/spliit-json.ts @@ -217,7 +217,7 @@ export class SpliitJsonFormat implements ImportFormat { ) => ({ expenseDate: coerceDate(e?.expenseDate), title: String(e?.title ?? '').trim() || `Expense ${index + 1}`, - category: 0, + category: resolveCategoryId(e?.categoryId ?? e?.category), amount: Math.round(coerceNumber(e?.amount, 'amount')), originalAmount: e?.originalAmount != null From 5ae94345a477590684317ac32219e6d24fcae31c Mon Sep 17 00:00:00 2001 From: Uli-Z <58443149+Uli-Z@users.noreply.github.com> Date: Tue, 9 Dec 2025 08:56:29 +0100 Subject: [PATCH 09/31] feat(db): add ImportJob model for serverless-compatible import state --- .../migration.sql | 22 +++++++++++++++++ prisma/migrations/migration_lock.toml | 4 ++-- prisma/schema.prisma | 24 +++++++++++++++++++ 3 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 prisma/migrations/20251209075202_add_import_job_model/migration.sql diff --git a/prisma/migrations/20251209075202_add_import_job_model/migration.sql b/prisma/migrations/20251209075202_add_import_job_model/migration.sql new file mode 100644 index 000000000..e739b7c6a --- /dev/null +++ b/prisma/migrations/20251209075202_add_import_job_model/migration.sql @@ -0,0 +1,22 @@ +-- CreateEnum +CREATE TYPE "ImportJobStatus" AS ENUM ('PENDING', 'PROCESSING', 'COMPLETED', 'FAILED', 'CANCELLED'); + +-- CreateTable +CREATE TABLE "ImportJob" ( + "id" TEXT NOT NULL, + "groupId" TEXT NOT NULL, + "status" "ImportJobStatus" NOT NULL DEFAULT 'PENDING', + "expensesToCreate" JSONB NOT NULL, + "totalExpenses" INTEGER NOT NULL, + "processedExpenses" INTEGER NOT NULL DEFAULT 0, + "nextIndex" INTEGER NOT NULL DEFAULT 0, + "createdExpenseIds" TEXT[] DEFAULT ARRAY[]::TEXT[], + "error" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ImportJob_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "ImportJob" ADD CONSTRAINT "ImportJob_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml index fbffa92c2..044d57cdb 100644 --- a/prisma/migrations/migration_lock.toml +++ b/prisma/migrations/migration_lock.toml @@ -1,3 +1,3 @@ # Please do not edit this file manually -# It should be added in your version-control system (i.e. Git) -provider = "postgresql" \ No newline at end of file +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b20aa0e24..c8e0234a1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -21,6 +21,7 @@ model Group { expenses Expense[] activities Activity[] createdAt DateTime @default(now()) + importJobs ImportJob[] } model Participant { @@ -131,3 +132,26 @@ enum ActivityType { UPDATE_EXPENSE DELETE_EXPENSE } + +model ImportJob { + id String @id + group Group @relation(fields: [groupId], references: [id], onDelete: Cascade) + groupId String + status ImportJobStatus @default(PENDING) + expensesToCreate Json + totalExpenses Int + processedExpenses Int @default(0) + nextIndex Int @default(0) + createdExpenseIds String[] @default([]) + error String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +enum ImportJobStatus { + PENDING + PROCESSING + COMPLETED + FAILED + CANCELLED +} From 16326d6735f1073a9eea3adb5d38dbc651bdfff1 Mon Sep 17 00:00:00 2001 From: Uli-Z <58443149+Uli-Z@users.noreply.github.com> Date: Tue, 9 Dec 2025 08:56:38 +0100 Subject: [PATCH 10/31] refactor(import): replace in-memory job state with DB persistence --- .../groups/import/cancel-job.procedure.ts | 24 +++-- .../groups/import/finalize-job.procedure.ts | 23 ++-- .../groups/import/run-chunk.procedure.ts | 101 ++++++++++++------ src/trpc/routers/groups/import/shared.ts | 31 ------ .../groups/import/start-job.procedure.ts | 17 +-- 5 files changed, 108 insertions(+), 88 deletions(-) diff --git a/src/trpc/routers/groups/import/cancel-job.procedure.ts b/src/trpc/routers/groups/import/cancel-job.procedure.ts index e0a5b9d99..93166d4d6 100644 --- a/src/trpc/routers/groups/import/cancel-job.procedure.ts +++ b/src/trpc/routers/groups/import/cancel-job.procedure.ts @@ -1,24 +1,30 @@ import { prisma } from '@/lib/prisma' import { baseProcedure } from '@/trpc/init' import { z } from 'zod' -import { createImportJobs } from './shared' // Cancels a running job: deletes all created expenses and the newly created group. export const cancelCreateImportFromFileProcedure = baseProcedure .input(z.object({ jobId: z.string().min(1) })) .mutation(async ({ input: { jobId } }) => { - const job = createImportJobs.get(jobId) - if (!job) throw new Error('Import job not found or already completed.') - // Fast cancellation: remove all expenses of the group in one go, then drop the group. - await prisma.expense.deleteMany({ where: { groupId: job.groupId } }) + const job = await prisma.importJob.findUnique({ + where: { id: jobId }, + include: { group: true }, + }) + + if (!job) throw new Error('Import job not found.') + if (job.status === 'COMPLETED') { + throw new Error('Import job is already completed.') + } + + // Deleting the group will cascade delete the expenses and the ImportJob itself. await prisma.group.delete({ where: { id: job.groupId } }) - createImportJobs.delete(jobId) + // We still respond with a synthetic result for the client return { resultId: jobId, - processed: job.nextIndex, - total: job.expenses.length, + processed: job.processedExpenses, + total: job.totalExpenses, groupId: job.groupId, - groupName: job.groupName, + groupName: job.group.name, } }) diff --git a/src/trpc/routers/groups/import/finalize-job.procedure.ts b/src/trpc/routers/groups/import/finalize-job.procedure.ts index 780a1a753..3fb672e63 100644 --- a/src/trpc/routers/groups/import/finalize-job.procedure.ts +++ b/src/trpc/routers/groups/import/finalize-job.procedure.ts @@ -1,16 +1,27 @@ +import { prisma } from '@/lib/prisma' import { baseProcedure } from '@/trpc/init' import { z } from 'zod' -import { createImportResults, lookupCreateImportRecord } from './shared' // Finalizes a completed job so the UI can navigate to the new group safely. export const finalizeCreateImportFromFileProcedure = baseProcedure .input(z.object({ resultId: z.string().min(1) })) - .mutation(({ input: { resultId } }) => { - const record = lookupCreateImportRecord(resultId) - createImportResults.delete(resultId) + .mutation(async ({ input: { resultId } }) => { + const job = await prisma.importJob.findUnique({ + where: { id: resultId }, + include: { group: true }, + }) + + if (!job) throw new Error('Import result not found or already handled.') + if (job.status !== 'COMPLETED') { + throw new Error('Import job is not completed.') + } + + // Clean up the job record, but keep the group! + await prisma.importJob.delete({ where: { id: resultId } }) + return { success: true, - groupId: record.groupId, - groupName: record.groupName, + groupId: job.groupId, + groupName: job.group.name, } }) diff --git a/src/trpc/routers/groups/import/run-chunk.procedure.ts b/src/trpc/routers/groups/import/run-chunk.procedure.ts index 983c444cf..b12811156 100644 --- a/src/trpc/routers/groups/import/run-chunk.procedure.ts +++ b/src/trpc/routers/groups/import/run-chunk.procedure.ts @@ -1,63 +1,96 @@ import { createExpense } from '@/lib/api' +import { prisma } from '@/lib/prisma' +import { type ExpenseFormValues } from '@/lib/schemas' import { baseProcedure } from '@/trpc/init' import { z } from 'zod' -import { - createImportJobs, - createImportResults, - getImportChunkSize, -} from './shared' +import { getImportChunkSize } from './shared' // Consumes the next chunk of expenses and creates them in the DB. export const runCreateImportFromFileChunkProcedure = baseProcedure .input(z.object({ jobId: z.string().min(1) })) .mutation(async ({ input: { jobId } }) => { - const job = createImportJobs.get(jobId) - if (!job) throw new Error('Import job not found or already completed.') + const job = await prisma.importJob.findUnique({ + where: { id: jobId }, + include: { group: true }, + }) - // Fixed chunk size to keep each request short and predictable + if (!job) throw new Error('Import job not found.') + if (job.status === 'COMPLETED') { + return { + processed: job.totalExpenses, + total: job.totalExpenses, + remaining: 0, + done: true, + resultId: job.id, + groupId: job.groupId, + groupName: job.group.name, + } + } + if (job.status === 'CANCELLED' || job.status === 'FAILED') { + throw new Error(`Import job is ${job.status.toLowerCase()}.`) + } + + // Mark as processing if strictly pending + if (job.status === 'PENDING') { + await prisma.importJob.update({ + where: { id: jobId }, + data: { status: 'PROCESSING' }, + }) + } + + const allExpenses = job.expensesToCreate as unknown as ExpenseFormValues[] const step = Math.max(1, getImportChunkSize()) const startIndex = job.nextIndex - const endIndex = Math.min(job.nextIndex + step, job.expenses.length) + const endIndex = Math.min(startIndex + step, allExpenses.length) + const createdIds: string[] = [] let nextIndex = startIndex + try { for (let index = startIndex; index < endIndex; index++) { - const expense = job.expenses[index] - if (expense) { + const rawExpense = allExpenses[index] + if (rawExpense) { + // JSON serialization turns dates into strings; revive them. + const expense = { + ...rawExpense, + expenseDate: new Date(rawExpense.expenseDate), + } const createdExpense = await createExpense(expense, job.groupId) - job.createdExpenseIds.push(createdExpense.id) + createdIds.push(createdExpense.id) } nextIndex = index + 1 } } catch (error) { - createImportJobs.delete(jobId) + await prisma.importJob.update({ + where: { id: jobId }, + data: { + status: 'FAILED', + error: error instanceof Error ? error.message : String(error), + }, + }) throw error } - job.nextIndex = nextIndex - const done = job.nextIndex >= job.expenses.length - let resultId: string | undefined - if (done) { - createImportResults.set(jobId, { - id: jobId, - groupId: job.groupId, - groupName: job.groupName, - expenseIds: [...job.createdExpenseIds], - totalExpenses: job.expenses.length, - processedExpenses: job.nextIndex, - status: 'completed', - }) - createImportJobs.delete(jobId) - resultId = jobId - } + const done = nextIndex >= allExpenses.length + const newStatus = done ? 'COMPLETED' : 'PROCESSING' + + await prisma.importJob.update({ + where: { id: jobId }, + data: { + nextIndex, + processedExpenses: nextIndex, + createdExpenseIds: { push: createdIds }, + status: newStatus, + }, + }) return { - processed: job.nextIndex, - total: job.expenses.length, - remaining: Math.max(job.expenses.length - job.nextIndex, 0), + processed: nextIndex, + total: allExpenses.length, + remaining: Math.max(allExpenses.length - nextIndex, 0), done, - resultId, + resultId: done ? jobId : undefined, groupId: job.groupId, - groupName: job.groupName, + groupName: job.group.name, } }) diff --git a/src/trpc/routers/groups/import/shared.ts b/src/trpc/routers/groups/import/shared.ts index 85b434e95..441bf2e1d 100644 --- a/src/trpc/routers/groups/import/shared.ts +++ b/src/trpc/routers/groups/import/shared.ts @@ -1,28 +1,3 @@ -import { buildExpensesFromFileImport } from '@/lib/imports/file-import' - -export type CreateImportJob = { - id: string - groupId: string - groupName: string - expenses: Awaited>['expenses'] - nextIndex: number - createdExpenseIds: string[] -} - -export type CreateImportRecordStatus = 'completed' | 'cancelled' -export type CreateImportRecord = { - id: string - groupId: string - groupName: string - expenseIds: string[] - totalExpenses: number - processedExpenses: number - status: CreateImportRecordStatus -} - -export const createImportJobs = new Map() -export const createImportResults = new Map() - export const DEFAULT_IMPORT_CHUNK_SIZE = 50 export const getImportChunkSize = () => { const envValue = process.env.IMPORT_CHUNK_SIZE @@ -31,9 +6,3 @@ export const getImportChunkSize = () => { if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_IMPORT_CHUNK_SIZE return Math.floor(parsed) } - -export const lookupCreateImportRecord = (resultId: string) => { - const record = createImportResults.get(resultId) - if (!record) throw new Error('Import result not found or already handled.') - return record -} diff --git a/src/trpc/routers/groups/import/start-job.procedure.ts b/src/trpc/routers/groups/import/start-job.procedure.ts index c8a7c8bba..b4675193b 100644 --- a/src/trpc/routers/groups/import/start-job.procedure.ts +++ b/src/trpc/routers/groups/import/start-job.procedure.ts @@ -1,9 +1,9 @@ import { createGroup } from '@/lib/api' import { buildExpensesFromFileImport } from '@/lib/imports/file-import' +import { prisma } from '@/lib/prisma' import { baseProcedure } from '@/trpc/init' import { nanoid } from 'nanoid' import { z } from 'zod' -import { createImportJobs } from './shared' // Starts a new import job: creates the group and stages expenses. export const startCreateImportFromFileProcedure = baseProcedure @@ -68,13 +68,14 @@ export const startCreateImportFromFileProcedure = baseProcedure const remappedExpenses = result.expenses.map(remapExpense) const jobId = nanoid() - createImportJobs.set(jobId, { - id: jobId, - groupId: group.id, - groupName: group.name, - expenses: remappedExpenses, - nextIndex: 0, - createdExpenseIds: [], + await prisma.importJob.create({ + data: { + id: jobId, + groupId: group.id, + status: 'PENDING', + expensesToCreate: remappedExpenses as any, // Prisma Json handling + totalExpenses: remappedExpenses.length, + }, }) return { From 2218cf002dd9384ff829523c3e86c690670c74fb Mon Sep 17 00:00:00 2001 From: Uli-Z <58443149+Uli-Z@users.noreply.github.com> Date: Tue, 9 Dec 2025 08:56:45 +0100 Subject: [PATCH 11/31] test(import): add unit tests for Spliit-JSON format adapter --- src/lib/imports/spliit-json.test.ts | 113 ++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 src/lib/imports/spliit-json.test.ts diff --git a/src/lib/imports/spliit-json.test.ts b/src/lib/imports/spliit-json.test.ts new file mode 100644 index 000000000..8ae7b7bda --- /dev/null +++ b/src/lib/imports/spliit-json.test.ts @@ -0,0 +1,113 @@ +import { SpliitJsonFormat } from './spliit-json' + +describe('SpliitJsonFormat', () => { + const format = new SpliitJsonFormat() + + describe('detect', () => { + it('should return 0 for invalid JSON', () => { + expect(format.detect('invalid json')).toBe(0) + }) + + it('should return 0 for JSON without required fields', () => { + expect(format.detect('{}')).toBe(0) + expect(format.detect('{"foo": "bar"}')).toBe(0) + }) + + it('should return a high score for valid Spliit JSON', () => { + const content = JSON.stringify({ + participants: [{ name: 'Alice' }, { name: 'Bob' }], + expenses: [ + { + paidById: '1', + paidFor: [{ participantId: '2', shares: 1 }], + amount: 100, + expenseDate: '2023-01-01', + title: 'Test', + }, + ], + }) + expect(format.detect(content)).toBeGreaterThan(0.8) + }) + }) + + describe('parseToInternal', () => { + it('should parse a valid export correctly', () => { + const content = JSON.stringify({ + participants: [ + { id: 'p1', name: 'Alice' }, + { id: 'p2', name: 'Bob' }, + ], + expenses: [ + { + paidById: 'p1', + paidFor: [ + { participantId: 'p2', shares: 1 }, + { participantId: 'p1', shares: 2 }, + ], + amount: 1234, // 12.34 + expenseDate: '2023-05-20', + title: 'Lunch', + category: 'Food', + }, + ], + name: 'My Group', + currency: '$', + }) + + const result = format.parseToInternal(content) + + expect(result.errors).toHaveLength(0) + expect(result.group).toEqual({ + name: 'My Group', + currency: '$', + currencyCode: undefined, + participants: [{ name: 'Alice' }, { name: 'Bob' }], + }) + + expect(result.expenses).toHaveLength(1) + const expense = result.expenses[0] + expect(expense.title).toBe('Lunch') + expect(expense.amount).toBe(1234) + expect(expense.paidBy).toBe('Alice') // ID resolved to name + expect(expense.paidFor).toHaveLength(2) + expect(expense.paidFor).toContainEqual( + expect.objectContaining({ participant: 'Bob', shares: 1 }) + ) + expect(expense.paidFor).toContainEqual( + expect.objectContaining({ participant: 'Alice', shares: 2 }) + ) + expect(expense.expenseDate).toBeInstanceOf(Date) + expect(expense.expenseDate.toISOString()).toContain('2023-05-20') + }) + + it('should handle missing participant names by falling back to ID or index', () => { + const content = JSON.stringify({ + participants: [ + { id: 'p1' }, // No name + {}, // No ID or name + ], + expenses: [], + }) + + const result = format.parseToInternal(content) + const names = result.group?.participants?.map(p => p.name) + expect(names).toEqual(['p1', 'Participant 2']) + }) + + it('should collect errors for invalid expenses', () => { + const content = JSON.stringify({ + participants: [{ id: 'p1', name: 'Alice' }], + expenses: [ + { paidById: 'p1', amount: 'invalid', title: 'Bad Amount', expenseDate: '2023-01-01' }, + ], + }) + + // Note: The current implementation is quite lenient. + // - "invalid" amount might throw during coercion. + + const result = format.parseToInternal(content) + expect(result.errors).toHaveLength(1) + expect(result.errors?.[0].message).toContain('Invalid amount') + }) + }) +}) From d1dc759cb0d7057de6babfc78ea5ce472b00d26b Mon Sep 17 00:00:00 2001 From: Uli-Z <58443149+Uli-Z@users.noreply.github.com> Date: Tue, 9 Dec 2025 10:20:56 +0100 Subject: [PATCH 12/31] refactor(import): improve SpliitJsonFormat readability and maintainability Extracted complex parsing logic from 'parseToInternal' into smaller, private helper methods for better readability and easier maintenance. --- src/lib/imports/spliit-json.test.ts | 23 ++-- src/lib/imports/spliit-json.ts | 171 +++++++++++++++------------- 2 files changed, 109 insertions(+), 85 deletions(-) diff --git a/src/lib/imports/spliit-json.test.ts b/src/lib/imports/spliit-json.test.ts index 8ae7b7bda..33088707f 100644 --- a/src/lib/imports/spliit-json.test.ts +++ b/src/lib/imports/spliit-json.test.ts @@ -55,7 +55,7 @@ describe('SpliitJsonFormat', () => { }) const result = format.parseToInternal(content) - + expect(result.errors).toHaveLength(0) expect(result.group).toEqual({ name: 'My Group', @@ -63,7 +63,7 @@ describe('SpliitJsonFormat', () => { currencyCode: undefined, participants: [{ name: 'Alice' }, { name: 'Bob' }], }) - + expect(result.expenses).toHaveLength(1) const expense = result.expenses[0] expect(expense.title).toBe('Lunch') @@ -71,10 +71,10 @@ describe('SpliitJsonFormat', () => { expect(expense.paidBy).toBe('Alice') // ID resolved to name expect(expense.paidFor).toHaveLength(2) expect(expense.paidFor).toContainEqual( - expect.objectContaining({ participant: 'Bob', shares: 1 }) + expect.objectContaining({ participant: 'Bob', shares: 1 }), ) expect(expense.paidFor).toContainEqual( - expect.objectContaining({ participant: 'Alice', shares: 2 }) + expect.objectContaining({ participant: 'Alice', shares: 2 }), ) expect(expense.expenseDate).toBeInstanceOf(Date) expect(expense.expenseDate.toISOString()).toContain('2023-05-20') @@ -90,21 +90,26 @@ describe('SpliitJsonFormat', () => { }) const result = format.parseToInternal(content) - const names = result.group?.participants?.map(p => p.name) + const names = result.group?.participants?.map((p) => p.name) expect(names).toEqual(['p1', 'Participant 2']) }) it('should collect errors for invalid expenses', () => { - const content = JSON.stringify({ + const content = JSON.stringify({ participants: [{ id: 'p1', name: 'Alice' }], expenses: [ - { paidById: 'p1', amount: 'invalid', title: 'Bad Amount', expenseDate: '2023-01-01' }, + { + paidById: 'p1', + amount: 'invalid', + title: 'Bad Amount', + expenseDate: '2023-01-01', + }, ], }) - + // Note: The current implementation is quite lenient. // - "invalid" amount might throw during coercion. - + const result = format.parseToInternal(content) expect(result.errors).toHaveLength(1) expect(result.errors?.[0].message).toContain('Invalid amount') diff --git a/src/lib/imports/spliit-json.ts b/src/lib/imports/spliit-json.ts index fd967f8fa..2855c0411 100644 --- a/src/lib/imports/spliit-json.ts +++ b/src/lib/imports/spliit-json.ts @@ -144,18 +144,14 @@ export class SpliitJsonFormat implements ImportFormat { priority = 100 detect(content: string): number { - // Parse and validate minimal structure using the full content. return looksLikeSpliitJson(content) ? 0.95 : 0 } - // Convert parsed export into internal ExpenseFormValues. - // Participant ids remain as synthesized participant- from parseSpliitJson. parseToInternal(content: string): { expenses: ExpenseFormValues[] group?: import('@/lib/imports/types').ImportParsedGroupInfo errors?: { row: number; message: string }[] } { - // Parse complete JSON let raw: any try { raw = JSON.parse(content) @@ -163,20 +159,32 @@ export class SpliitJsonFormat implements ImportFormat { throw new Error('Invalid JSON: unable to parse file contents.') } - // Assume a well-formed export and avoid strict participant validation here. - // Any malformed expense will be skipped and reported. + const { externalIdToName, participantNames } = this.extractParticipants( + raw?.participants, + ) + const { expenses, errors } = this.extractExpenses( + raw?.expenses, + externalIdToName, + ) - const expenses: ExpenseFormValues[] = [] - const errors: { row: number; message: string }[] = [] + const group = { + name: raw?.name ?? undefined, + currency: raw?.currency ?? undefined, + currencyCode: raw?.currencyCode ?? undefined, + participants: participantNames.length + ? participantNames.map((name) => ({ name })) + : undefined, + } - const rawExpenses = Array.isArray(raw?.expenses) ? raw.expenses : [] + return { expenses, group, errors } + } - // Build a lookup from external participant id -> display name. - // Fallbacks ensure we always get a stable, human-readable name. + private extractParticipants(rawParticipants: any[]) { const externalIdToName = new Map() const participantNames: string[] = [] - if (Array.isArray(raw?.participants)) { - raw.participants.forEach((p: any, i: number) => { + + if (Array.isArray(rawParticipants)) { + rawParticipants.forEach((p: any, i: number) => { const id = typeof p?.id === 'string' ? p.id.trim() : '' const nameRaw = typeof p?.name === 'string' ? p.name.trim() : '' const name = nameRaw || id || `Participant ${i + 1}` @@ -184,37 +192,83 @@ export class SpliitJsonFormat implements ImportFormat { if (id) externalIdToName.set(id, name) }) } + return { externalIdToName, participantNames } + } + + private extractExpenses( + rawExpenses: any[], + externalIdToName: Map, + ) { + const expenses: ExpenseFormValues[] = [] + const errors: { row: number; message: string }[] = [] + + const list = Array.isArray(rawExpenses) ? rawExpenses : [] - // Build paidFor list from raw entries. - // Rules: - // - Keep only the first entry per participantId (ignore duplicates) - // - Coerce shares to positive integers (min 1) - const parsePaidFor = (entries: any[]): ExpenseFormValues['paidFor'] => { - const seen = new Set() - const paidFor: ExpenseFormValues['paidFor'] = [] - entries.forEach((pf: any) => { - const rawId = String(pf?.participantId ?? '').trim() - if (!rawId) throw new Error('paidFor without participantId') - if (seen.has(rawId)) return - seen.add(rawId) - const shares = Math.max( - 1, - Math.trunc(coerceNumber(pf?.shares ?? 1, 'shares')), + list.forEach((e: any, index: number) => { + try { + const paidByRaw = String(e?.paidById ?? '').trim() + if (!paidByRaw) throw new Error('Missing paidById') + const paidBy = externalIdToName.get(paidByRaw) ?? paidByRaw + + const paidFor = this.extractPaidFor( + Array.isArray(e?.paidFor) ? e.paidFor : [], + externalIdToName, ) - const name = externalIdToName.get(rawId) ?? rawId - paidFor.push({ participant: name, shares, originalAmount: undefined }) - }) - return paidFor - } - // Build the minimal ExpenseFormValues input for schema parsing. - // Applies light coercion + sensible defaults; schema handles the rest. - const toFormBase = ( - e: any, - index: number, - paidBy: string, - paidFor: ExpenseFormValues['paidFor'], - ) => ({ + const base = this.mapToFormBase( + e, + index, + paidBy, + paidFor, + ) + const result = expenseFormSchema.safeParse(base) + if (result.success) { + expenses.push(result.data) + } else { + const message = + result.error.issues.map((i) => i.message).join(', ') || + 'Invalid expense' + errors.push({ row: index + 1, message }) + } + } catch (err: any) { + errors.push({ + row: index + 1, + message: String(err?.message ?? 'Invalid expense'), + }) + } + }) + + return { expenses, errors } + } + + private extractPaidFor( + entries: any[], + externalIdToName: Map, + ): ExpenseFormValues['paidFor'] { + const seen = new Set() + const paidFor: ExpenseFormValues['paidFor'] = [] + entries.forEach((pf: any) => { + const rawId = String(pf?.participantId ?? '').trim() + if (!rawId) throw new Error('paidFor without participantId') + if (seen.has(rawId)) return + seen.add(rawId) + const shares = Math.max( + 1, + Math.trunc(coerceNumber(pf?.shares ?? 1, 'shares')), + ) + const name = externalIdToName.get(rawId) ?? rawId + paidFor.push({ participant: name, shares, originalAmount: undefined }) + }) + return paidFor + } + + private mapToFormBase( + e: any, + index: number, + paidBy: string, + paidFor: ExpenseFormValues['paidFor'], + ) { + return { expenseDate: coerceDate(e?.expenseDate), title: String(e?.title ?? '').trim() || `Expense ${index + 1}`, category: resolveCategoryId(e?.categoryId ?? e?.category), @@ -237,42 +291,7 @@ export class SpliitJsonFormat implements ImportFormat { notes: e?.notes ?? undefined, recurrenceRule: (e?.recurrenceRule as ExpenseFormValues['recurrenceRule']) ?? 'NONE', - }) - - rawExpenses.forEach((e: any, index: number) => { - try { - const paidByRaw = String(e?.paidById ?? '').trim() - if (!paidByRaw) throw new Error('Missing paidById') - const paidBy = externalIdToName.get(paidByRaw) ?? paidByRaw - - const paidFor = parsePaidFor(Array.isArray(e?.paidFor) ? e.paidFor : []) - const base = toFormBase(e, index, paidBy, paidFor) - const result = expenseFormSchema.safeParse(base) - if (result.success) expenses.push(result.data) - else { - const message = - result.error.issues.map((i) => i.message).join(', ') || - 'Invalid expense' - errors.push({ row: index + 1, message }) - } - } catch (err: any) { - errors.push({ - row: index + 1, - message: String(err?.message ?? 'Invalid expense'), - }) - } - }) - - const group = { - name: raw?.name ?? undefined, - currency: raw?.currency ?? undefined, - currencyCode: raw?.currencyCode ?? undefined, - participants: participantNames.length - ? participantNames.map((name) => ({ name })) - : undefined, } - - return { expenses, group, errors } } } From dbe30986ba8dc83952152f6be20d8ce5b7075db3 Mon Sep 17 00:00:00 2001 From: Uli-Z <58443149+Uli-Z@users.noreply.github.com> Date: Tue, 9 Dec 2025 10:21:00 +0100 Subject: [PATCH 13/31] feat(import): add cleanup for old import jobs Implemented a daily cleanup of ImportJob records older than 24 hours at the start of a new import, preventing database bloat from stale jobs. --- src/trpc/routers/groups/import/start-job.procedure.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/trpc/routers/groups/import/start-job.procedure.ts b/src/trpc/routers/groups/import/start-job.procedure.ts index b4675193b..a00aeaf51 100644 --- a/src/trpc/routers/groups/import/start-job.procedure.ts +++ b/src/trpc/routers/groups/import/start-job.procedure.ts @@ -15,6 +15,13 @@ export const startCreateImportFromFileProcedure = baseProcedure }), ) .mutation(async ({ input: { fileContent, groupName } }) => { + // Maintenance: Clean up old jobs (older than 24h) to prevent table bloat. + // This is a simple strategy that runs on every new import start. + const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000) + await prisma.importJob.deleteMany({ + where: { createdAt: { lt: yesterday } }, + }) + const trimmed = fileContent.trim() if (!trimmed) throw new Error('Uploaded file was empty.') const result = await buildExpensesFromFileImport(trimmed) From 33848cd8310691510a11e6e497726303b20b8ae7 Mon Sep 17 00:00:00 2001 From: Uli-Z <58443149+Uli-Z@users.noreply.github.com> Date: Tue, 9 Dec 2025 10:21:04 +0100 Subject: [PATCH 14/31] feat(import): add Zod validation for import job expenses Implemented Zod validation for 'expensesToCreate' in the ImportJob model when processing chunks. This ensures data integrity and prevents runtime errors from corrupted job data by safely parsing and validating the JSON. --- .../groups/import/run-chunk.procedure.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/trpc/routers/groups/import/run-chunk.procedure.ts b/src/trpc/routers/groups/import/run-chunk.procedure.ts index b12811156..b19d574ad 100644 --- a/src/trpc/routers/groups/import/run-chunk.procedure.ts +++ b/src/trpc/routers/groups/import/run-chunk.procedure.ts @@ -1,10 +1,12 @@ import { createExpense } from '@/lib/api' import { prisma } from '@/lib/prisma' -import { type ExpenseFormValues } from '@/lib/schemas' +import { expenseFormSchema, type ExpenseFormValues } from '@/lib/schemas' import { baseProcedure } from '@/trpc/init' import { z } from 'zod' import { getImportChunkSize } from './shared' +const storageSchema = z.array(expenseFormSchema) + // Consumes the next chunk of expenses and creates them in the DB. export const runCreateImportFromFileChunkProcedure = baseProcedure .input(z.object({ jobId: z.string().min(1) })) @@ -38,7 +40,18 @@ export const runCreateImportFromFileChunkProcedure = baseProcedure }) } - const allExpenses = job.expensesToCreate as unknown as ExpenseFormValues[] + // Safely parse expenses from JSON. + const parseResult = storageSchema.safeParse(job.expensesToCreate) + if (!parseResult.success) { + const msg = 'Import job data is corrupted.' + await prisma.importJob.update({ + where: { id: jobId }, + data: { status: 'FAILED', error: msg }, + }) + throw new Error(msg) + } + const allExpenses = parseResult.data + const step = Math.max(1, getImportChunkSize()) const startIndex = job.nextIndex const endIndex = Math.min(startIndex + step, allExpenses.length) From 32b086b9cc050cb85ec3a734a2882466216e06fe Mon Sep 17 00:00:00 2001 From: Uli-Z <58443149+Uli-Z@users.noreply.github.com> Date: Wed, 10 Dec 2025 11:04:24 +0100 Subject: [PATCH 15/31] feat(import): Finalize import feature with security, tests, and UI refactoring This commit consolidates the review feedback implementation: Security & Robustness: - Enforced a 10MB limit on file uploads to prevent DoS. - Implemented optimistic locking in chunk processing to prevent race conditions. Refactoring: - Extracted UI logic into 'useFileImportProcess' hook for better separation of concerns. - Centralized participant derivation logic in the import library. Quality: - Added integration tests for category mapping consistency. - Applied code formatting. --- src/components/file-import-modal.tsx | 388 +++--------------- src/components/import/use-import-process.ts | 342 +++++++++++++++ src/lib/imports/file-import.ts | 16 + src/lib/imports/spliit-json.test.ts | 102 ++++- src/lib/imports/spliit-json.ts | 11 +- .../groups/import/preview.procedure.ts | 5 +- .../groups/import/run-chunk.procedure.ts | 105 +++-- .../groups/import/start-job.procedure.ts | 20 +- 8 files changed, 602 insertions(+), 387 deletions(-) create mode 100644 src/components/import/use-import-process.ts diff --git a/src/components/file-import-modal.tsx b/src/components/file-import-modal.tsx index bb24f95c6..21ea62de5 100644 --- a/src/components/file-import-modal.tsx +++ b/src/components/file-import-modal.tsx @@ -13,6 +13,7 @@ import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react' import { ImportAnalysisPanel } from '@/components/import/import-analysis-panel' import { UploadDropzone } from '@/components/import/upload-dropzone' +import { useFileImportProcess } from '@/components/import/use-import-process' import { Button } from '@/components/ui/button' import { Dialog, @@ -22,28 +23,10 @@ import { DialogTrigger, } from '@/components/ui/dialog' import { Label } from '@/components/ui/label' -import { useToast } from '@/components/ui/use-toast' -import { type ImportBuildResult } from '@/lib/imports/file-import' -import { trpc } from '@/trpc/client' import { Loader2, Upload } from 'lucide-react' import { useTranslations } from 'next-intl' import { Input } from './ui/input' -type UploadState = { - csv: string - fileName: string | null -} - -// The server advances progress in ~10% increments per call. - -// Minimal shape of the result state used by the UI. -type ImportResultState = null | { - status: 'completed' | 'cancelled' - created: number - total: number - resultId: string -} - export function FileImportModal({ open: controlledOpen, onOpenChange, @@ -56,141 +39,42 @@ export function FileImportModal({ onCreateSuccess?: (result: { groupId: string; groupName: string }) => void }) { const t = useTranslations('FileImport') - const tErrors = useTranslations('FileImportErrors') - // i18n strings are provided via t(); formatting handled in subcomponents - const [groupName, setGroupName] = useState('') + const [dialogOpen, setDialogOpen] = useState(controlledOpen ?? false) const setOpen = onOpenChange ?? setDialogOpen const open = controlledOpen ?? dialogOpen - const { toast } = useToast() - const [uploadState, setUploadState] = useState({ - csv: '', - fileName: null, - }) - const [previewResult, setPreviewResult] = useState( - null, - ) - const [previewError, setPreviewError] = useState(null) - const scrollContainerRef = useRef(null) - const [hasReachedBottom, setHasReachedBottom] = useState(false) - const [isDraggingFile, setIsDraggingFile] = useState(false) - const [isProcessing, setIsProcessing] = useState(false) - const [currentJobId, setCurrentJobId] = useState(null) - const cancelRequestedRef = useRef(false) - const [isCancellingImport, setIsCancellingImport] = useState(false) - const [importResult, setImportResult] = useState(null) - const [resultActionLoading, setResultActionLoading] = useState(false) - const [importProgress, setImportProgress] = useState<{ - processed: number - total: number - }>({ - processed: 0, - total: 0, - }) - - const localizeErrorMessage = useCallback( - (message: string) => { - const normalized = message.toLowerCase() - if (normalized.includes('no participants')) - return tErrors('noParticipants') - if (normalized.includes('uploaded file was empty')) - return tErrors('fileEmpty') - if (normalized.includes('invalid amount')) return tErrors('invalidAmount') - if (normalized.includes('invalid expense date')) - return tErrors('invalidDate') - return message - }, - [tErrors], - ) - const utils = trpc.useUtils() - - // Step 1: Preview the uploaded file to show warnings and totals before importing. - const previewMutation = trpc.groups.importFromFilePreview.useMutation({ - onSuccess(result) { - setPreviewResult(result) - setPreviewError(null) - setIsProcessing(false) - }, - onError(error) { - setPreviewResult(null) - setPreviewError(localizeErrorMessage(error.message)) - setIsProcessing(false) - }, + const { + processState, + fileName, + groupName, + setGroupName, + previewResult, + previewError, + importProgress, + importResult, + resultActionLoading, + analyzeFile, + startImport, + requestCancel, + finalizeImport, + resetProcess, + } = useFileImportProcess({ + onImportSuccess: onCreateSuccess, + onClose: () => setOpen(false), }) - // Import job mutations (create a new group from file) - const startCreateImportMutation = - trpc.groups.importFromFileStartJob.useMutation({ - onError(error) { - setIsProcessing(false) - toast({ - title: t('errorTitle'), - description: error.message, - variant: 'destructive', - }) - }, - }) - const runCreateImportChunkMutation = - trpc.groups.importFromFileRunChunk.useMutation({ - onError(error) { - setIsProcessing(false) - toast({ - title: t('errorTitle'), - description: error.message, - variant: 'destructive', - }) - }, - }) - const cancelCreateImportMutation = - trpc.groups.importFromFileCancelJob.useMutation({ - onError(error) { - setIsCancellingImport(false) - toast({ - title: t('errorTitle'), - description: error.message, - variant: 'destructive', - }) - }, - }) - const finalizeCreateImportMutation = - trpc.groups.importFromFileFinalize.useMutation({ - onError(error) { - toast({ - title: t('errorTitle'), - description: error.message, - variant: 'destructive', - }) - }, - }) - - const analyzeCsv = useCallback( - (content: string) => { - if (!content.trim()) { - setPreviewResult(null) - return - } - - setPreviewResult(null) - setPreviewError(null) - setIsProcessing(true) - previewMutation.mutate({ - fileContent: content, - fileName: uploadState.fileName ?? undefined, - }) - }, - [previewMutation, uploadState.fileName], - ) + const scrollContainerRef = useRef(null) + const [hasReachedBottom, setHasReachedBottom] = useState(false) const handleFileRead = (file: File) => { const reader = new FileReader() reader.onload = () => { const content = String(reader.result ?? '') - setUploadState({ csv: content, fileName: file.name }) - analyzeCsv(content) + analyzeFile(content, file.name) } reader.onerror = () => { - setPreviewError(localizeErrorMessage(t('fileReadError'))) + // Error handling is now in the hook } reader.readAsText(file, 'utf-8') } @@ -204,135 +88,18 @@ export function FileImportModal({ const handleDropSelect = (file: File) => handleFileRead(file) - const totalRows = - (previewResult?.expenses.length ?? 0) + (previewResult?.errors.length ?? 0) const hasFatalErrors = (previewResult?.errors.length ?? 0) > 0 const canImport = Boolean(previewResult) && !hasFatalErrors - const jobRunning = Boolean(currentJobId) + const jobRunning = processState === 'importing' const importLoading = - jobRunning || - startCreateImportMutation.status === 'pending' || - runCreateImportChunkMutation.status === 'pending' || - cancelCreateImportMutation.status === 'pending' - - // Keep a stable reference to the mutation reset function so the cleanup - // effect can call it without triggering new renders when the mutation object - // identity changes. - const previewResetRef = useRef(previewMutation.reset) - - useEffect(() => { - previewResetRef.current = previewMutation.reset - }, [previewMutation.reset]) + processState === 'analyzing' || processState === 'importing' + // Reset process when modal closes useEffect(() => { if (!open) { - setUploadState({ csv: '', fileName: null }) - setPreviewResult(null) - setPreviewError(null) - setHasReachedBottom(false) - setImportProgress({ processed: 0, total: 0 }) - setIsProcessing(false) - setCurrentJobId(null) - cancelRequestedRef.current = false - setIsCancellingImport(false) - setImportResult(null) - setGroupName('') - previewResetRef.current() + resetProcess() } - }, [open]) - - useEffect(() => { - setHasReachedBottom(false) - }, [previewResult]) - - useEffect(() => { - if (!previewResult) return - if (!groupName && previewResult.group?.name) { - setGroupName(previewResult.group.name) - } - }, [groupName, previewResult]) - - // Participant matching is not part of this modal - - const handleStartImport = useCallback(async () => { - if (!canImport || importLoading) return - cancelRequestedRef.current = false - setIsCancellingImport(false) - setImportResult(null) - setIsProcessing(true) - try { - const start = await startCreateImportMutation.mutateAsync({ - fileContent: uploadState.csv, - groupName: groupName.trim() || undefined, - fileName: uploadState.fileName ?? undefined, - }) - setCurrentJobId(start.jobId) - setImportProgress({ processed: 0, total: start.totalExpenses }) - - let finalResult: ImportResultState = null - while (!cancelRequestedRef.current) { - const chunk = await runCreateImportChunkMutation.mutateAsync({ - jobId: start.jobId, - }) - setImportProgress({ processed: chunk.processed, total: chunk.total }) - if (chunk.done && chunk.resultId) { - finalResult = { - status: 'completed', - created: chunk.processed, - total: chunk.total, - resultId: chunk.resultId, - } - ;(finalResult as any).groupId = chunk.groupId - ;(finalResult as any).groupName = chunk.groupName - break - } - } - - if (cancelRequestedRef.current && !finalResult) { - setIsCancellingImport(true) - const cancel = await cancelCreateImportMutation.mutateAsync({ - jobId: start.jobId, - }) - finalResult = { - status: 'cancelled', - created: cancel.processed, - total: cancel.total, - resultId: cancel.resultId, - } - ;(finalResult as any).groupId = cancel.groupId - ;(finalResult as any).groupName = cancel.groupName - } - - if (finalResult) { - setImportResult(finalResult) - } - } catch (error) { - if (error instanceof Error) { - toast({ - title: t('errorTitle'), - description: error.message, - variant: 'destructive', - }) - } - setImportProgress({ processed: 0, total: 0 }) - } finally { - cancelRequestedRef.current = false - setCurrentJobId(null) - setIsCancellingImport(false) - setIsProcessing(false) - } - }, [ - canImport, - importLoading, - startCreateImportMutation, - runCreateImportChunkMutation, - cancelCreateImportMutation, - uploadState.csv, - uploadState.fileName, - groupName, - t, - toast, - ]) + }, [open, resetProcess]) const handleScroll = useCallback(() => { const element = scrollContainerRef.current @@ -342,45 +109,10 @@ export function FileImportModal({ setHasReachedBottom(reachedBottom) }, []) - const handleCancelImport = useCallback(() => { - if (!currentJobId) return - cancelRequestedRef.current = true - setIsCancellingImport(true) - }, [currentJobId]) - - // When cancel is requested (via X or button), we only set the cancel flag here. - // The running import loop will notice it, finish the current chunk, then perform - // a single cancel request to the server. This avoids race conditions where the - // group gets deleted while a chunk is still creating expenses. - const requestCancel = useCallback(() => { - if (!currentJobId) return - cancelRequestedRef.current = true - setIsCancellingImport(true) - }, [currentJobId]) - - // Finalize/undo is not included here - useEffect(() => { handleScroll() }, [handleScroll, previewResult, importProgress]) - useEffect(() => { - const anyPending = - previewMutation.status === 'pending' || - startCreateImportMutation.status === 'pending' || - runCreateImportChunkMutation.status === 'pending' || - cancelCreateImportMutation.status === 'pending' - - if (!anyPending) { - setIsProcessing(false) - } - }, [ - previewMutation.status, - startCreateImportMutation?.status, - runCreateImportChunkMutation?.status, - cancelCreateImportMutation?.status, - ]) - const renderContent = () => { if (jobRunning) { return ( @@ -410,10 +142,15 @@ export function FileImportModal({
) @@ -437,30 +174,7 @@ export function FileImportModal({ @@ -482,14 +194,12 @@ export function FileImportModal({ ) } - // jobRunning branch above covers both existing and create flows - return (
@@ -516,7 +226,7 @@ export function FileImportModal({
{t('Dialog.titleLabel')} -
{receiptInfo ? receiptInfo.title ?? : '…'}
+
{receiptInfo ? (receiptInfo.title ?? ) : '…'}
{t('Dialog.categoryLabel')} diff --git a/src/app/groups/[groupId]/expenses/expense-form.tsx b/src/app/groups/[groupId]/expenses/expense-form.tsx index ebf2c883c..4f3f921bc 100644 --- a/src/app/groups/[groupId]/expenses/expense-form.tsx +++ b/src/app/groups/[groupId]/expenses/expense-form.tsx @@ -209,64 +209,64 @@ export function ExpenseForm({ recurrenceRule: expense.recurrenceRule ?? undefined, } : searchParams.get('reimbursement') - ? { - title: t('reimbursement'), - expenseDate: new Date(), - amount: amountAsDecimal( - Number(searchParams.get('amount')) || 0, - groupCurrency, - ), - originalCurrency: group.currencyCode, - originalAmount: undefined, - conversionRate: undefined, - category: 1, // category with Id 1 is Payment - paidBy: searchParams.get('from') ?? undefined, - paidFor: [ - searchParams.get('to') - ? { - participant: searchParams.get('to')!, - shares: '1' as any, // String for consistent form handling - } - : undefined, - ], - isReimbursement: true, - splitMode: defaultSplittingOptions.splitMode, - saveDefaultSplittingOptions: false, - documents: [], - notes: '', - recurrenceRule: RecurrenceRule.NONE, - } - : { - title: searchParams.get('title') ?? '', - expenseDate: searchParams.get('date') - ? new Date(searchParams.get('date') as string) - : new Date(), - amount: Number(searchParams.get('amount')) || 0, - originalCurrency: group.currencyCode ?? undefined, - originalAmount: undefined, - conversionRate: undefined, - category: searchParams.get('categoryId') - ? Number(searchParams.get('categoryId')) - : 0, // category with Id 0 is General - // paid for all, split evenly - paidFor: defaultSplittingOptions.paidFor, - paidBy: getSelectedPayer(), - isReimbursement: false, - splitMode: defaultSplittingOptions.splitMode, - saveDefaultSplittingOptions: false, - documents: searchParams.get('imageUrl') - ? [ - { - id: randomId(), - url: searchParams.get('imageUrl') as string, - width: Number(searchParams.get('imageWidth')), - height: Number(searchParams.get('imageHeight')), - }, - ] - : [], - notes: '', - recurrenceRule: RecurrenceRule.NONE, - }, + ? { + title: t('reimbursement'), + expenseDate: new Date(), + amount: amountAsDecimal( + Number(searchParams.get('amount')) || 0, + groupCurrency, + ), + originalCurrency: group.currencyCode, + originalAmount: undefined, + conversionRate: undefined, + category: 1, // category with Id 1 is Payment + paidBy: searchParams.get('from') ?? undefined, + paidFor: [ + searchParams.get('to') + ? { + participant: searchParams.get('to')!, + shares: '1' as any, // String for consistent form handling + } + : undefined, + ], + isReimbursement: true, + splitMode: defaultSplittingOptions.splitMode, + saveDefaultSplittingOptions: false, + documents: [], + notes: '', + recurrenceRule: RecurrenceRule.NONE, + } + : { + title: searchParams.get('title') ?? '', + expenseDate: searchParams.get('date') + ? new Date(searchParams.get('date') as string) + : new Date(), + amount: Number(searchParams.get('amount')) || 0, + originalCurrency: group.currencyCode ?? undefined, + originalAmount: undefined, + conversionRate: undefined, + category: searchParams.get('categoryId') + ? Number(searchParams.get('categoryId')) + : 0, // category with Id 0 is General + // paid for all, split evenly + paidFor: defaultSplittingOptions.paidFor, + paidBy: getSelectedPayer(), + isReimbursement: false, + splitMode: defaultSplittingOptions.splitMode, + saveDefaultSplittingOptions: false, + documents: searchParams.get('imageUrl') + ? [ + { + id: randomId(), + url: searchParams.get('imageUrl') as string, + width: Number(searchParams.get('imageWidth')), + height: Number(searchParams.get('imageHeight')), + }, + ] + : [], + notes: '', + recurrenceRule: RecurrenceRule.NONE, + }, }) const [isCategoryLoading, setCategoryLoading] = useState(false) const activeUserId = useActiveUser(group.id) @@ -927,12 +927,12 @@ export function ExpenseForm({ 'BY_PERCENTAGE' ? Number(shares) * 100 // Convert percentage to basis points (e.g., 50% -> 5000) : form.watch('splitMode') === - 'BY_AMOUNT' - ? amountAsMinorUnits( - shares, - groupCurrency, - ) - : shares, + 'BY_AMOUNT' + ? amountAsMinorUnits( + shares, + groupCurrency, + ) + : shares, expenseId: '', participantId: '', }), diff --git a/src/components/money.tsx b/src/components/money.tsx index e3cac1d4d..4bd0d2ce4 100644 --- a/src/components/money.tsx +++ b/src/components/money.tsx @@ -23,8 +23,8 @@ export function Money({ colored && amount <= 1 ? 'text-red-600' : colored && amount >= 1 - ? 'text-green-600' - : '', + ? 'text-green-600' + : '', bold && 'font-bold', )} > diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index 2fa8629be..d66a5e864 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -119,9 +119,10 @@ export const expenseFormSchema = z } }), splitMode: z - .enum( - Object.values(SplitMode) as any, - ) + .enum< + SplitMode, + [SplitMode, ...SplitMode[]] + >(Object.values(SplitMode) as any) .default('EVENLY'), saveDefaultSplittingOptions: z.boolean(), isReimbursement: z.boolean(), @@ -137,9 +138,10 @@ export const expenseFormSchema = z .default([]), notes: z.string().optional(), recurrenceRule: z - .enum( - Object.values(RecurrenceRule) as any, - ) + .enum< + RecurrenceRule, + [RecurrenceRule, ...RecurrenceRule[]] + >(Object.values(RecurrenceRule) as any) .default('NONE'), }) .superRefine((expense, ctx) => {