mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
6ef3c7ae6b
* feat(reservations): native booking-confirmation import via KDE KItinerary
Adds a two-step preview → confirm flow for importing booking emails,
PDFs, PKPass and HTML confirmations. The server invokes the KDE
kitinerary-extractor binary, maps JSON-LD schema.org output to TREK
reservation shapes, and persists via the existing createReservation
pipeline (accommodations, budget, places, WebSocket broadcasts).
- NestJS BookingImportModule: preview + confirm endpoints under
/api/trips/:tripId/reservations/import/booking{,/confirm}
- KitineraryExtractorService: spawns the binary, filters stderr noise,
handles QDateTime (@value) timezone-aware datetimes
- kitinerary-mapper: FlightReservation, TrainReservation, BusReservation,
BoatReservation, LodgingReservation, FoodEstablishmentReservation,
RentalCarReservation, EventReservation → typed preview items
- BookingImportService: auto-creates place rows; geocodes venues without
coordinates via Nominatim (name+address → address → name fallback);
resolves day IDs for accommodation linking
- BookingImportModal: drag-and-drop multi-file upload, preview cards
with type icons, per-item exclude toggle, confirm step
- Shared Zod contracts: BookingImportPreviewItem, PreviewResponse,
ConfirmRequest, ConfirmResponse — consumed by controller, service,
API client and modal
- Dockerfile: node:24-trixie-slim runtime; amd64 downloads KDE static
binary + locales; arm64 installs libkitinerary-bin + symlinks to
fixed path; ENV KITINERARY_EXTRACTOR_PATH set for both arches
- /api/health/features exposes { bookingImport: boolean } so the UI
hides the Import button when the binary is absent
- i18n keys (English), wiki docs, API.md, README one-liner
* i18n: add booking import translations for all 19 non-English locales
Adds 17 reservations.import.* keys and undo.importBooking to ar, br, cs,
de, es, fr, gr, hu, id, it, ja, ko, nl, pl, ru, tr, uk, zh, zh-TW.
* chore: enforce i18n parity
* docs(wiki): add KItinerary local setup instructions to dev environment guide
103 lines
3.4 KiB
TypeScript
103 lines
3.4 KiB
TypeScript
import {
|
|
Controller,
|
|
Post,
|
|
Body,
|
|
Param,
|
|
Headers,
|
|
HttpException,
|
|
UseGuards,
|
|
UseInterceptors,
|
|
UploadedFiles,
|
|
} from '@nestjs/common';
|
|
import { FilesInterceptor } from '@nestjs/platform-express';
|
|
import { memoryStorage } from 'multer';
|
|
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 type { BookingImportPreviewItem, BookingImportPreviewResponse, BookingImportConfirmResponse } from '@trek/shared';
|
|
|
|
const ACCEPTED_EXTS = new Set(['.eml', '.pdf', '.pkpass', '.html', '.htm', '.txt']);
|
|
const MAX_FILE_BYTES = 10 * 1024 * 1024;
|
|
const MAX_FILES = 5;
|
|
|
|
const UPLOAD = {
|
|
storage: memoryStorage(),
|
|
limits: { fileSize: MAX_FILE_BYTES, files: MAX_FILES },
|
|
};
|
|
|
|
@Controller('api/trips/:tripId/reservations/import')
|
|
@UseGuards(JwtAuthGuard)
|
|
export class BookingImportController {
|
|
constructor(private readonly bookingImport: BookingImportService) {}
|
|
|
|
private requireTrip(tripId: string, user: User) {
|
|
const trip = this.bookingImport.verifyTripAccess(tripId, user.id);
|
|
if (!trip) throw new HttpException({ error: 'Trip not found' }, 404);
|
|
return trip;
|
|
}
|
|
|
|
private requireEdit(trip: ReturnType<BookingImportService['verifyTripAccess']>, user: User): void {
|
|
if (!this.bookingImport.canEdit(trip!, user)) {
|
|
throw new HttpException({ error: 'No permission' }, 403);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* POST /api/trips/:tripId/reservations/import/booking
|
|
* Accepts up to 5 booking confirmation files (EML, PDF, PKPass, HTML, TXT).
|
|
* Returns a preview list without persisting anything.
|
|
*/
|
|
@Post('booking')
|
|
@UseInterceptors(FilesInterceptor('files', MAX_FILES, UPLOAD))
|
|
async preview(
|
|
@CurrentUser() user: User,
|
|
@Param('tripId') tripId: string,
|
|
@UploadedFiles() files: Express.Multer.File[] | undefined,
|
|
) {
|
|
const trip = this.requireTrip(tripId, user);
|
|
this.requireEdit(trip, user);
|
|
|
|
if (!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);
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* POST /api/trips/:tripId/reservations/import/booking/confirm
|
|
* Persists the user-confirmed subset of parsed items.
|
|
*/
|
|
@Post('booking/confirm')
|
|
async confirm(
|
|
@CurrentUser() user: User,
|
|
@Param('tripId') tripId: string,
|
|
@Body() body: { items?: BookingImportPreviewItem[] },
|
|
@Headers('x-socket-id') socketId?: string,
|
|
): Promise<BookingImportConfirmResponse> {
|
|
const trip = this.requireTrip(tripId, user);
|
|
this.requireEdit(trip, user);
|
|
|
|
const items = body?.items;
|
|
if (!Array.isArray(items) || items.length === 0) {
|
|
throw new HttpException({ error: 'items must be a non-empty array' }, 400);
|
|
}
|
|
|
|
return this.bookingImport.confirm(tripId, items, socketId);
|
|
}
|
|
}
|