mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-30 18:46:00 +00:00
feat(import): parse bookings in the background with a progress widget
Parsing a booking can take a while on a CPU host, so don't hold the upload modal open for it. The async import endpoint returns a job id right away; the parse runs server-side (one at a time per user) and pushes progress over the user's WebSocket, and a small widget in the bottom corner tracks it while the user keeps navigating and editing. A finished job opens the per-item review from the widget.
This commit is contained in:
@@ -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() {
|
||||
<TranslationProvider>
|
||||
{!isAuthPage && <SystemNoticeHost />}
|
||||
<ToastContainer />
|
||||
{!isAuthPage && <BackgroundTasksWidget />}
|
||||
<OfflineBanner />
|
||||
<Routes>
|
||||
<Route path="/" element={<RootRedirect />} />
|
||||
|
||||
@@ -670,6 +670,17 @@ export const reservationsApi = {
|
||||
},
|
||||
importBookingConfirm: (tripId: number | string, items: BookingImportPreviewItem[]): Promise<BookingImportConfirmResponse> =>
|
||||
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 = {
|
||||
|
||||
@@ -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<string, unknown>) => {
|
||||
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(
|
||||
<div
|
||||
style={{ position: 'fixed', right: 16, bottom: 16, zIndex: 50000, display: 'flex', flexDirection: 'column', gap: 8, width: 380, maxWidth: 'calc(100vw - 32px)', fontFamily: 'var(--font-system)' }}
|
||||
>
|
||||
{tasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="bg-surface-card"
|
||||
style={{ borderRadius: 12, border: '1px solid var(--border-primary)', boxShadow: '0 8px 24px rgba(0,0,0,0.18)', padding: '11px 13px', backdropFilter: 'blur(8px)', display: 'flex', gap: 10, alignItems: 'flex-start' }}
|
||||
>
|
||||
<div style={{ flexShrink: 0, marginTop: 1 }}>
|
||||
{task.status === 'running' && <Loader2 size={16} className="animate-spin" color="var(--accent)" />}
|
||||
{task.status === 'done' && <CheckCircle2 size={16} color="#10b981" />}
|
||||
{task.status === 'error' && <AlertCircle size={16} color="#ef4444" />}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 12.5, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{task.label}
|
||||
</div>
|
||||
|
||||
{task.status === 'running' && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 1 }}>
|
||||
{t('reservations.import.parsing')}
|
||||
{task.total > 1 ? ` · ${task.done}/${task.total}` : ''}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{task.status === 'done' && (
|
||||
(task.items?.length ?? 0) > 0 ? (
|
||||
<button
|
||||
onClick={() => review(task)}
|
||||
className="bg-accent text-accent-text"
|
||||
style={{ marginTop: 4, border: 'none', borderRadius: 8, padding: '4px 12px', fontSize: 11.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
>
|
||||
{t('common.import')}
|
||||
</button>
|
||||
) : (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 1 }}>{t('reservations.import.previewEmpty')}</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{task.status === 'error' && (
|
||||
<div style={{ fontSize: 11, color: '#b91c1c', marginTop: 1, whiteSpace: 'pre-wrap' }}>{task.error}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{task.status !== 'running' && (
|
||||
<button
|
||||
onClick={() => dismiss(task.id)}
|
||||
className="bg-transparent text-content-faint"
|
||||
style={{ flexShrink: 0, border: 'none', cursor: 'pointer', padding: 2, borderRadius: 6, display: 'flex', alignItems: 'center' }}
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -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) => 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<string, React.FC<{ size: number; color?: string }>> = {
|
||||
flight: Plane,
|
||||
train: Train,
|
||||
hotel: Hotel,
|
||||
restaurant: UtensilsCrossed,
|
||||
car: Car,
|
||||
cruise: Anchor,
|
||||
event: Calendar,
|
||||
}
|
||||
|
||||
function typeColor(type: string): string {
|
||||
const map: Record<string, string> = {
|
||||
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<HTMLInputElement>(null)
|
||||
const mouseDownTarget = useRef<EventTarget | null>(null)
|
||||
|
||||
type Phase = 'upload' | 'preview' | 'confirming'
|
||||
const [phase, setPhase] = useState<Phase>('upload')
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const [isDragOver, setIsDragOver] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [previewItems, setPreviewItems] = useState<BookingImportPreviewItem[]>([])
|
||||
const [warnings, setWarnings] = useState<string[]>([])
|
||||
const [excluded, setExcluded] = useState<Set<number>>(() => new Set())
|
||||
// AI fallback: addon-level availability + per-file report + in-flight retries.
|
||||
const [aiParsing, setAiParsing] = useState(false)
|
||||
const [fileReports, setFileReports] = useState<BookingImportFileReport[]>([])
|
||||
const [retrying, setRetrying] = useState<Set<string>>(() => 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(
|
||||
<div
|
||||
className="bg-[rgba(0,0,0,0.4)]"
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 99999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
onMouseDown={e => { 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
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={e => 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 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 }}>
|
||||
{phase === 'preview' && (
|
||||
<button onClick={() => setPhase('upload')} className="bg-transparent text-content-faint" style={{ border: 'none', cursor: 'pointer', padding: 4, borderRadius: 6, display: 'flex', alignItems: 'center' }}>
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
)}
|
||||
<div style={{ flex: 1, fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||
{t('reservations.import.title')}
|
||||
</div>
|
||||
@@ -259,155 +132,45 @@ export default function BookingImportModal({ isOpen, onClose, tripId, pushUndo,
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
||||
{/* Upload phase */}
|
||||
{phase === 'upload' && (
|
||||
<>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginBottom: 14, lineHeight: 1.45 }}>
|
||||
{t('reservations.import.acceptedFormats')}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginBottom: 14, lineHeight: 1.45 }}>
|
||||
{t('reservations.import.acceptedFormats')}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={ACCEPTED_EXTS.join(',')}
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={ACCEPTED_EXTS.join(',')}
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
|
||||
<div
|
||||
onClick={() => 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',
|
||||
}}
|
||||
>
|
||||
<Upload size={18} strokeWidth={1.8} color={isDragOver ? 'var(--accent)' : 'var(--text-faint)'} style={{ pointerEvents: 'none' }} />
|
||||
{isDragOver ? (
|
||||
<span className="text-accent" style={{ pointerEvents: 'none' }}>{t('reservations.import.dropActive')}</span>
|
||||
) : files.length > 0 ? (
|
||||
<span style={{ color: 'var(--text-primary)', textAlign: 'center', wordBreak: 'break-all', pointerEvents: 'none' }}>{files.map(f => f.name).join(', ')}</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--text-faint)', textAlign: 'center', pointerEvents: 'none' }}>{t('reservations.import.dropHere')}</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
onClick={() => 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',
|
||||
}}
|
||||
>
|
||||
<Upload size={18} strokeWidth={1.8} color={isDragOver ? 'var(--accent)' : 'var(--text-faint)'} style={{ pointerEvents: 'none' }} />
|
||||
{isDragOver ? (
|
||||
<span className="text-accent" style={{ pointerEvents: 'none' }}>{t('reservations.import.dropActive')}</span>
|
||||
) : files.length > 0 ? (
|
||||
<span style={{ color: 'var(--text-primary)', textAlign: 'center', wordBreak: 'break-all', pointerEvents: 'none' }}>{files.map((f) => f.name).join(', ')}</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--text-faint)', textAlign: 'center', pointerEvents: 'none' }}>{t('reservations.import.dropHere')}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preview phase */}
|
||||
{(phase === 'preview' || phase === 'confirming') && (
|
||||
<>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 10 }}>
|
||||
{t('reservations.import.previewHeading', { count: previewItems.length })}
|
||||
</div>
|
||||
|
||||
{previewItems.length === 0 && (
|
||||
<div className="text-content-faint" style={{ fontSize: 13, textAlign: 'center', padding: '24px 0' }}>
|
||||
{t('reservations.import.previewEmpty')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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 (
|
||||
<div
|
||||
key={`${item.source.fileName}-${idx}`}
|
||||
className={isExcluded ? 'bg-surface-tertiary' : 'bg-surface-secondary'}
|
||||
style={{
|
||||
borderRadius: 10, padding: '10px 12px', marginBottom: 8,
|
||||
border: `1px solid ${isExcluded ? 'var(--border-faint)' : 'var(--border-primary)'}`,
|
||||
opacity: isExcluded ? 0.5 : 1, transition: 'opacity 0.15s',
|
||||
display: 'flex', gap: 10, alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<div style={{ flexShrink: 0, marginTop: 2 }}>
|
||||
<Icon size={15} color={typeColor(item.type)} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{item.title}
|
||||
</span>
|
||||
{item.needs_review && (
|
||||
<span className="bg-[rgba(245,158,11,0.15)] text-[#92400e]" style={{ flexShrink: 0, fontSize: 10, fontWeight: 600, padding: '1px 6px', borderRadius: 6 }}>
|
||||
{t('reservations.import.needsReview')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{fromEp && toEp && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 2 }}>
|
||||
{fromEp.code ?? fromEp.name} → {toEp.code ?? toEp.name}
|
||||
</div>
|
||||
)}
|
||||
{item.reservation_time && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)' }}>
|
||||
{formatDateTime(item.reservation_time)}
|
||||
{item.reservation_end_time && ` – ${formatDateTime(item.reservation_end_time)}`}
|
||||
</div>
|
||||
)}
|
||||
{item._accommodation?.check_in && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)' }}>
|
||||
{formatDateTime(item._accommodation.check_in)} – {formatDateTime(item._accommodation.check_out)}
|
||||
</div>
|
||||
)}
|
||||
{item.confirmation_number && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', fontFamily: 'monospace' }}>
|
||||
{item.confirmation_number}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toggleExclude(idx)}
|
||||
className="bg-transparent text-content-faint"
|
||||
style={{ border: 'none', cursor: 'pointer', padding: 4, borderRadius: 6, flexShrink: 0, fontSize: 11, fontFamily: 'inherit', fontWeight: 500 }}
|
||||
title={t('reservations.import.removeItem')}
|
||||
>
|
||||
{isExcluded ? '+' : <X size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Per-file AI fallback: offer a retry for files kitinerary couldn't read. */}
|
||||
{phase === 'preview' && fileReports.filter(r => r.aiAvailable && !r.aiUsed).map(r => (
|
||||
<div key={`ai-${r.fileName}`} className="bg-surface-secondary" style={{ borderRadius: 10, padding: '8px 12px', marginBottom: 8, border: '1px dashed var(--border-primary)', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ flex: 1, minWidth: 0, fontSize: 12, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{r.fileName}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleRetryAi(r.fileName)}
|
||||
disabled={retrying.has(r.fileName)}
|
||||
className="bg-accent text-accent-text"
|
||||
style={{ flexShrink: 0, border: 'none', borderRadius: 8, padding: '5px 10px', fontSize: 12, fontWeight: 500, cursor: retrying.has(r.fileName) ? 'default' : 'pointer', fontFamily: 'inherit', opacity: retrying.has(r.fileName) ? 0.6 : 1 }}
|
||||
>
|
||||
{retrying.has(r.fileName) ? t('reservations.import.aiParsing') : t('reservations.import.tryAi')}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Warnings */}
|
||||
{warnings.length > 0 && (
|
||||
<div className="bg-[rgba(245,158,11,0.08)] text-[#92400e]" style={{ border: '1px solid rgba(245,158,11,0.3)', borderRadius: 10, padding: '8px 10px', fontSize: 12, marginTop: 8, whiteSpace: 'pre-wrap' }}>
|
||||
{warnings.join('\n')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="bg-[rgba(239,68,68,0.08)] text-[#b91c1c]" style={{ border: '1px solid rgba(239,68,68,0.35)', borderRadius: 10, padding: '8px 10px', fontSize: 12, whiteSpace: 'pre-wrap', marginTop: 8 }}>
|
||||
{error}
|
||||
@@ -423,28 +186,14 @@ export default function BookingImportModal({ isOpen, onClose, tripId, pushUndo,
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
|
||||
{phase === 'upload' && (
|
||||
<button
|
||||
onClick={handleParse}
|
||||
disabled={files.length === 0 || loading}
|
||||
className={files.length > 0 && !loading ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-faint'}
|
||||
style={{ padding: '8px 16px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 500, cursor: files.length > 0 && !loading ? 'pointer' : 'default', fontFamily: 'inherit' }}
|
||||
>
|
||||
{loading ? t('reservations.import.parsing') : t('common.import')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(phase === 'preview' || phase === 'confirming') && (
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={activeCount === 0 || phase === 'confirming'}
|
||||
className={activeCount > 0 && phase !== 'confirming' ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-faint'}
|
||||
style={{ padding: '8px 16px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 500, cursor: activeCount > 0 && phase !== 'confirming' ? 'pointer' : 'default', fontFamily: 'inherit' }}
|
||||
>
|
||||
{phase === 'confirming' ? t('common.loading') : t('reservations.import.confirm', { count: activeCount })}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleParse}
|
||||
disabled={files.length === 0 || loading}
|
||||
className={files.length > 0 && !loading ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-faint'}
|
||||
style={{ padding: '8px 16px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 500, cursor: files.length > 0 && !loading ? 'pointer' : 'default', fontFamily: 'inherit' }}
|
||||
>
|
||||
{loading ? t('reservations.import.parsing') : t('common.import')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
|
||||
@@ -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<import('mapbox-gl').Map | null>(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) }}
|
||||
/>
|
||||
)}
|
||||
<BookingImportModal isOpen={showBookingImport} onClose={() => setShowBookingImport(false)} tripId={tripId} pushUndo={pushUndo} onImported={loadAccommodations} onReview={startImportReview} />
|
||||
<BookingImportModal isOpen={showBookingImport} onClose={() => setShowBookingImport(false)} tripId={tripId} />
|
||||
<AirTrailImportModal isOpen={showAirTrailImport} onClose={() => setShowAirTrailImport(false)} tripId={tripId} pushUndo={pushUndo} />
|
||||
<ConfirmDialog
|
||||
isOpen={!!deletePlaceId}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { create } from 'zustand'
|
||||
import type { BookingImportPreviewItem } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* Tracks booking-import parses that run in the BACKGROUND (the async endpoint).
|
||||
* The upload modal closes the moment a parse starts and adds a task here; the
|
||||
* server pushes import:progress / import:done / import:error over the user's
|
||||
* WebSocket (which reaches every page), and the global BackgroundTasksWidget
|
||||
* renders the list. The trip page turns a finished task into the review flow.
|
||||
*
|
||||
* Memory-only (no persist): a parse is tied to a live server job and WebSocket,
|
||||
* so it shouldn't survive a reload.
|
||||
*/
|
||||
export interface BackgroundImportTask {
|
||||
id: string // server job id
|
||||
tripId: string
|
||||
label: string // file name(s) being parsed
|
||||
status: 'running' | 'done' | 'error'
|
||||
done: number
|
||||
total: number
|
||||
items?: BookingImportPreviewItem[]
|
||||
warnings?: string[]
|
||||
error?: string
|
||||
reviewRequested?: boolean // user clicked "review" — the trip page consumes it
|
||||
consumed?: boolean // review has been handed to the trip page
|
||||
}
|
||||
|
||||
interface BackgroundTasksState {
|
||||
tasks: BackgroundImportTask[]
|
||||
addTask: (task: { id: string; tripId: string; label: string; total: number }) => 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<BackgroundTasksState>((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<BackgroundImportTask>) =>
|
||||
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) })),
|
||||
}
|
||||
})
|
||||
@@ -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<BookingImportPreviewResponse> {
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -65,6 +65,7 @@ export class BookingImportService {
|
||||
files: Express.Multer.File[],
|
||||
mode: BookingImportMode,
|
||||
userId: number,
|
||||
onProgress?: (done: number, total: number, fileName: string) => void,
|
||||
): Promise<BookingImportPreviewResponse> {
|
||||
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 };
|
||||
|
||||
@@ -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<string, ImportJob>();
|
||||
/** Tail of each user's job chain — parses run one at a time per user, not all at once. */
|
||||
private readonly chains = new Map<number, Promise<void>>();
|
||||
|
||||
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<void> {
|
||||
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<string, unknown>): void {
|
||||
broadcastToUser(job.userId, { type, jobId: job.id, tripId: job.tripId, ...payload });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user