From 6a70f4fc41fa77a7f490761fb2f3b32ab42cdddb Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 26 Jun 2026 16:27:06 +0200 Subject: [PATCH] fix(import): persist source files in IndexedDB so attach survives a reload The source document was only kept in memory on the background task, so a page reload during the (now always-LLM ~25s) parse lost it and the booking saved without its file. Store the uploaded files in IndexedDB keyed by job id; the review loads them from there when the in-memory copy is gone, and a 1h TTL prunes abandoned imports. --- .../components/Planner/BookingImportModal.tsx | 5 +- client/src/db/offlineDb.ts | 48 +++++++++++++++++++ client/src/pages/TripPlannerPage.tsx | 14 ++++-- 3 files changed, 62 insertions(+), 5 deletions(-) diff --git a/client/src/components/Planner/BookingImportModal.tsx b/client/src/components/Planner/BookingImportModal.tsx index 9407ac79..3580d6fe 100644 --- a/client/src/components/Planner/BookingImportModal.tsx +++ b/client/src/components/Planner/BookingImportModal.tsx @@ -4,6 +4,7 @@ import { Upload, X } from 'lucide-react' import { useTranslation } from '../../i18n' import { reservationsApi, healthApi } from '../../api/client' import { useBackgroundTasksStore } from '../../store/backgroundTasksStore' +import { saveImportFiles } from '../../db/offlineDb' interface BookingImportModalProps { isOpen: boolean @@ -96,7 +97,9 @@ export default function BookingImportModal({ isOpen, onClose, tripId }: BookingI try { const mode = aiParsing ? 'fallback-on-empty' : 'no-ai' const { jobId } = await reservationsApi.importBookingAsync(tripId, files, mode) - // Keep the uploaded files so the review can attach each source document to its booking. + // Keep the uploaded files so the review can attach each source document to its booking — + // in memory for the immediate path, and in IndexedDB so it survives a reload mid-parse. + await saveImportFiles(jobId, files) addTask({ id: jobId, tripId: String(tripId), label: files.map((f) => f.name).join(', '), total: files.length, files }) handleClose() } catch (err: any) { diff --git a/client/src/db/offlineDb.ts b/client/src/db/offlineDb.ts index 51d42c82..536e8c8a 100644 --- a/client/src/db/offlineDb.ts +++ b/client/src/db/offlineDb.ts @@ -60,6 +60,15 @@ export interface BlobCacheEntry { cachedAt: number; } +/** An uploaded booking-import source file, kept so the review flow can attach it to the + * created bookings even after a page reload during the (background) parse. Keyed by job. */ +export interface ImportSourceFile { + jobId: string; + fileName: string; + blob: Blob; + createdAt: number; +} + // ── Dexie class ──────────────────────────────────────────────────────────────── /** @@ -105,6 +114,7 @@ class TrekOfflineDb extends Dexie { mutationQueue!: Table; syncMeta!: Table; blobCache!: Table; + importFiles!: Table; constructor(name: string = ANON_DB_NAME) { super(name); @@ -140,6 +150,11 @@ class TrekOfflineDb extends Dexie { if (row.bytes == null) row.bytes = row.blob?.size ?? 0; }); }); + + // v4: durable store for booking-import source files (survives a reload mid-parse). + this.version(4).stores({ + importFiles: '[jobId+fileName], jobId, createdAt', + }); } } @@ -264,6 +279,39 @@ export async function getCachedBlob(url: string): Promise { } } +// ── Booking-import source files ───────────────────────────────────────────── + +/** Abandoned import files (never reviewed) are pruned after this long. */ +const IMPORT_FILE_TTL_MS = 60 * 60_000; + +/** + * Persist the uploaded source files for a background import job so the per-item review can + * attach each document to its booking even if the page reloads during the parse. Best-effort. + */ +export async function saveImportFiles(jobId: string, files: File[]): Promise { + try { + const now = Date.now(); + await offlineDb.importFiles.bulkPut(files.map(f => ({ jobId, fileName: f.name, blob: f, createdAt: now }))); + // Prune leftovers from imports that were never reviewed. + await offlineDb.importFiles.where('createdAt').below(now - IMPORT_FILE_TTL_MS).delete(); + } catch { /* the in-memory copy still serves the no-reload path */ } +} + +/** A job's stored source files, rebuilt as File objects (name + type preserved for upload). */ +export async function getImportFiles(jobId: string): Promise { + try { + const rows = await offlineDb.importFiles.where('jobId').equals(jobId).toArray(); + return rows.map(r => new File([r.blob], r.fileName, { type: r.blob.type || 'application/octet-stream' })); + } catch { + return []; + } +} + +/** Drop a job's stored source files once they've been handed to the review flow. */ +export async function deleteImportFiles(jobId: string): Promise { + try { await offlineDb.importFiles.where('jobId').equals(jobId).delete(); } catch { /* ignore */ } +} + // ── Blob-cache budget ─────────────────────────────────────────────────────── /** diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 2905add8..2ddad092 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -36,7 +36,7 @@ import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen, import { useTranslation } from '../i18n' import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, mapsApi } from '../api/client' import { accommodationRepo } from '../repo/accommodationRepo' -import { offlineDb } from '../db/offlineDb' +import { offlineDb, getImportFiles, deleteImportFiles } from '../db/offlineDb' import { useAuthStore } from '../store/authStore' import ConfirmDialog from '../components/shared/ConfirmDialog' import { useResizablePanels } from '../hooks/useResizablePanels' @@ -224,9 +224,15 @@ export default function TripPlannerPage(): React.ReactElement | null { // Hand the items (and the source files, to attach to each booking) to the review flow // and clear the widget entry — once the user hit "review", the background card is done. const items = task.items - const sourceFiles = task.sourceFiles - dismissBgTask(task.id) - startImportReview(items, sourceFiles) + const jobId = task.id + const inMemory = task.sourceFiles + dismissBgTask(jobId) + // Prefer the in-memory files (immediate path); after a reload they live in IndexedDB. + void (async () => { + const files = inMemory && inMemory.length ? inMemory : await getImportFiles(jobId) + deleteImportFiles(jobId) + startImportReview(items, files) + })() } }, [bgTasks, tripId, startImportReview, dismissBgTask])