diff --git a/client/src/App.tsx b/client/src/App.tsx index 6bdae822..ff0899d2 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -20,6 +20,7 @@ import SharedTripPage from './pages/SharedTripPage' import InAppNotificationsPage from './pages/InAppNotificationsPage.tsx' import OAuthAuthorizePage from './pages/OAuthAuthorizePage' import { ToastContainer } from './components/shared/Toast' +import BackgroundTasksWidget from './components/BackgroundTasks/BackgroundTasksWidget' import BottomNav from './components/Layout/BottomNav' import { TranslationProvider, useTranslation } from './i18n' import { authApi } from './api/client' @@ -208,6 +209,7 @@ export default function App() { {!isAuthPage && } + {!isAuthPage && } } /> diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 340d304e..1416082a 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -671,6 +671,17 @@ export const reservationsApi = { }, importBookingConfirm: (tripId: number | string, items: BookingImportPreviewItem[]): Promise => apiClient.post(`/trips/${tripId}/reservations/import/booking/confirm`, { items }).then(r => r.data), + // Start a background parse: returns a job id at once; progress + result arrive + // over the WebSocket (import:progress / import:done / import:error). + importBookingAsync: (tripId: number | string, files: File[], mode: BookingImportMode = 'no-ai'): Promise<{ jobId: string }> => { + const fd = new FormData() + for (const f of files) fd.append('files', f) + fd.append('mode', mode) + return apiClient.post(`/trips/${tripId}/reservations/import/booking/async`, fd, { headers: { 'Content-Type': 'multipart/form-data' }, timeout: 0 }).then(r => r.data) + }, + // Poll a background job — recovery path when a WebSocket push was missed. + importJobStatus: (tripId: number | string, jobId: string): Promise<{ status: 'running' | 'done' | 'error'; done: number; total: number; result?: BookingImportPreviewResponse; error?: string }> => + apiClient.get(`/trips/${tripId}/reservations/import/jobs/${jobId}`).then(r => r.data), } export const healthApi = { diff --git a/client/src/components/BackgroundTasks/BackgroundTasksWidget.tsx b/client/src/components/BackgroundTasks/BackgroundTasksWidget.tsx new file mode 100644 index 00000000..6624c654 --- /dev/null +++ b/client/src/components/BackgroundTasks/BackgroundTasksWidget.tsx @@ -0,0 +1,133 @@ +import ReactDOM from 'react-dom' +import { useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { Loader2, CheckCircle2, AlertCircle, X } from 'lucide-react' +import { useTranslation } from '../../i18n' +import { addListener, removeListener } from '../../api/websocket' +import { reservationsApi } from '../../api/client' +import { useBackgroundTasksStore, type BackgroundImportTask } from '../../store/backgroundTasksStore' + +/** + * Global, route-independent widget (bottom-right) that tracks background booking + * imports. Mounted once at the app root so it survives navigation. It listens to the + * user's WebSocket for import:progress / import:done / import:error and reflects each + * job; a finished job offers a "review" action that takes the user to the trip, where + * the per-item review flow opens. Polls running jobs as a backstop for missed pushes. + */ +export default function BackgroundTasksWidget() { + const { t } = useTranslation() + const navigate = useNavigate() + const tasks = useBackgroundTasksStore((s) => s.tasks) + const setProgress = useBackgroundTasksStore((s) => s.setProgress) + const setDone = useBackgroundTasksStore((s) => s.setDone) + const setError = useBackgroundTasksStore((s) => s.setError) + const requestReview = useBackgroundTasksStore((s) => s.requestReview) + const dismiss = useBackgroundTasksStore((s) => s.dismiss) + + // Server pushes import:* to the user on whatever page they're on. + useEffect(() => { + const handler = (e: Record) => { + const type = typeof e.type === 'string' ? e.type : '' + if (!type.startsWith('import:')) return + const id = String(e.jobId ?? '') + const tripId = String(e.tripId ?? '') + if (!id) return + if (type === 'import:progress') setProgress(id, tripId, Number(e.done ?? 0), Number(e.total ?? 1)) + else if (type === 'import:done') { + const result = e.result as { items?: unknown[]; warnings?: string[] } | undefined + setDone(id, tripId, (result?.items ?? []) as never, result?.warnings ?? []) + } else if (type === 'import:error') setError(id, tripId, String(e.message ?? 'error')) + } + addListener(handler) + return () => removeListener(handler) + }, [setProgress, setDone, setError]) + + // Backstop: poll running jobs in case a WebSocket push was missed on reconnect. + useEffect(() => { + const running = tasks.filter((task) => task.status === 'running') + if (running.length === 0) return + const iv = setInterval(() => { + for (const task of running) { + reservationsApi + .importJobStatus(task.tripId, task.id) + .then((s) => { + if (s.status === 'done') setDone(task.id, task.tripId, (s.result?.items ?? []) as never, s.result?.warnings ?? []) + else if (s.status === 'error') setError(task.id, task.tripId, s.error ?? 'error') + else setProgress(task.id, task.tripId, s.done, s.total) + }) + .catch(() => {}) + } + }, 5000) + return () => clearInterval(iv) + }, [tasks, setProgress, setDone, setError]) + + if (tasks.length === 0) return null + + const review = (task: BackgroundImportTask) => { + requestReview(task.id) + navigate(`/trips/${task.tripId}`) + } + + return ReactDOM.createPortal( +
+ {tasks.map((task) => ( +
+
+ {task.status === 'running' && } + {task.status === 'done' && } + {task.status === 'error' && } +
+ +
+
+ {task.label} +
+ + {task.status === 'running' && ( +
+ {t('reservations.import.parsing')} + {task.total > 1 ? ` · ${task.done}/${task.total}` : ''} +
+ )} + + {task.status === 'done' && ( + (task.items?.length ?? 0) > 0 ? ( + + ) : ( +
{t('reservations.import.previewEmpty')}
+ ) + )} + + {task.status === 'error' && ( +
{task.error}
+ )} +
+ + {task.status !== 'running' && ( + + )} +
+ ))} +
, + document.body + ) +} diff --git a/client/src/components/Planner/BookingImportModal.tsx b/client/src/components/Planner/BookingImportModal.tsx index 721e466a..4d2790f2 100644 --- a/client/src/components/Planner/BookingImportModal.tsx +++ b/client/src/components/Planner/BookingImportModal.tsx @@ -1,95 +1,43 @@ -import React from 'react' import ReactDOM from 'react-dom' import { useState, useRef, useEffect } from 'react' -import { Upload, Plane, Train, Hotel, UtensilsCrossed, Car, Anchor, Calendar, ArrowLeft, X } from 'lucide-react' -import type { BookingImportPreviewItem, BookingImportFileReport } from '@trek/shared' +import { Upload, X } from 'lucide-react' import { useTranslation } from '../../i18n' -import { useToast } from '../shared/Toast' import { reservationsApi, healthApi } from '../../api/client' -import { useTripStore } from '../../store/tripStore' +import { useBackgroundTasksStore } from '../../store/backgroundTasksStore' interface BookingImportModalProps { isOpen: boolean onClose: () => void tripId: number - pushUndo?: (label: string, undoFn: () => Promise | void) => void - // Fired after a successful import so the page can refresh state that lives - // outside the trip store — notably the accommodations list a hotel booking - // links to (loadTrip alone leaves it stale, so the edit modal shows blanks). - onImported?: () => void - // When provided, the parsed items aren't persisted directly: each is handed - // back so the page can open the normal edit modal pre-filled for review before - // the user saves it. Falls back to direct confirm() when absent. - onReview?: (items: BookingImportPreviewItem[]) => void } const ACCEPTED_EXTS = ['.eml', '.pdf', '.pkpass', '.html', '.htm', '.txt'] const MAX_FILE_BYTES = 10 * 1024 * 1024 const MAX_FILES = 5 -const TYPE_ICONS: Record> = { - flight: Plane, - train: Train, - hotel: Hotel, - restaurant: UtensilsCrossed, - car: Car, - cruise: Anchor, - event: Calendar, -} - -function typeColor(type: string): string { - const map: Record = { - flight: '#3b82f6', - train: '#10b981', - hotel: '#8b5cf6', - restaurant: '#f59e0b', - car: '#6b7280', - cruise: '#06b6d4', - event: '#ec4899', - } - return map[type] ?? 'var(--text-faint)' -} - -function formatDateTime(iso: unknown): string { - if (!iso) return '' - const str = typeof iso === 'string' ? iso : typeof iso === 'object' ? JSON.stringify(iso) : String(iso) - const date = str.slice(0, 10) - const time = str.length > 10 ? str.slice(11, 16) : '' - return [date, time].filter(Boolean).join(' ') -} - -export default function BookingImportModal({ isOpen, onClose, tripId, pushUndo, onImported, onReview }: BookingImportModalProps) { +/** + * Upload booking files and kick off a BACKGROUND parse. The modal closes at once; + * the parse runs server-side and is tracked by the global BackgroundTasksWidget + * (progress over the WebSocket). When it finishes, the trip page opens the per-item + * review flow — so the user can navigate and keep editing while it works. + */ +export default function BookingImportModal({ isOpen, onClose, tripId }: BookingImportModalProps) { const { t } = useTranslation() - const toast = useToast() - const loadTrip = useTripStore((s) => s.loadTrip) + const addTask = useBackgroundTasksStore((s) => s.addTask) const fileInputRef = useRef(null) const mouseDownTarget = useRef(null) - type Phase = 'upload' | 'preview' | 'confirming' - const [phase, setPhase] = useState('upload') const [files, setFiles] = useState([]) const [isDragOver, setIsDragOver] = useState(false) const [loading, setLoading] = useState(false) const [error, setError] = useState('') - const [previewItems, setPreviewItems] = useState([]) - const [warnings, setWarnings] = useState([]) - const [excluded, setExcluded] = useState>(() => new Set()) - // AI fallback: addon-level availability + per-file report + in-flight retries. const [aiParsing, setAiParsing] = useState(false) - const [fileReports, setFileReports] = useState([]) - const [retrying, setRetrying] = useState>(() => new Set()) const reset = () => { - setPhase('upload') setFiles([]) setIsDragOver(false) setLoading(false) setError('') - setPreviewItems([]) - setWarnings([]) - setExcluded(new Set()) - setFileReports([]) - setRetrying(new Set()) } useEffect(() => { @@ -100,7 +48,7 @@ export default function BookingImportModal({ isOpen, onClose, tripId, pushUndo, useEffect(() => { if (!isOpen) return - healthApi.features().then(f => setAiParsing(!!f.aiParsing)).catch(() => setAiParsing(false)) + healthApi.features().then((f) => setAiParsing(!!f.aiParsing)).catch(() => setAiParsing(false)) }, [isOpen]) const handleClose = () => { reset(); onClose() } @@ -140,116 +88,41 @@ export default function BookingImportModal({ isOpen, onClose, tripId, pushUndo, if (list.length) selectFiles(list) } + // Start the parse in the background and close — the widget takes it from here. const handleParse = async () => { if (files.length === 0 || loading) return setLoading(true) setError('') try { - // Auto-rescue: whenever AI parsing is available, files kitinerary can't - // read fall back to the LLM automatically — no extra confirmation step. const mode = aiParsing ? 'fallback-on-empty' : 'no-ai' - const result = await reservationsApi.importBookingPreview(tripId, files, mode) - setPreviewItems(result.items ?? []) - setWarnings(result.warnings ?? []) - setFileReports(result.files ?? []) - setExcluded(new Set()) - setPhase('preview') - } catch (err: any) { - const msg = err?.response?.data?.error ?? t('reservations.import.error') - setError(msg) - } finally { - setLoading(false) - } - } - - // Re-run a single file through the LLM (force-ai) and merge any new items in. - const handleRetryAi = async (fileName: string) => { - const file = files.find(f => f.name === fileName) - if (!file || retrying.has(fileName)) return - setRetrying(prev => new Set(prev).add(fileName)) - try { - const result = await reservationsApi.importBookingPreview(tripId, [file], 'force-ai') - setPreviewItems(prev => [...prev, ...(result.items ?? [])]) - setWarnings(prev => [...prev, ...(result.warnings ?? [])]) - setFileReports(prev => prev.map(r => r.fileName === fileName ? { ...r, aiUsed: true } : r)) - } catch (err: any) { - const msg = err?.response?.data?.error ?? t('reservations.import.error') - setError(msg) - } finally { - setRetrying(prev => { const next = new Set(prev); next.delete(fileName); return next }) - } - } - - const handleConfirm = async () => { - const toImport = previewItems.filter((_, i) => !excluded.has(i)) - if (toImport.length === 0) return - // Review-first flow: hand the parsed items to the page, which opens the - // normal edit modal pre-filled for each so the user checks before saving. - if (onReview) { onReview(toImport); handleClose(); return } - setPhase('confirming') - setError('') - try { - const result = await reservationsApi.importBookingConfirm(tripId, toImport) - const created = result.created ?? [] - await loadTrip(tripId) - // Refresh out-of-store state (accommodations) so a freshly imported hotel - // resolves its place/date range in the reservation edit modal. - onImported?.() - - if (created.length > 0) { - pushUndo?.(t('undo.importBooking'), async () => { - try { - const { reservationsApi: rApi } = await import('../../api/client') - await Promise.all(created.map((r) => rApi.delete(tripId, r.id).catch(() => {}))) - } catch {} - await loadTrip(tripId) - }) - toast.success(t('reservations.import.success', { count: created.length })) - } else { - toast.warning(t('reservations.import.previewEmpty')) - } - + const { jobId } = await reservationsApi.importBookingAsync(tripId, files, mode) + addTask({ id: jobId, tripId: String(tripId), label: files.map((f) => f.name).join(', '), total: files.length }) handleClose() } catch (err: any) { setError(err?.response?.data?.error ?? t('reservations.import.error')) - setPhase('preview') + setLoading(false) } } - const toggleExclude = (idx: number) => { - setExcluded(prev => { - const next = new Set(prev) - if (next.has(idx)) next.delete(idx); else next.add(idx) - return next - }) - } - - const activeCount = previewItems.filter((_, i) => !excluded.has(i)).length - if (!isOpen) return null return ReactDOM.createPortal(
{ mouseDownTarget.current = e.target }} - onClick={e => { + onMouseDown={(e) => { mouseDownTarget.current = e.target }} + onClick={(e) => { if (e.target === e.currentTarget && mouseDownTarget.current === e.currentTarget) handleClose() mouseDownTarget.current = null }} >
e.stopPropagation()} + onClick={(e) => e.stopPropagation()} className="bg-surface-card" - style={{ borderRadius: 16, width: '100%', maxWidth: 540, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)', fontFamily: "var(--font-system)", maxHeight: '90vh', display: 'flex', flexDirection: 'column' }} + style={{ borderRadius: 16, width: '100%', maxWidth: 540, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)', fontFamily: 'var(--font-system)', maxHeight: '90vh', display: 'flex', flexDirection: 'column' }} > {/* Header */}
- {phase === 'preview' && ( - - )}
{t('reservations.import.title')}
@@ -259,155 +132,45 @@ export default function BookingImportModal({ isOpen, onClose, tripId, pushUndo,
- {/* Upload phase */} - {phase === 'upload' && ( - <> -
- {t('reservations.import.acceptedFormats')} -
+
+ {t('reservations.import.acceptedFormats')} +
- + -
fileInputRef.current?.click()} - onDragOver={handleDragOver} - onDragEnter={handleDragOver} - onDragLeave={handleDragLeave} - onDrop={handleDrop} - className={isDragOver ? 'bg-surface-tertiary' : 'bg-transparent'} - style={{ - width: '100%', minHeight: 100, borderRadius: 12, - border: `2px dashed ${isDragOver ? 'var(--accent)' : 'var(--border-primary)'}`, - display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', - gap: 6, fontSize: 13, fontWeight: 500, cursor: 'pointer', - marginBottom: 12, padding: 16, boxSizing: 'border-box', - transition: 'border-color 0.15s, background 0.15s', - }} - > - - {isDragOver ? ( - {t('reservations.import.dropActive')} - ) : files.length > 0 ? ( - {files.map(f => f.name).join(', ')} - ) : ( - {t('reservations.import.dropHere')} - )} -
- - )} +
fileInputRef.current?.click()} + onDragOver={handleDragOver} + onDragEnter={handleDragOver} + onDragLeave={handleDragLeave} + onDrop={handleDrop} + className={isDragOver ? 'bg-surface-tertiary' : 'bg-transparent'} + style={{ + width: '100%', minHeight: 100, borderRadius: 12, + border: `2px dashed ${isDragOver ? 'var(--accent)' : 'var(--border-primary)'}`, + display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', + gap: 6, fontSize: 13, fontWeight: 500, cursor: 'pointer', + marginBottom: 12, padding: 16, boxSizing: 'border-box', + transition: 'border-color 0.15s, background 0.15s', + }} + > + + {isDragOver ? ( + {t('reservations.import.dropActive')} + ) : files.length > 0 ? ( + {files.map((f) => f.name).join(', ')} + ) : ( + {t('reservations.import.dropHere')} + )} +
- {/* Preview phase */} - {(phase === 'preview' || phase === 'confirming') && ( - <> -
- {t('reservations.import.previewHeading', { count: previewItems.length })} -
- - {previewItems.length === 0 && ( -
- {t('reservations.import.previewEmpty')} -
- )} - - {previewItems.map((item, idx) => { - const Icon = TYPE_ICONS[item.type] ?? Calendar - const isExcluded = excluded.has(idx) - const fromEp = item.endpoints?.find(e => e.role === 'from') - const toEp = item.endpoints?.find(e => e.role === 'to') - - return ( -
-
- -
-
-
- - {item.title} - - {item.needs_review && ( - - {t('reservations.import.needsReview')} - - )} -
- {fromEp && toEp && ( -
- {fromEp.code ?? fromEp.name} → {toEp.code ?? toEp.name} -
- )} - {item.reservation_time && ( -
- {formatDateTime(item.reservation_time)} - {item.reservation_end_time && ` – ${formatDateTime(item.reservation_end_time)}`} -
- )} - {item._accommodation?.check_in && ( -
- {formatDateTime(item._accommodation.check_in)} – {formatDateTime(item._accommodation.check_out)} -
- )} - {item.confirmation_number && ( -
- {item.confirmation_number} -
- )} -
- -
- ) - })} - - {/* Per-file AI fallback: offer a retry for files kitinerary couldn't read. */} - {phase === 'preview' && fileReports.filter(r => r.aiAvailable && !r.aiUsed).map(r => ( -
- - {r.fileName} - - -
- ))} - - )} - - {/* Warnings */} - {warnings.length > 0 && ( -
- {warnings.join('\n')} -
- )} - - {/* Error */} {error && (
{error} @@ -423,28 +186,14 @@ export default function BookingImportModal({ isOpen, onClose, tripId, pushUndo, > {t('common.cancel')} - - {phase === 'upload' && ( - - )} - - {(phase === 'preview' || phase === 'confirming') && ( - - )} +
, diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 18562863..decf73ad 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -18,6 +18,7 @@ import TripMembersModal from '../components/Trips/TripMembersModal' import { ReservationModal } from '../components/Planner/ReservationModal' import { TransportModal } from '../components/Planner/TransportModal' import BookingImportModal from '../components/Planner/BookingImportModal' +import { useBackgroundTasksStore } from '../store/backgroundTasksStore' import AirTrailImportModal from '../components/Planner/AirTrailImportModal' // MemoriesPanel moved to Journey addon import ReservationsPanel from '../components/Planner/ReservationsPanel' @@ -211,6 +212,23 @@ export default function TripPlannerPage(): React.ReactElement | null { mapTileUrl, defaultCenter, defaultZoom, fontStyle, splashDone, } = useTripPlanner() + // Bridge: when a finished background import is sent here for review (the user hit + // "review" in the background widget, on this or any page), open the per-item flow. + const bgTasks = useBackgroundTasksStore((s) => s.tasks) + const dismissBgTask = useBackgroundTasksStore((s) => s.dismiss) + useEffect(() => { + const task = bgTasks.find( + (tk) => tk.tripId === String(tripId) && tk.status === 'done' && tk.reviewRequested && !tk.consumed, + ) + if (task && task.items && task.items.length > 0) { + // Hand the items to the review flow and clear the widget entry — once the user + // hit "review", the background card has done its job. + const items = task.items + dismissBgTask(task.id) + startImportReview(items) + } + }, [bgTasks, tripId, startImportReview, dismissBgTask]) + const poi = usePoiExplore() const [glMap, setGlMap] = useState(null) const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false @@ -714,7 +732,7 @@ export default function TripPlannerPage(): React.ReactElement | null { onSaved={() => { setBookingExpense(null); loadBudgetItems(tripId) }} /> )} - setShowBookingImport(false)} tripId={tripId} pushUndo={pushUndo} onImported={loadAccommodations} onReview={startImportReview} /> + setShowBookingImport(false)} tripId={tripId} /> setShowAirTrailImport(false)} tripId={tripId} pushUndo={pushUndo} /> void + setProgress: (id: string, tripId: string, done: number, total: number) => void + setDone: (id: string, tripId: string, items: BookingImportPreviewItem[], warnings: string[]) => void + setError: (id: string, tripId: string, error: string) => void + requestReview: (id: string) => void + markConsumed: (id: string) => void + dismiss: (id: string) => void +} + +export const useBackgroundTasksStore = create((set) => { + /** Update an existing task by id, or insert a fresh one (events can arrive before addTask). */ + const upsert = (id: string, tripId: string, patch: Partial) => + set((state) => { + const idx = state.tasks.findIndex((t) => t.id === id) + if (idx === -1) { + const base: BackgroundImportTask = { id, tripId, label: 'Import', status: 'running', done: 0, total: 1 } + return { tasks: [...state.tasks, { ...base, ...patch }] } + } + const tasks = state.tasks.slice() + tasks[idx] = { ...tasks[idx], ...patch } + return { tasks } + }) + + return { + tasks: [], + addTask: ({ id, tripId, label, total }) => upsert(id, tripId, { label, total, status: 'running', done: 0 }), + setProgress: (id, tripId, done, total) => upsert(id, tripId, { done, total, status: 'running' }), + setDone: (id, tripId, items, warnings) => upsert(id, tripId, { status: 'done', items, warnings, done: items?.length ?? 0 }), + setError: (id, tripId, error) => upsert(id, tripId, { status: 'error', error }), + requestReview: (id) => set((s) => ({ tasks: s.tasks.map((t) => (t.id === id ? { ...t, reviewRequested: true } : t)) })), + markConsumed: (id) => set((s) => ({ tasks: s.tasks.map((t) => (t.id === id ? { ...t, consumed: true, reviewRequested: false } : t)) })), + dismiss: (id) => set((s) => ({ tasks: s.tasks.filter((t) => t.id !== id) })), + } +}) diff --git a/server/src/nest/booking-import/booking-import.controller.ts b/server/src/nest/booking-import/booking-import.controller.ts index 764db67b..61724e44 100644 --- a/server/src/nest/booking-import/booking-import.controller.ts +++ b/server/src/nest/booking-import/booking-import.controller.ts @@ -1,6 +1,7 @@ import { Controller, Post, + Get, Body, Param, Headers, @@ -15,6 +16,7 @@ import type { User } from '../../types'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { CurrentUser } from '../auth/current-user.decorator'; import { BookingImportService } from './booking-import.service'; +import { ImportJobsService } from './import-jobs.service'; import { bookingImportModeSchema } from '@trek/shared'; import type { BookingImportPreviewItem, BookingImportPreviewResponse, BookingImportConfirmResponse, BookingImportMode } from '@trek/shared'; @@ -30,7 +32,10 @@ const UPLOAD = { @Controller('api/trips/:tripId/reservations/import') @UseGuards(JwtAuthGuard) export class BookingImportController { - constructor(private readonly bookingImport: BookingImportService) {} + constructor( + private readonly bookingImport: BookingImportService, + private readonly importJobs: ImportJobsService, + ) {} private requireTrip(tripId: string, user: User) { const trip = this.bookingImport.verifyTripAccess(tripId, user.id); @@ -44,6 +49,31 @@ export class BookingImportController { } } + /** Shared validation for both the sync and async import endpoints; returns the parsed mode. */ + private validateImport(tripId: string, user: User, files: Express.Multer.File[] | undefined, rawMode?: string): BookingImportMode { + const trip = this.requireTrip(tripId, user); + this.requireEdit(trip, user); + + const modeResult = bookingImportModeSchema.safeParse(rawMode ?? 'no-ai'); + if (!modeResult.success) throw new HttpException({ error: 'Invalid mode' }, 400); + const mode = modeResult.data; + + if (mode === 'force-ai' && !this.bookingImport.aiAvailable(user.id)) { + throw new HttpException({ error: 'AI parsing is not configured' }, 409); + } + if (mode === 'no-ai' && !this.bookingImport.isAvailable()) { + throw new HttpException({ error: 'KItinerary extractor is not available on this server' }, 503); + } + if (!files || files.length === 0) throw new HttpException({ error: 'No files uploaded' }, 400); + for (const f of files) { + const ext = f.originalname.toLowerCase().slice(f.originalname.lastIndexOf('.')); + if (!ACCEPTED_EXTS.has(ext)) { + throw new HttpException({ error: `Unsupported file type: ${f.originalname}. Accepted: EML, PDF, PKPass, HTML, TXT` }, 400); + } + } + return mode; + } + /** * POST /api/trips/:tripId/reservations/import/booking * Accepts up to 5 booking confirmation files (EML, PDF, PKPass, HTML, TXT). @@ -56,39 +86,41 @@ export class BookingImportController { @Param('tripId') tripId: string, @UploadedFiles() files: Express.Multer.File[] | undefined, @Body('mode') rawMode?: string, - ) { - const trip = this.requireTrip(tripId, user); - this.requireEdit(trip, user); + ): Promise { + const mode = this.validateImport(tripId, user, files, rawMode); + return this.bookingImport.preview(files!, mode, user.id); + } - const modeResult = bookingImportModeSchema.safeParse(rawMode ?? 'no-ai'); - if (!modeResult.success) { - throw new HttpException({ error: 'Invalid mode' }, 400); - } - const mode: BookingImportMode = modeResult.data; + /** + * POST /api/trips/:tripId/reservations/import/booking/async + * Same input as /booking, but returns a job id immediately and parses in the + * background. Progress + completion are pushed over the user's WebSocket + * (import:progress / import:done / import:error). Lets the upload modal close at + * once and a background widget track the work while the user keeps navigating. + */ + @Post('booking/async') + @UseInterceptors(FilesInterceptor('files', MAX_FILES, UPLOAD)) + async previewAsync( + @CurrentUser() user: User, + @Param('tripId') tripId: string, + @UploadedFiles() files: Express.Multer.File[] | undefined, + @Body('mode') rawMode?: string, + ): Promise<{ jobId: string }> { + const mode = this.validateImport(tripId, user, files, rawMode); + const jobId = this.importJobs.start(tripId, files!, mode, user.id); + return { jobId }; + } - // Forcing AI requires it to be configured; otherwise surface a clear 4xx. - if (mode === 'force-ai' && !this.bookingImport.aiAvailable(user.id)) { - throw new HttpException({ error: 'AI parsing is not configured' }, 409); - } - // For the kitinerary-only path, keep the existing 503 contract. - if (mode === 'no-ai' && !this.bookingImport.isAvailable()) { - throw new HttpException({ error: 'KItinerary extractor is not available on this server' }, 503); - } - - if (!files || files.length === 0) { - throw new HttpException({ error: 'No files uploaded' }, 400); - } - - // Validate extensions - for (const f of files) { - const ext = f.originalname.toLowerCase().slice(f.originalname.lastIndexOf('.')); - if (!ACCEPTED_EXTS.has(ext)) { - throw new HttpException({ error: `Unsupported file type: ${f.originalname}. Accepted: EML, PDF, PKPass, HTML, TXT` }, 400); - } - } - - const result: BookingImportPreviewResponse = await this.bookingImport.preview(files, mode, user.id); - return result; + /** + * GET /api/trips/:tripId/reservations/import/jobs/:jobId + * Poll a background import job — recovery path for a client that missed the + * WebSocket push (navigation, reconnect). 404 once the job has expired. + */ + @Get('jobs/:jobId') + async jobStatus(@CurrentUser() user: User, @Param('jobId') jobId: string) { + const job = this.importJobs.get(jobId, user.id); + if (!job) throw new HttpException({ error: 'Job not found' }, 404); + return { status: job.status, done: job.done, total: job.total, result: job.result, error: job.error }; } /** diff --git a/server/src/nest/booking-import/booking-import.module.ts b/server/src/nest/booking-import/booking-import.module.ts index a89194c9..e4f74401 100644 --- a/server/src/nest/booking-import/booking-import.module.ts +++ b/server/src/nest/booking-import/booking-import.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { BookingImportController } from './booking-import.controller'; import { BookingImportService } from './booking-import.service'; +import { ImportJobsService } from './import-jobs.service'; import { KitineraryExtractorService } from './kitinerary-extractor.service'; import { FeaturesController } from './features.controller'; import { LlmParseModule } from '../llm-parse/llm-parse.module'; @@ -8,6 +9,6 @@ import { LlmParseModule } from '../llm-parse/llm-parse.module'; @Module({ imports: [LlmParseModule], controllers: [BookingImportController, FeaturesController], - providers: [BookingImportService, KitineraryExtractorService], + providers: [BookingImportService, KitineraryExtractorService, ImportJobsService], }) export class BookingImportModule {} diff --git a/server/src/nest/booking-import/booking-import.service.ts b/server/src/nest/booking-import/booking-import.service.ts index c02dca5d..f651e5b6 100644 --- a/server/src/nest/booking-import/booking-import.service.ts +++ b/server/src/nest/booking-import/booking-import.service.ts @@ -65,6 +65,7 @@ export class BookingImportService { files: Express.Multer.File[], mode: BookingImportMode, userId: number, + onProgress?: (done: number, total: number, fileName: string) => void, ): Promise { const kitineraryAvailable = this.extractor.isAvailable(); const aiAvailable = this.llmParse.isAvailable(userId); @@ -76,6 +77,7 @@ export class BookingImportService { const allWarnings: string[] = []; const fileReports: BookingImportFileReport[] = []; + let processed = 0; for (const file of files) { let kiItems: KiReservation[] = []; let aiUsed = false; @@ -102,14 +104,16 @@ export class BookingImportService { if (kiItems.length === 0) { allWarnings.push(`${file.originalname}: no reservations found`); - continue; + } else { + const { items, warnings } = mapReservations(kiItems, file.originalname); + // LLM extraction is less certain than kitinerary — always flag for review. + if (aiUsed) for (const it of items) it.needs_review = true; + allItems.push(...items); + allWarnings.push(...warnings); } - const { items, warnings } = mapReservations(kiItems, file.originalname); - // LLM extraction is less certain than kitinerary — always flag for review. - if (aiUsed) for (const it of items) it.needs_review = true; - allItems.push(...items); - allWarnings.push(...warnings); + // Report per-file progress so a background import can drive a live widget. + onProgress?.(++processed, files.length, file.originalname); } return { items: allItems, warnings: allWarnings, files: fileReports }; diff --git a/server/src/nest/booking-import/import-jobs.service.ts b/server/src/nest/booking-import/import-jobs.service.ts new file mode 100644 index 00000000..8ba1ea37 --- /dev/null +++ b/server/src/nest/booking-import/import-jobs.service.ts @@ -0,0 +1,85 @@ +import { Injectable } from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; +import { broadcastToUser } from '../../websocket'; +import { BookingImportService } from './booking-import.service'; +import type { BookingImportMode, BookingImportPreviewResponse } from '@trek/shared'; + +type JobStatus = 'running' | 'done' | 'error'; + +interface ImportJob { + id: string; + tripId: string; + userId: number; + status: JobStatus; + done: number; + total: number; + result?: BookingImportPreviewResponse; + error?: string; + createdAt: number; +} + +// Keep a finished job around briefly so a client that missed the WebSocket push +// (navigation, reconnect) can still GET its result. +const JOB_TTL_MS = 10 * 60_000; + +/** + * Runs a booking-import parse OFF the request: the controller returns a job id + * immediately, the parse continues here, and progress/completion are pushed to the + * user's sockets via `broadcastToUser` (which reaches them on ANY page, not just the + * trip room). This is what lets the upload modal close at once and a background widget + * track the work while the user keeps navigating. The actual parsing is the same + * `BookingImportService.preview` the synchronous endpoint uses. + */ +@Injectable() +export class ImportJobsService { + private readonly jobs = new Map(); + /** Tail of each user's job chain — parses run one at a time per user, not all at once. */ + private readonly chains = new Map>(); + + constructor(private readonly bookingImport: BookingImportService) {} + + /** Create a job and queue it behind the user's other parses; returns the job id at once. */ + start(tripId: string, files: Express.Multer.File[], mode: BookingImportMode, userId: number): string { + const id = randomUUID(); + const job: ImportJob = { id, tripId, userId, status: 'running', done: 0, total: files.length, createdAt: Date.now() }; + this.jobs.set(id, job); + // Chain onto the user's previous parse so they run sequentially (one CPU-heavy + // inference at a time), while the request returns immediately. + const prev = this.chains.get(userId) ?? Promise.resolve(); + const next = prev.then(() => this.run(job, files, mode)).catch(() => {}); + this.chains.set(userId, next); + void next.finally(() => { + if (this.chains.get(userId) === next) this.chains.delete(userId); + }); + return id; + } + + get(id: string, userId: number): ImportJob | undefined { + const job = this.jobs.get(id); + return job && job.userId === userId ? job : undefined; + } + + private async run(job: ImportJob, files: Express.Multer.File[], mode: BookingImportMode): Promise { + this.push(job, 'import:progress', { status: 'running', done: 0, total: job.total }); + try { + const result = await this.bookingImport.preview(files, mode, job.userId, (done, total, fileName) => { + job.done = done; + this.push(job, 'import:progress', { status: 'running', done, total, fileName }); + }); + job.status = 'done'; + job.result = result; + this.push(job, 'import:done', { result }); + } catch (err) { + job.status = 'error'; + job.error = err instanceof Error ? err.message : String(err); + this.push(job, 'import:error', { message: job.error }); + } finally { + const id = job.id; + setTimeout(() => this.jobs.delete(id), JOB_TTL_MS).unref?.(); + } + } + + private push(job: ImportJob, type: string, payload: Record): void { + broadcastToUser(job.userId, { type, jobId: job.id, tripId: job.tripId, ...payload }); + } +}