mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-30 18:46:00 +00:00
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:
@@ -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) {
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user