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.
This commit is contained in:
Maurice
2026-06-26 16:27:06 +02:00
committed by Maurice
parent 27fbc241e8
commit 6a70f4fc41
3 changed files with 62 additions and 5 deletions
@@ -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) {
+48
View File
@@ -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<QueuedMutation, string>;
syncMeta!: Table<SyncMeta, number>;
blobCache!: Table<BlobCacheEntry, string>;
importFiles!: Table<ImportSourceFile, [string, string]>;
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<Blob | null> {
}
}
// ── 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<void> {
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<File[]> {
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<void> {
try { await offlineDb.importFiles.where('jobId').equals(jobId).delete(); } catch { /* ignore */ }
}
// ── Blob-cache budget ───────────────────────────────────────────────────────
/**
+10 -4
View File
@@ -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])