diff --git a/server/src/nest/calendar/calendar.module.ts b/server/src/nest/calendar/calendar.module.ts new file mode 100644 index 000000000..10ede6b81 --- /dev/null +++ b/server/src/nest/calendar/calendar.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { CalendarService } from './calendar.service'; +import { DatabaseModule } from '../database/database.module'; +import { ReservationsModule } from '../reservations/reservations.module'; + +/** Calendar export. Imported by trips (the download route) and feeds (the + * subscribable URLs); it pulls in neither, which is what lets FeedsModule stop + * importing the whole trips aggregate for an ICS string. */ +@Module({ + imports: [DatabaseModule, ReservationsModule], + providers: [CalendarService], + exports: [CalendarService], +}) +export class CalendarModule {} diff --git a/server/src/nest/calendar/calendar.service.ts b/server/src/nest/calendar/calendar.service.ts new file mode 100644 index 000000000..a7ea4be60 --- /dev/null +++ b/server/src/nest/calendar/calendar.service.ts @@ -0,0 +1,358 @@ +import { Injectable } from '@nestjs/common'; +import { DatabaseService } from '../database/database.service'; +import { ReservationsService } from '../reservations/reservations.service'; +import { addDays } from '../days/days.service'; +import { resolveTimeZone } from '../common/timezoneService'; +import { NotFoundError } from '../common/domain-errors'; + +// ── ICS folding ───────────────────────────────────────────────────────────── + +// RFC 5545 §3.1: content lines longer than 75 octets must be folded with a CRLF +// followed by a single leading space. We fold on UTF-8 *octet* boundaries and +// never split a multi-byte codepoint, so non-ASCII titles/notes (accents, CJK, +// emoji) stay intact. Applied to the whole calendar, so both the one-time +// download and the subscribable feed emit spec-compliant output. +function foldICS(ics: string): string { + const foldLine = (line: string): string => { + const bytes = Buffer.from(line, 'utf8'); + if (bytes.length <= 75) return line; + const parts: Buffer[] = []; + let start = 0; + let limit = 75; // first physical line may use 75 octets + while (start < bytes.length) { + let end = Math.min(start + limit, bytes.length); + // Back off so we never cut a multi-byte UTF-8 sequence (0x80–0xBF = continuation byte). + while (end < bytes.length && (bytes[end] & 0xc0) === 0x80) end--; + parts.push(bytes.subarray(start, end)); + start = end; + limit = 74; // continuation lines spend one octet on the leading space + } + return parts.map((b, i) => (i === 0 ? '' : ' ') + b.toString('utf8')).join('\r\n'); + }; + return ics.split('\r\n').map(foldLine).join('\r\n'); +} + +// ── ICS time-zone helpers ──────────────────────────────────────────────────── +// Timed events must carry an explicit IANA zone; a bare "YYYYMMDDTHHMMSS" is an +// RFC 5545 "floating" time that clients render in the *subscriber's* zone (#1453). + +// A stored/plugin-provided timezone (e.g. a transport endpoint's `timezone`) is a +// free string that need not be a real IANA zone. Intl.DateTimeFormat throws a +// RangeError on an unknown zone, which — via buildVTimezone → tzOffsetString — +// would crash the whole ICS export (and drop the trip from the all-trips feed). +// Validate once so an invalid zone degrades to a floating local time instead. +// Module-scoped on purpose (like the permissions/FX caches): the bridge instance +// and the DI singleton share one cache. +const _tzValidCache = new Map(); +function isValidTimeZone(zone: string): boolean { + const cached = _tzValidCache.get(zone); + if (cached !== undefined) return cached; + let ok = false; + try { + new Intl.DateTimeFormat('en-US', { timeZone: zone }); + ok = true; + } catch { + // Unknown/invalid zone → ok stays false. + } + // Bound the cache — the key is a free-form (plugin/importer-written) zone string, + // so cap distinct entries rather than growing for the process lifetime. + if (_tzValidCache.size >= 1000) _tzValidCache.clear(); + _tzValidCache.set(zone, ok); + return ok; +} + +// UTC offset ("+0200") the zone uses on the given YYYYMMDD date. Only feeds the +// fallback VTIMEZONE offset; iOS/Google resolve the named zone from their own +// IANA database, so a single representative offset is sufficient. +function tzOffsetString(zone: string, yyyymmdd: string): string { + const iso = `${yyyymmdd.slice(0, 4)}-${yyyymmdd.slice(4, 6)}-${yyyymmdd.slice(6, 8)}T12:00:00Z`; + const probe = new Date(iso); + if (Number.isNaN(probe.getTime())) return '+0000'; + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: zone, + timeZoneName: 'longOffset', + }).formatToParts(probe); + const raw = parts.find((p) => p.type === 'timeZoneName')?.value ?? 'GMT'; + const m = raw.match(/GMT([+-])(\d{2}):?(\d{2})?/); + if (!m) return '+0000'; // "GMT" (UTC) has no offset digits + return `${m[1]}${m[2]}${m[3] ?? '00'}`; +} + +// Minimal but RFC-valid VTIMEZONE. Smart clients override it with their own tz +// rules; dumb clients fall back to this fixed offset. +function buildVTimezone(zone: string, yyyymmdd: string): string { + const off = tzOffsetString(zone, yyyymmdd); + return ( + 'BEGIN:VTIMEZONE\r\n' + + `TZID:${zone}\r\n` + + 'BEGIN:STANDARD\r\n' + + 'DTSTART:19700101T000000\r\n' + + `TZOFFSETFROM:${off}\r\n` + + `TZOFFSETTO:${off}\r\n` + + `TZNAME:${zone}\r\n` + + 'END:STANDARD\r\n' + + 'END:VTIMEZONE\r\n' + ); +} +/** + * Everything TREK knows how to say in iCalendar. Moved out of TripsService + * unchanged: same statements, same escaping, same folding, same VTIMEZONE + * fallback, so the emitted bytes are identical for both consumers (the one-time + * download on the trip route and the subscribable feeds). + * + * It is its own domain rather than a method on trips because FeedsService needs + * calendars without needing the trip aggregate, and because the ICS rules + * (RFC 5545 folding, floating vs. zoned times) have nothing to do with trips. + */ +@Injectable() +export class CalendarService { + constructor( + private readonly dbs: DatabaseService, + private readonly reservations: ReservationsService, + ) {} + + private get db() { + return this.dbs.connection; + } + + // ── ICS export ──────────────────────────────────────────────────────────── + + exportICS(tripId: string | number): { ics: string; filename: string } { + const trip = this.db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as any; + if (!trip) throw new NotFoundError('Trip not found'); + + const reservations = this.db + .prepare( + `SELECT r.*, pl.lat AS place_lat, pl.lng AS place_lng + FROM reservations r + LEFT JOIN places pl ON r.place_id = pl.id + WHERE r.trip_id = ?`, + ) + .all(tripId) as any[]; + + const esc = (s: string) => s + .replace(/\\/g, '\\\\') + .replace(/;/g, '\\;') + .replace(/,/g, '\\,') + .replace(/\r?\n/g, '\\n') + .replace(/\r/g, ''); + const fmtDate = (d: string) => d.replace(/-/g, ''); + const now = new Date().toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'; + const uid = (id: number, type: string) => `trek-${type}-${id}@trek`; + + // Format datetime: handles full ISO "2026-03-30T09:00" and time-only "10:00" + // iCal requires exactly YYYYMMDDTHHMMSS format + const fmtDateTime = (d: string, refDate?: string) => { + if (d.includes('T')) { + const raw = d.replace(/[-:]/g, '').split('.')[0]; + // Pad to 15 chars (YYYYMMDDTHHMMSS) — add missing seconds + return raw.length === 13 ? raw + '00' : raw; + } + // Time-only: combine with reference date + if (refDate && d.match(/^\d{2}:\d{2}/)) { + const datePart = refDate.split('T')[0]; + return `${datePart}T${d.replace(/:/g, '')}00`.replace(/-/g, ''); + } + return d.replace(/[-:]/g, ''); + }; + + // Zones referenced by timed events → representative YYYYMMDD (for the fallback + // VTIMEZONE offset). Populated by dtLine; emitted once as VTIMEZONE blocks. + const usedZones = new Map(); + + // Emit a DTSTART/DTEND line, attaching TZID when the event's zone is known so + // subscribers see the time in TREK's zone. Falls back to a floating local time + // (unchanged behavior) when no zone resolves or the value is not a date-time. + const dtLine = ( + prop: 'DTSTART' | 'DTEND', + wallClock: string, + zone: string | null, + refDate?: string, + ): string => { + const val = fmtDateTime(wallClock, refDate); + if (zone && isValidTimeZone(zone) && /^\d{8}T\d{6}$/.test(val)) { + if (!usedZones.has(zone)) usedZones.set(zone, val.slice(0, 8)); + return `${prop};TZID=${zone}:${val}\r\n`; + } + return `${prop}:${val}\r\n`; + }; + + let ics = 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//TREK//Travel Planner//EN\r\nCALSCALE:GREGORIAN\r\nMETHOD:PUBLISH\r\n'; + ics += `X-WR-CALNAME:${esc(trip.title || 'TREK Trip')}\r\n`; + + // Trip as all-day event. DTEND is exclusive, so it must be the day *after* the last + // day. addDays() stays in UTC — building a local-time Date here dropped the trip's + // last day on any server east of Greenwich (#1453). + if (trip.start_date && trip.end_date) { + const endStr = fmtDate(addDays(trip.end_date, 1)); + ics += `BEGIN:VEVENT\r\nUID:${uid(trip.id, 'trip')}\r\nDTSTAMP:${now}\r\nDTSTART;VALUE=DATE:${fmtDate(trip.start_date)}\r\nDTEND;VALUE=DATE:${endStr}\r\nSUMMARY:${esc(trip.title || 'Trip')}\r\n`; + if (trip.description) ics += `DESCRIPTION:${esc(trip.description)}\r\n`; + ics += `END:VEVENT\r\n`; + } + + // Days with assignments and notes + const days = this.db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(tripId) as any[]; + for (const day of days) { + if (!day.date) continue; + + const assignments = this.db.prepare(` + SELECT da.*, p.name as place_name, p.address as place_address, + p.lat as place_lat, p.lng as place_lng, + COALESCE(da.assignment_time, p.place_time) as effective_time, + COALESCE(da.assignment_end_time, p.end_time) as effective_end_time + FROM day_assignments da + JOIN places p ON da.place_id = p.id + WHERE da.day_id = ? + ORDER BY da.order_index ASC, da.created_at ASC + `).all(day.id) as any[]; + + const notes = this.db.prepare( + 'SELECT * FROM day_notes WHERE day_id = ? ORDER BY sort_order ASC, created_at ASC' + ).all(day.id) as any[]; + + const timed = assignments.filter(a => a.effective_time); + const untimed = assignments.filter(a => !a.effective_time); + + // Timed assignments → individual events + for (const a of timed) { + const zone = resolveTimeZone(a.place_lat, a.place_lng); + ics += `BEGIN:VEVENT\r\nUID:${uid(a.id, 'assign')}\r\nDTSTAMP:${now}\r\n`; + ics += dtLine('DTSTART', a.effective_time, zone, day.date + 'T00:00'); + if (a.effective_end_time) { + ics += dtLine('DTEND', a.effective_end_time, zone, day.date + 'T00:00'); + } + ics += `SUMMARY:${esc(a.place_name)}\r\n`; + let desc = ''; + if (a.notes) desc += a.notes; + if (a.place_address) desc += (desc ? '\n' : '') + a.place_address; + if (desc) ics += `DESCRIPTION:${esc(desc)}\r\n`; + if (a.place_address) ics += `LOCATION:${esc(a.place_address)}\r\n`; + ics += `END:VEVENT\r\n`; + } + + // Build all-day summary event if there are untimed activities or notes + if (untimed.length > 0 || notes.length > 0) { + const dayTitle = day.title || `Day ${day.day_number}`; + const endStr = fmtDate(addDays(day.date, 1)); + + ics += `BEGIN:VEVENT\r\nUID:${uid(day.id, 'day')}\r\nDTSTAMP:${now}\r\n`; + ics += `DTSTART;VALUE=DATE:${fmtDate(day.date)}\r\nDTEND;VALUE=DATE:${endStr}\r\n`; + ics += `SUMMARY:${esc(dayTitle)}\r\n`; + + let desc = ''; + if (untimed.length > 0) { + desc += untimed.map(a => { + let line = `• ${a.place_name}`; + if (a.place_address) line += ` (${a.place_address})`; + if (a.notes) line += ` — ${a.notes}`; + return line; + }).join('\n'); + } + if (notes.length > 0) { + if (desc) desc += '\n\n'; + desc += 'Notes:\n' + notes.map(n => { + const line = n.time ? `${n.time} — ${n.text}` : `• ${n.text}`; + return line; + }).join('\n'); + } + if (desc) ics += `DESCRIPTION:${esc(desc)}\r\n`; + ics += `END:VEVENT\r\n`; + } + } + + // Transport/flight reservations carry no top-level reservation_time; their + // times live per endpoint (local_date + local_time) in reservation_endpoints. + const endpointsMap = this.reservations.loadEndpointsByTrip(tripId); + const isDate = (s: string | null | undefined) => !!s && /^\d{4}-\d{2}-\d{2}$/.test(s); + const isTime = (s: string | null | undefined) => !!s && /^\d{2}:\d{2}/.test(s); + + // Build the DTSTART/DTEND lines for a reservation, or null when it has no + // calendar-placeable time. Hotels/restaurants use reservation_time; flights + // fall back to their first/last endpoint. + const buildReservationTimeLines = (r: any): string | null => { + if (r.reservation_time) { + const datePart = r.reservation_time.includes('T') ? r.reservation_time.split('T')[0] : r.reservation_time; + if (!isDate(datePart)) return null; // time-only (relative "Day N" trips) + if (r.reservation_time.includes('T')) { + // Hotels/restaurants: derive the zone from the linked place, if any. + const zone = resolveTimeZone(r.place_lat, r.place_lng); + let out = dtLine('DTSTART', r.reservation_time, zone); + if (r.reservation_end_time) { + const endDt = fmtDateTime(r.reservation_end_time, r.reservation_time); + if (endDt.length >= 15) out += dtLine('DTEND', r.reservation_end_time, zone, r.reservation_time); + } + return out; + } + return `DTSTART;VALUE=DATE:${fmtDate(r.reservation_time)}\r\n`; + } + + const eps = endpointsMap.get(r.id); + if (!eps || eps.length === 0) return null; + const ordered = [...eps].sort((a, b) => a.sequence - b.sequence); + const first = ordered[0]; + const last = ordered[ordered.length - 1]; + if (!isDate(first.local_date)) return null; + if (isTime(first.local_time)) { + // Transport: departure endpoint zone drives DTSTART, arrival drives DTEND. + // Prefer the stored IANA zone; fall back to the endpoint's coordinates. + const startZone = first.timezone || resolveTimeZone(first.lat, first.lng); + let out = dtLine('DTSTART', `${first.local_date}T${first.local_time}`, startZone); + if (last !== first && isDate(last.local_date) && isTime(last.local_time)) { + const endZone = last.timezone || resolveTimeZone(last.lat, last.lng); + out += dtLine('DTEND', `${last.local_date}T${last.local_time}`, endZone); + } + return out; + } + return `DTSTART;VALUE=DATE:${fmtDate(first.local_date)}\r\n`; + }; + + // Reservations as events + for (const r of reservations) { + const timeLines = buildReservationTimeLines(r); + if (!timeLines) continue; + const meta = r.metadata ? (typeof r.metadata === 'string' ? JSON.parse(r.metadata) : r.metadata) : {}; + + ics += `BEGIN:VEVENT\r\nUID:${uid(r.id, 'res')}\r\nDTSTAMP:${now}\r\n`; + ics += timeLines; + ics += `SUMMARY:${esc(r.title)}\r\n`; + + let desc = r.type ? `Type: ${r.type}` : ''; + if (r.confirmation_number) desc += `\nConfirmation: ${r.confirmation_number}`; + if (meta.airline) desc += `\nAirline: ${meta.airline}`; + if (meta.flight_number) desc += `\nFlight: ${meta.flight_number}`; + if (Array.isArray(meta.legs) && meta.legs.length > 1) { + // Multi-leg flight: show the whole route (FRA → BER → HND) on one event. + const stops = [meta.legs[0]?.from, ...meta.legs.map((l: { to?: string }) => l.to)].filter(Boolean); + if (stops.length) desc += `\nRoute: ${stops.join(' → ')}`; + } else if (meta.departure_airport || meta.arrival_airport) { + if (meta.departure_airport) desc += `\nFrom: ${meta.departure_airport}`; + if (meta.arrival_airport) desc += `\nTo: ${meta.arrival_airport}`; + } else { + // Endpoint-based transport without route metadata: derive it from endpoints. + const eps = endpointsMap.get(r.id); + if (eps && eps.length > 1) { + const stops = [...eps].sort((a, b) => a.sequence - b.sequence).map(e => e.code || e.name).filter(Boolean); + if (stops.length > 1) desc += `\nRoute: ${stops.join(' → ')}`; + } + } + if (meta.train_number) desc += `\nTrain: ${meta.train_number}`; + if (r.notes) desc += `\n${r.notes}`; + if (desc) ics += `DESCRIPTION:${esc(desc)}\r\n`; + if (r.location) ics += `LOCATION:${esc(r.location)}\r\n`; + ics += `END:VEVENT\r\n`; + } + + ics += 'END:VCALENDAR\r\n'; + + // Define every referenced zone with a VTIMEZONE, inserted before the first + // event so TZID references resolve. No-op when no timed event carried a zone. + if (usedZones.size > 0) { + let vtz = ''; + for (const [zone, yyyymmdd] of usedZones) vtz += buildVTimezone(zone, yyyymmdd); + ics = ics.replace('BEGIN:VEVENT', vtz + 'BEGIN:VEVENT'); + } + + const safeFilename = (trip.title || 'trek-trip').replace(/["\r\n]/g, '').replace(/[^\w\s.-]/g, '_'); + return { ics: foldICS(ics), filename: `${safeFilename}.ics` }; + } +} diff --git a/server/src/nest/common/domain-errors.ts b/server/src/nest/common/domain-errors.ts new file mode 100644 index 000000000..c1fbb3010 --- /dev/null +++ b/server/src/nest/common/domain-errors.ts @@ -0,0 +1,18 @@ +/** + * The two error kinds the trip-shaped domains raise and their controllers map to + * HTTP. They live here rather than on one service so a second domain (calendar, + * maps) can raise them without importing the first one just for its error types. + */ +export class NotFoundError extends Error { + constructor(message: string) { + super(message); + this.name = 'NotFoundError'; + } +} + +export class ValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'ValidationError'; + } +} diff --git a/server/src/nest/feeds/feeds.module.ts b/server/src/nest/feeds/feeds.module.ts index 1aab7b275..399db42f5 100644 --- a/server/src/nest/feeds/feeds.module.ts +++ b/server/src/nest/feeds/feeds.module.ts @@ -1,10 +1,13 @@ import { Module } from '@nestjs/common'; -import { TripsModule } from '../trips/trips.module'; +import { CalendarModule } from '../calendar/calendar.module'; +import { DatabaseModule } from '../database/database.module'; import { FeedsService } from './feeds.service'; import { FeedsPublicController, TripFeedTokenController, UserFeedTokenController } from './feeds.controller'; @Module({ - imports: [TripsModule], + // Calendars, not the trip aggregate: feeds only ever needed an ICS string, and + // importing TripsModule for it pulled budget, packing, places and the rest in. + imports: [CalendarModule, DatabaseModule], controllers: [FeedsPublicController, TripFeedTokenController, UserFeedTokenController], providers: [FeedsService], }) diff --git a/server/src/nest/feeds/feeds.service.ts b/server/src/nest/feeds/feeds.service.ts index 473b1cb87..297349989 100644 --- a/server/src/nest/feeds/feeds.service.ts +++ b/server/src/nest/feeds/feeds.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { randomUUID } from 'crypto'; import { DatabaseService } from '../database/database.service'; -import { TripsService } from '../trips/trips.service'; +import { CalendarService } from '../calendar/calendar.service'; const ninetyDaysAgo = () => { const d = new Date(); @@ -17,7 +17,7 @@ function feedUrl(token: string, scope: 'trip' | 'user', base: string): string { export class FeedsService { constructor( private readonly dbs: DatabaseService, - private readonly trips: TripsService, + private readonly calendar: CalendarService, ) {} private get db() { @@ -95,7 +95,7 @@ export class FeedsService { | undefined; if (!row) return null; try { - const { ics, filename } = this.trips.exportICS(row.id); + const { ics, filename } = this.calendar.exportICS(row.id); // Inject calendar-subscription refresh hints into the VCALENDAR header so // clients re-fetch hourly. The one-time download path (exportICS) is left // untouched; this is feed-only. @@ -145,7 +145,7 @@ export class FeedsService { let events = ''; for (const { id } of trips) { try { - const { ics } = this.trips.exportICS(id); + const { ics } = this.calendar.exportICS(id); for (const vtz of extractVTimezones(ics)) { const tzid = vtz.match(/\r\nTZID:(.+)\r\n/)?.[1]; if (tzid && !zones.has(tzid)) zones.set(tzid, vtz); diff --git a/server/src/nest/trips/trips.controller.ts b/server/src/nest/trips/trips.controller.ts index 15fd8e1ce..18b00a2c0 100644 --- a/server/src/nest/trips/trips.controller.ts +++ b/server/src/nest/trips/trips.controller.ts @@ -35,6 +35,7 @@ import { AuditService } from '../audit/audit.service'; import { NotFoundError, ValidationError } from './trips.service'; import { TripCreateDto, TripUpdateDto, TripCopyDto, TripAddMemberDto, TripTransferOwnershipDto, TripCreateGuestDto, TripRenameGuestDto } from './trips.dto'; import { UnsplashService } from '../unsplash/unsplash.service'; +import { CalendarService } from '../calendar/calendar.service'; const MAX_COVER_SIZE = 20 * 1024 * 1024; const coversDir = path.join(__dirname, '../../../uploads/covers'); @@ -70,7 +71,9 @@ const addDays = (d: Date, n: number) => { const r = new Date(d); r.setDate(r.get @Controller('api/trips') @UseGuards(JwtAuthGuard) export class TripsController { - constructor(private readonly trips: TripsService, private readonly audit: AuditService, private readonly env: RuntimeEnvService, private readonly unsplash: UnsplashService) {} + // calendar last: the hand-wired construction sites in the tests stay positional, + // so a new dependency does not touch the ones that never reach the ICS route. + constructor(private readonly trips: TripsService, private readonly audit: AuditService, private readonly env: RuntimeEnvService, private readonly unsplash: UnsplashService, private readonly calendar: CalendarService) {} @Get() list(@CurrentUser() user: User, @Query('archived') archived?: string) { @@ -394,7 +397,7 @@ export class TripsController { throw new HttpException({ error: 'Trip not found' }, 404); } try { - const { ics, filename } = this.trips.exportICS(id); + const { ics, filename } = this.calendar.exportICS(id); res.setHeader('Content-Type', 'text/calendar; charset=utf-8'); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); res.send(ics); diff --git a/server/src/nest/trips/trips.mcp.ts b/server/src/nest/trips/trips.mcp.ts index 9782c8d3b..a135b73ff 100644 --- a/server/src/nest/trips/trips.mcp.ts +++ b/server/src/nest/trips/trips.mcp.ts @@ -6,6 +6,7 @@ import { } from '@trek/nest-mcp'; import { z } from 'zod'; import { AuthService } from '../auth/auth.service'; +import { CalendarService } from '../calendar/calendar.service'; import { isAddonEnabled, getCollabFeatures } from '../addons/addons.bridge'; import { ADDON_IDS } from '../../addons'; import { safeBroadcast, MAX_MCP_TRIP_DAYS, noAccess, hasTripPermission, permissionDenied } from '../../mcp/tools/_shared'; @@ -63,6 +64,9 @@ export class TripsMcp { private readonly todos: TodoService, private readonly collab: CollabService, private readonly auth: AuthService, + // Appended, not inserted: the hand-wired MCP test harnesses build this + // positionally, so an earlier slot would silently shift every one of them. + private readonly calendar: CalendarService, ) {} // --- TRIPS --- @@ -356,7 +360,7 @@ export class TripsMcp { async exportTripIcs({ tripId }: { tripId: number }, ctx: McpContext) { if (!this.trips.canAccessTrip(tripId, ctx.userId)) return noAccess(); try { - const { ics, filename } = this.trips.exportICS(tripId); + const { ics, filename } = this.calendar.exportICS(tripId); return ok({ ics, filename }); } catch { return { content: [{ type: 'text' as const, text: 'Trip not found.' }], isError: true }; diff --git a/server/src/nest/trips/trips.module.ts b/server/src/nest/trips/trips.module.ts index fedfe891b..32799c053 100644 --- a/server/src/nest/trips/trips.module.ts +++ b/server/src/nest/trips/trips.module.ts @@ -20,11 +20,12 @@ import { AppConfigModule } from '../app-config/app-config.module'; import { UnsplashModule } from '../unsplash/unsplash.module'; import { TripsMcp } from './trips.mcp'; import { AuthModule } from '../auth/auth.module'; +import { CalendarModule } from '../calendar/calendar.module'; /** Trips aggregate root (C1 — Phase 3). Uses exact strangler prefixes so it does * not capture the nested sub-domain mounts (collab, files, ...). */ @Module({ - imports: [TodoModule, PackingModule, FilesModule, ReservationsModule, DaysModule, PermissionsModule, AuditModule, BudgetModule, CollabModule, VacayModule, PlacesModule, AuthModule, AppConfigModule, UnsplashModule, RealtimeModule, PluginGuardsModule, TripMembershipModule], + imports: [TodoModule, PackingModule, FilesModule, ReservationsModule, DaysModule, PermissionsModule, AuditModule, BudgetModule, CollabModule, VacayModule, PlacesModule, AuthModule, AppConfigModule, UnsplashModule, RealtimeModule, PluginGuardsModule, TripMembershipModule, CalendarModule], controllers: [TripsController], providers: [TripsService, TripsMcp, TripsRpc], // Exported for FeedsModule (ICS feeds) and PluginsModule (RPC host injection). diff --git a/server/src/nest/trips/trips.service.ts b/server/src/nest/trips/trips.service.ts index f4bf7d3d0..726e961e6 100644 --- a/server/src/nest/trips/trips.service.ts +++ b/server/src/nest/trips/trips.service.ts @@ -10,9 +10,8 @@ import type { Trip, User } from '../../types'; import { avatarUrl } from '../common/avatarUrl'; import { UserCleanupService } from '../auth/user-cleanup.service'; import { emitUserDeleted } from '../../plugin-user-lifecycle'; -import { resolveTimeZone } from '../common/timezoneService'; import { PlacesService } from '../places/places.service'; -import { DaysService, addDays } from '../days/days.service'; +import { DaysService } from '../days/days.service'; import { PackingService } from '../packing/packing.service'; import { TodoService } from '../todo/todo.service'; import { BudgetService } from '../budget/budget.service'; @@ -21,6 +20,7 @@ import { FilesService } from '../files/files.service'; import { CollabService } from '../collab/collab.service'; import { VacayService } from '../vacay/vacay.service'; import { UnsplashService } from '../unsplash/unsplash.service'; +import { NotFoundError, ValidationError } from '../common/domain-errors'; export const MS_PER_DAY = 86400000; export const MAX_TRIP_DAYS = 365; @@ -109,96 +109,6 @@ export interface GuestMember { avatar_url: null; } -// ── ICS folding ───────────────────────────────────────────────────────────── - -// RFC 5545 §3.1: content lines longer than 75 octets must be folded with a CRLF -// followed by a single leading space. We fold on UTF-8 *octet* boundaries and -// never split a multi-byte codepoint, so non-ASCII titles/notes (accents, CJK, -// emoji) stay intact. Applied to the whole calendar, so both the one-time -// download and the subscribable feed emit spec-compliant output. -function foldICS(ics: string): string { - const foldLine = (line: string): string => { - const bytes = Buffer.from(line, 'utf8'); - if (bytes.length <= 75) return line; - const parts: Buffer[] = []; - let start = 0; - let limit = 75; // first physical line may use 75 octets - while (start < bytes.length) { - let end = Math.min(start + limit, bytes.length); - // Back off so we never cut a multi-byte UTF-8 sequence (0x80–0xBF = continuation byte). - while (end < bytes.length && (bytes[end] & 0xc0) === 0x80) end--; - parts.push(bytes.subarray(start, end)); - start = end; - limit = 74; // continuation lines spend one octet on the leading space - } - return parts.map((b, i) => (i === 0 ? '' : ' ') + b.toString('utf8')).join('\r\n'); - }; - return ics.split('\r\n').map(foldLine).join('\r\n'); -} - -// ── ICS time-zone helpers ──────────────────────────────────────────────────── -// Timed events must carry an explicit IANA zone; a bare "YYYYMMDDTHHMMSS" is an -// RFC 5545 "floating" time that clients render in the *subscriber's* zone (#1453). - -// A stored/plugin-provided timezone (e.g. a transport endpoint's `timezone`) is a -// free string that need not be a real IANA zone. Intl.DateTimeFormat throws a -// RangeError on an unknown zone, which — via buildVTimezone → tzOffsetString — -// would crash the whole ICS export (and drop the trip from the all-trips feed). -// Validate once so an invalid zone degrades to a floating local time instead. -// Module-scoped on purpose (like the permissions/FX caches): the bridge instance -// and the DI singleton share one cache. -const _tzValidCache = new Map(); -function isValidTimeZone(zone: string): boolean { - const cached = _tzValidCache.get(zone); - if (cached !== undefined) return cached; - let ok = false; - try { - new Intl.DateTimeFormat('en-US', { timeZone: zone }); - ok = true; - } catch { - // Unknown/invalid zone → ok stays false. - } - // Bound the cache — the key is a free-form (plugin/importer-written) zone string, - // so cap distinct entries rather than growing for the process lifetime. - if (_tzValidCache.size >= 1000) _tzValidCache.clear(); - _tzValidCache.set(zone, ok); - return ok; -} - -// UTC offset ("+0200") the zone uses on the given YYYYMMDD date. Only feeds the -// fallback VTIMEZONE offset; iOS/Google resolve the named zone from their own -// IANA database, so a single representative offset is sufficient. -function tzOffsetString(zone: string, yyyymmdd: string): string { - const iso = `${yyyymmdd.slice(0, 4)}-${yyyymmdd.slice(4, 6)}-${yyyymmdd.slice(6, 8)}T12:00:00Z`; - const probe = new Date(iso); - if (Number.isNaN(probe.getTime())) return '+0000'; - const parts = new Intl.DateTimeFormat('en-US', { - timeZone: zone, - timeZoneName: 'longOffset', - }).formatToParts(probe); - const raw = parts.find((p) => p.type === 'timeZoneName')?.value ?? 'GMT'; - const m = raw.match(/GMT([+-])(\d{2}):?(\d{2})?/); - if (!m) return '+0000'; // "GMT" (UTC) has no offset digits - return `${m[1]}${m[2]}${m[3] ?? '00'}`; -} - -// Minimal but RFC-valid VTIMEZONE. Smart clients override it with their own tz -// rules; dumb clients fall back to this fixed offset. -function buildVTimezone(zone: string, yyyymmdd: string): string { - const off = tzOffsetString(zone, yyyymmdd); - return ( - 'BEGIN:VTIMEZONE\r\n' + - `TZID:${zone}\r\n` + - 'BEGIN:STANDARD\r\n' + - 'DTSTART:19700101T000000\r\n' + - `TZOFFSETFROM:${off}\r\n` + - `TZOFFSETTO:${off}\r\n` + - `TZNAME:${zone}\r\n` + - 'END:STANDARD\r\n' + - 'END:VTIMEZONE\r\n' - ); -} - /** * Trip aggregate root, DI-native. The SQL moved 1:1 from the legacy * services/tripService.ts: identical statements, the `||` falsy-coercion @@ -944,247 +854,6 @@ export class TripsService { return true; } - // ── ICS export ──────────────────────────────────────────────────────────── - - exportICS(tripId: string | number): { ics: string; filename: string } { - const trip = this.db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as any; - if (!trip) throw new NotFoundError('Trip not found'); - - const reservations = this.db - .prepare( - `SELECT r.*, pl.lat AS place_lat, pl.lng AS place_lng - FROM reservations r - LEFT JOIN places pl ON r.place_id = pl.id - WHERE r.trip_id = ?`, - ) - .all(tripId) as any[]; - - const esc = (s: string) => s - .replace(/\\/g, '\\\\') - .replace(/;/g, '\\;') - .replace(/,/g, '\\,') - .replace(/\r?\n/g, '\\n') - .replace(/\r/g, ''); - const fmtDate = (d: string) => d.replace(/-/g, ''); - const now = new Date().toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'; - const uid = (id: number, type: string) => `trek-${type}-${id}@trek`; - - // Format datetime: handles full ISO "2026-03-30T09:00" and time-only "10:00" - // iCal requires exactly YYYYMMDDTHHMMSS format - const fmtDateTime = (d: string, refDate?: string) => { - if (d.includes('T')) { - const raw = d.replace(/[-:]/g, '').split('.')[0]; - // Pad to 15 chars (YYYYMMDDTHHMMSS) — add missing seconds - return raw.length === 13 ? raw + '00' : raw; - } - // Time-only: combine with reference date - if (refDate && d.match(/^\d{2}:\d{2}/)) { - const datePart = refDate.split('T')[0]; - return `${datePart}T${d.replace(/:/g, '')}00`.replace(/-/g, ''); - } - return d.replace(/[-:]/g, ''); - }; - - // Zones referenced by timed events → representative YYYYMMDD (for the fallback - // VTIMEZONE offset). Populated by dtLine; emitted once as VTIMEZONE blocks. - const usedZones = new Map(); - - // Emit a DTSTART/DTEND line, attaching TZID when the event's zone is known so - // subscribers see the time in TREK's zone. Falls back to a floating local time - // (unchanged behavior) when no zone resolves or the value is not a date-time. - const dtLine = ( - prop: 'DTSTART' | 'DTEND', - wallClock: string, - zone: string | null, - refDate?: string, - ): string => { - const val = fmtDateTime(wallClock, refDate); - if (zone && isValidTimeZone(zone) && /^\d{8}T\d{6}$/.test(val)) { - if (!usedZones.has(zone)) usedZones.set(zone, val.slice(0, 8)); - return `${prop};TZID=${zone}:${val}\r\n`; - } - return `${prop}:${val}\r\n`; - }; - - let ics = 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//TREK//Travel Planner//EN\r\nCALSCALE:GREGORIAN\r\nMETHOD:PUBLISH\r\n'; - ics += `X-WR-CALNAME:${esc(trip.title || 'TREK Trip')}\r\n`; - - // Trip as all-day event. DTEND is exclusive, so it must be the day *after* the last - // day. addDays() stays in UTC — building a local-time Date here dropped the trip's - // last day on any server east of Greenwich (#1453). - if (trip.start_date && trip.end_date) { - const endStr = fmtDate(addDays(trip.end_date, 1)); - ics += `BEGIN:VEVENT\r\nUID:${uid(trip.id, 'trip')}\r\nDTSTAMP:${now}\r\nDTSTART;VALUE=DATE:${fmtDate(trip.start_date)}\r\nDTEND;VALUE=DATE:${endStr}\r\nSUMMARY:${esc(trip.title || 'Trip')}\r\n`; - if (trip.description) ics += `DESCRIPTION:${esc(trip.description)}\r\n`; - ics += `END:VEVENT\r\n`; - } - - // Days with assignments and notes - const days = this.db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(tripId) as any[]; - for (const day of days) { - if (!day.date) continue; - - const assignments = this.db.prepare(` - SELECT da.*, p.name as place_name, p.address as place_address, - p.lat as place_lat, p.lng as place_lng, - COALESCE(da.assignment_time, p.place_time) as effective_time, - COALESCE(da.assignment_end_time, p.end_time) as effective_end_time - FROM day_assignments da - JOIN places p ON da.place_id = p.id - WHERE da.day_id = ? - ORDER BY da.order_index ASC, da.created_at ASC - `).all(day.id) as any[]; - - const notes = this.db.prepare( - 'SELECT * FROM day_notes WHERE day_id = ? ORDER BY sort_order ASC, created_at ASC' - ).all(day.id) as any[]; - - const timed = assignments.filter(a => a.effective_time); - const untimed = assignments.filter(a => !a.effective_time); - - // Timed assignments → individual events - for (const a of timed) { - const zone = resolveTimeZone(a.place_lat, a.place_lng); - ics += `BEGIN:VEVENT\r\nUID:${uid(a.id, 'assign')}\r\nDTSTAMP:${now}\r\n`; - ics += dtLine('DTSTART', a.effective_time, zone, day.date + 'T00:00'); - if (a.effective_end_time) { - ics += dtLine('DTEND', a.effective_end_time, zone, day.date + 'T00:00'); - } - ics += `SUMMARY:${esc(a.place_name)}\r\n`; - let desc = ''; - if (a.notes) desc += a.notes; - if (a.place_address) desc += (desc ? '\n' : '') + a.place_address; - if (desc) ics += `DESCRIPTION:${esc(desc)}\r\n`; - if (a.place_address) ics += `LOCATION:${esc(a.place_address)}\r\n`; - ics += `END:VEVENT\r\n`; - } - - // Build all-day summary event if there are untimed activities or notes - if (untimed.length > 0 || notes.length > 0) { - const dayTitle = day.title || `Day ${day.day_number}`; - const endStr = fmtDate(addDays(day.date, 1)); - - ics += `BEGIN:VEVENT\r\nUID:${uid(day.id, 'day')}\r\nDTSTAMP:${now}\r\n`; - ics += `DTSTART;VALUE=DATE:${fmtDate(day.date)}\r\nDTEND;VALUE=DATE:${endStr}\r\n`; - ics += `SUMMARY:${esc(dayTitle)}\r\n`; - - let desc = ''; - if (untimed.length > 0) { - desc += untimed.map(a => { - let line = `• ${a.place_name}`; - if (a.place_address) line += ` (${a.place_address})`; - if (a.notes) line += ` — ${a.notes}`; - return line; - }).join('\n'); - } - if (notes.length > 0) { - if (desc) desc += '\n\n'; - desc += 'Notes:\n' + notes.map(n => { - const line = n.time ? `${n.time} — ${n.text}` : `• ${n.text}`; - return line; - }).join('\n'); - } - if (desc) ics += `DESCRIPTION:${esc(desc)}\r\n`; - ics += `END:VEVENT\r\n`; - } - } - - // Transport/flight reservations carry no top-level reservation_time; their - // times live per endpoint (local_date + local_time) in reservation_endpoints. - const endpointsMap = this.reservations.loadEndpointsByTrip(tripId); - const isDate = (s: string | null | undefined) => !!s && /^\d{4}-\d{2}-\d{2}$/.test(s); - const isTime = (s: string | null | undefined) => !!s && /^\d{2}:\d{2}/.test(s); - - // Build the DTSTART/DTEND lines for a reservation, or null when it has no - // calendar-placeable time. Hotels/restaurants use reservation_time; flights - // fall back to their first/last endpoint. - const buildReservationTimeLines = (r: any): string | null => { - if (r.reservation_time) { - const datePart = r.reservation_time.includes('T') ? r.reservation_time.split('T')[0] : r.reservation_time; - if (!isDate(datePart)) return null; // time-only (relative "Day N" trips) - if (r.reservation_time.includes('T')) { - // Hotels/restaurants: derive the zone from the linked place, if any. - const zone = resolveTimeZone(r.place_lat, r.place_lng); - let out = dtLine('DTSTART', r.reservation_time, zone); - if (r.reservation_end_time) { - const endDt = fmtDateTime(r.reservation_end_time, r.reservation_time); - if (endDt.length >= 15) out += dtLine('DTEND', r.reservation_end_time, zone, r.reservation_time); - } - return out; - } - return `DTSTART;VALUE=DATE:${fmtDate(r.reservation_time)}\r\n`; - } - - const eps = endpointsMap.get(r.id); - if (!eps || eps.length === 0) return null; - const ordered = [...eps].sort((a, b) => a.sequence - b.sequence); - const first = ordered[0]; - const last = ordered[ordered.length - 1]; - if (!isDate(first.local_date)) return null; - if (isTime(first.local_time)) { - // Transport: departure endpoint zone drives DTSTART, arrival drives DTEND. - // Prefer the stored IANA zone; fall back to the endpoint's coordinates. - const startZone = first.timezone || resolveTimeZone(first.lat, first.lng); - let out = dtLine('DTSTART', `${first.local_date}T${first.local_time}`, startZone); - if (last !== first && isDate(last.local_date) && isTime(last.local_time)) { - const endZone = last.timezone || resolveTimeZone(last.lat, last.lng); - out += dtLine('DTEND', `${last.local_date}T${last.local_time}`, endZone); - } - return out; - } - return `DTSTART;VALUE=DATE:${fmtDate(first.local_date)}\r\n`; - }; - - // Reservations as events - for (const r of reservations) { - const timeLines = buildReservationTimeLines(r); - if (!timeLines) continue; - const meta = r.metadata ? (typeof r.metadata === 'string' ? JSON.parse(r.metadata) : r.metadata) : {}; - - ics += `BEGIN:VEVENT\r\nUID:${uid(r.id, 'res')}\r\nDTSTAMP:${now}\r\n`; - ics += timeLines; - ics += `SUMMARY:${esc(r.title)}\r\n`; - - let desc = r.type ? `Type: ${r.type}` : ''; - if (r.confirmation_number) desc += `\nConfirmation: ${r.confirmation_number}`; - if (meta.airline) desc += `\nAirline: ${meta.airline}`; - if (meta.flight_number) desc += `\nFlight: ${meta.flight_number}`; - if (Array.isArray(meta.legs) && meta.legs.length > 1) { - // Multi-leg flight: show the whole route (FRA → BER → HND) on one event. - const stops = [meta.legs[0]?.from, ...meta.legs.map((l: { to?: string }) => l.to)].filter(Boolean); - if (stops.length) desc += `\nRoute: ${stops.join(' → ')}`; - } else if (meta.departure_airport || meta.arrival_airport) { - if (meta.departure_airport) desc += `\nFrom: ${meta.departure_airport}`; - if (meta.arrival_airport) desc += `\nTo: ${meta.arrival_airport}`; - } else { - // Endpoint-based transport without route metadata: derive it from endpoints. - const eps = endpointsMap.get(r.id); - if (eps && eps.length > 1) { - const stops = [...eps].sort((a, b) => a.sequence - b.sequence).map(e => e.code || e.name).filter(Boolean); - if (stops.length > 1) desc += `\nRoute: ${stops.join(' → ')}`; - } - } - if (meta.train_number) desc += `\nTrain: ${meta.train_number}`; - if (r.notes) desc += `\n${r.notes}`; - if (desc) ics += `DESCRIPTION:${esc(desc)}\r\n`; - if (r.location) ics += `LOCATION:${esc(r.location)}\r\n`; - ics += `END:VEVENT\r\n`; - } - - ics += 'END:VCALENDAR\r\n'; - - // Define every referenced zone with a VTIMEZONE, inserted before the first - // event so TZID references resolve. No-op when no timed event carried a zone. - if (usedZones.size > 0) { - let vtz = ''; - for (const [zone, yyyymmdd] of usedZones) vtz += buildVTimezone(zone, yyyymmdd); - ics = ics.replace('BEGIN:VEVENT', vtz + 'BEGIN:VEVENT'); - } - - const safeFilename = (trip.title || 'trek-trip').replace(/["\r\n]/g, '').replace(/[^\w\s.-]/g, '_'); - return { ics: foldICS(ics), filename: `${safeFilename}.ics` }; - } - // ── Trip summary (used by MCP get_trip_summary tool) ────────────────────── getTripSummary(tripId: number, viewerUserId?: number) { @@ -1268,18 +937,7 @@ export class TripsService { } } -// ── Custom error types ──────────────────────────────────────────────────── - -export class NotFoundError extends Error { - constructor(message: string) { - super(message); - this.name = 'NotFoundError'; - } -} - -export class ValidationError extends Error { - constructor(message: string) { - super(message); - this.name = 'ValidationError'; - } -} +// Defined in common/ so calendar and maps can raise them without importing the +// trip aggregate; re-exported here because nine files already import them from +// this module. +export { NotFoundError, ValidationError }; diff --git a/server/tests/e2e/feeds.e2e.test.ts b/server/tests/e2e/feeds.e2e.test.ts index 6c31e0f02..508d20ba7 100644 --- a/server/tests/e2e/feeds.e2e.test.ts +++ b/server/tests/e2e/feeds.e2e.test.ts @@ -42,12 +42,12 @@ const SAMPLE_ICS = 'BEGIN:VEVENT\r\nUID:trek-trip-x@trek\r\nDTSTAMP:20260101T000000Z\r\n' + 'DTSTART;VALUE=DATE:20260101\r\nDTEND;VALUE=DATE:20260102\r\nSUMMARY:Sample\r\nEND:VEVENT\r\n' + 'END:VCALENDAR\r\n'; -// Since the trip fold, FeedsService injects TripsService — the mock is a spy on -// the container singleton (created in beforeAll, after build()). +// FeedsService injects CalendarService — the mock is a spy on the container +// singleton (created in beforeAll, after build()). const exportICS = vi.fn(); import { FeedsModule } from '../../src/nest/feeds/feeds.module'; -import { TripsService } from '../../src/nest/trips/trips.service'; +import { CalendarService } from '../../src/nest/calendar/calendar.service'; import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; @@ -73,7 +73,7 @@ describe('Calendar-feed e2e (real auth guard + temp SQLite)', () => { seedUser(db as never, { id: 1, username: 'e2e-user' }); seedUser(db as never, { id: 2, username: 'other-user', email: 'other@example.test' }); app = await build(); - vi.spyOn(app.get(TripsService), 'exportICS').mockImplementation(exportICS as never); + vi.spyOn(app.get(CalendarService), 'exportICS').mockImplementation(exportICS as never); server = app.getHttpServer(); }); diff --git a/server/tests/helpers/mcp-test-controllers.ts b/server/tests/helpers/mcp-test-controllers.ts index d0c2227f0..9db4b6a53 100644 --- a/server/tests/helpers/mcp-test-controllers.ts +++ b/server/tests/helpers/mcp-test-controllers.ts @@ -56,6 +56,8 @@ import { JourneyDomainService } from '../../src/nest/journey/journey-domain.serv import { JourneyShareService } from '../../src/nest/journey/journey-share.service'; import { TrekPhotosRepository } from '../../src/nest/photos/trek-photos.repository'; import { UnsplashService } from '../../src/nest/unsplash/unsplash.service'; +import { UserCleanupService } from '../../src/nest/auth/user-cleanup.service'; +import { CalendarService } from '../../src/nest/calendar/calendar.service'; import { PlacePhotoCacheService } from '../../src/nest/place-photos/place-photo-cache.service'; import { RuntimeEnvService } from '../../src/nest/app-config/runtime-env.service'; import { makeNotificationsService, makeNotificationPreferencesService } from './notifications'; @@ -105,7 +107,13 @@ export function createMcpTestRegistry(): McpRegistry { new VacayService(dbService, realtimeService), realtimeService, placesService, + // These two were missing: the call passed 12 arguments to a 14-parameter + // constructor, so unsplash and userCleanup were undefined here. tsconfig + // has include:['src'], so tsc never saw it. + new UnsplashService(dbService, new RuntimeEnvService()), + new UserCleanupService(dbService), ); + const calendarService = new CalendarService(dbService, reservationsService); return createTestRegistry( [ new TagsMcp(new TagsService(dbService), authService), @@ -123,7 +131,7 @@ export function createMcpTestRegistry(): McpRegistry { new AssignmentsMcp(new AssignmentsService(dbService, permissionsService, realtimeService, queryHelpersService, journeyDomain), daysService, authService), new CollabMcp(collabService, authService), new VacayMcp(new VacayService(dbService, realtimeService), authService), - new TripsMcp(tripsService, todoService, collabService, authService), + new TripsMcp(tripsService, todoService, collabService, authService, calendarService), new ShareMcp(new ShareService(dbService, new SettingsService(dbService), permissionsService, queryHelpersService), authService), new MapsMcp(mapsService), new PlacesMcp(placesService, mapsService, dbService, authService, journeyDomain), diff --git a/server/tests/unit/nest/calendar.service.test.ts b/server/tests/unit/nest/calendar.service.test.ts new file mode 100644 index 000000000..2012d2cdc --- /dev/null +++ b/server/tests/unit/nest/calendar.service.test.ts @@ -0,0 +1,389 @@ +/** + * Unit tests for CalendarService. The cases moved 1:1 out of + * tests/unit/nest/trips.service.test.ts together with the code they cover + * (TRIP-SVC-001..002b, 020..027 and 048); the identifiers are unchanged so the + * diff shows a move rather than a rewrite, and the assertions still pin the + * emitted ICS byte for byte. Uses a real in-memory SQLite DB so the SQL runs. + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; + +// ── DB setup ────────────────────────────────────────────── + +const { testDb, dbMock } = vi.hoisted(() => { + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + db.exec('PRAGMA busy_timeout = 5000'); + const mock = { + db, + closeDb: () => {}, + reinitialize: () => {}, + getPlaceWithTags: () => null, + canAccessTrip: (tripId: any, userId: number) => + db.prepare(` + SELECT t.id, t.user_id FROM trips t + LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? + WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL) + `).get(userId, tripId, userId), + isOwner: (tripId: any, userId: number) => + !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId), + }; + return { testDb: db, dbMock: mock }; +}); + +vi.mock('../../../src/db/database', () => dbMock); +vi.mock('../../../src/config', () => ({ + JWT_SECRET: 'test-secret', + ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', + updateJwtSecret: () => {}, +})); +const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() })); +vi.mock('../../../src/websocket', () => ({ broadcast })); + +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; +import { resetTestDb } from '../../helpers/test-db'; +import { createUser, createTrip, createReservation, createPlace, createDay, createDayAssignment, createDayNote } from '../../helpers/factories'; +import { DatabaseService } from '../../../src/nest/database/database.service'; +import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; +import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; +import { ReservationsService } from '../../../src/nest/reservations/reservations.service'; +import { BudgetService } from '../../../src/nest/budget/budget.service'; +import { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service'; +import { CalendarService } from '../../../src/nest/calendar/calendar.service'; +import { CalendarModule } from '../../../src/nest/calendar/calendar.module'; +import { expectRegisteredProvider } from '../../helpers/module-providers'; + +const dbs = () => new DatabaseService(testDb); +const budgetSvc = new BudgetService(dbs(), new PermissionsService(dbs()), new ExchangeRatesService(), new RealtimeService()); + +// Named `svc` so the moved cases below read exactly as they did on TripsService. +const svc = new CalendarService( + dbs(), + new ReservationsService(dbs(), new PermissionsService(dbs()), budgetSvc, new RealtimeService()), +); + +beforeAll(() => { + createTables(testDb); + runMigrations(testDb); +}); + +beforeEach(() => { + resetTestDb(testDb); +}); + +afterAll(() => { + testDb.close(); +}); + +describe('exportICS', () => { + it('TRIP-SVC-001: returns VCALENDAR wrapper', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { + title: 'My Vacation', + start_date: '2025-06-01', + end_date: '2025-06-07', + }); + + const { ics } = svc.exportICS(trip.id); + + expect(ics).toContain('BEGIN:VCALENDAR'); + expect(ics).toContain('END:VCALENDAR'); + }); + + it('TRIP-SVC-002: trip with start_date + end_date includes all-day VEVENT', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { + title: 'Summer Holiday', + start_date: '2025-06-01', + end_date: '2025-06-07', + }); + + const { ics } = svc.exportICS(trip.id); + + expect(ics).toContain('DTSTART;VALUE=DATE:20250601'); + // DTEND is exclusive — the day *after* the last day, or the trip loses a day. + expect(ics).toContain('DTEND;VALUE=DATE:20250608'); + expect(ics).toContain('SUMMARY:Summer Holiday'); + }); + + describe('#1453 all-day DTEND is timezone-independent', () => { + const originalTz = process.env.TZ; + + afterAll(() => { + process.env.TZ = originalTz; + }); + + // The old code did `new Date(date + 'T00:00:00')` — no Z, so parsed as *server-local* + // midnight — then setDate(+1) and .toISOString(). East of Greenwich that round-trip + // lands a day early, and since DTEND is exclusive the trip's last day was dropped. + // Only invisible in CI because containers default to TZ=UTC. + for (const tz of ['Europe/Berlin', 'Asia/Tokyo', 'Pacific/Kiritimati', 'America/New_York', 'UTC']) { + it(`TRIP-SVC-002b: DTEND is the day after the last day under TZ=${tz}`, () => { + process.env.TZ = tz; + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { + title: 'TZ Trip', + start_date: '2026-03-28', + end_date: '2026-03-30', + }); + + const { ics } = svc.exportICS(trip.id); + + expect(ics).toContain('DTSTART;VALUE=DATE:20260328'); + expect(ics).toContain('DTEND;VALUE=DATE:20260331'); + }); + } + + it('TRIP-SVC-002c: a per-day all-day summary event has the same exclusive DTEND', () => { + process.env.TZ = 'Asia/Tokyo'; + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Day Note Trip' }); + const day = createDay(testDb, trip.id, { date: '2026-03-30', day_number: 1 }); + createDayNote(testDb, day.id, trip.id, { text: 'Pack the bags' }); + + const { ics } = svc.exportICS(trip.id); + + expect(ics).toContain('DTSTART;VALUE=DATE:20260330'); + expect(ics).toContain('DTEND;VALUE=DATE:20260331'); + }); + }); + + it('TRIP-SVC-003: reservation with full datetime (includes T) → DTSTART without VALUE=DATE', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Paris Trip' }); + const reservation = createReservation(testDb, trip.id, { + title: 'Morning Flight', + type: 'flight', + }); + testDb + .prepare('UPDATE reservations SET reservation_time=? WHERE id=?') + .run('2025-06-02T09:00', reservation.id); + + const { ics } = svc.exportICS(trip.id); + + expect(ics).toContain('DTSTART:20250602T090000'); + expect(ics).not.toContain('DTSTART;VALUE=DATE'); + }); + + it('TRIP-SVC-004: reservation with date-only → DTSTART;VALUE=DATE', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Paris Trip' }); + const reservation = createReservation(testDb, trip.id, { + title: 'Hotel Check-in', + type: 'hotel', + }); + testDb + .prepare('UPDATE reservations SET reservation_time=? WHERE id=?') + .run('2025-06-02', reservation.id); + + const { ics } = svc.exportICS(trip.id); + + expect(ics).toContain('DTSTART;VALUE=DATE:20250602'); + }); + + it('TRIP-SVC-005: reservation metadata with flight info appears in DESCRIPTION', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Paris Trip' }); + const reservation = createReservation(testDb, trip.id, { + title: 'CDG to JFK', + type: 'flight', + }); + testDb + .prepare('UPDATE reservations SET reservation_time=?, metadata=? WHERE id=?') + .run( + '2025-06-02T09:00', + JSON.stringify({ + airline: 'Air Test', + flight_number: 'AT100', + departure_airport: 'CDG', + arrival_airport: 'JFK', + }), + reservation.id + ); + + const { ics } = svc.exportICS(trip.id); + + expect(ics).toContain('Airline: Air Test'); + expect(ics).toContain('Flight: AT100'); + }); + + it('TRIP-SVC-006: special characters in title are escaped', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Trip; First, Best' }); + + const { ics } = svc.exportICS(trip.id); + + expect(ics).toContain('Trip\\; First\\, Best'); + }); + + it('TRIP-SVC-007: throws NotFoundError for non-existent trip', () => { + expect(() => svc.exportICS(99999)).toThrow(); + }); + + it('TRIP-SVC-008: returns a filename derived from trip title', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'My Trip 2025' }); + + const { filename } = svc.exportICS(trip.id); + + expect(filename).toMatch(/My.Trip.2025\.ics/); + }); + + it('TRIP-SVC-009: reservation with end time includes DTEND', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Paris Trip' }); + const reservation = createReservation(testDb, trip.id, { + title: 'Afternoon Tour', + type: 'activity', + }); + testDb + .prepare('UPDATE reservations SET reservation_time=?, reservation_end_time=? WHERE id=?') + .run('2025-06-02T14:00', '2025-06-02T16:00', reservation.id); + + const { ics } = svc.exportICS(trip.id); + + expect(ics).toContain('DTEND:20250602T160000'); + }); + + it('TRIP-SVC-024: flight with endpoint times but no reservation_time is included', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Paris Trip' }); + const reservation = createReservation(testDb, trip.id, { + title: 'CDG → JFK', + type: 'flight', + }); + // Confirmed flights store times per endpoint, never as reservation_time. + testDb.prepare('UPDATE reservations SET reservation_time=NULL, reservation_end_time=NULL WHERE id=?').run(reservation.id); + const insertEp = testDb.prepare( + 'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' + ); + insertEp.run(reservation.id, 'from', 0, 'Paris CDG', 'CDG', 49.0, 2.5, 'Europe/Paris', '09:00', '2025-06-02'); + insertEp.run(reservation.id, 'to', 1, 'New York JFK', 'JFK', 40.6, -73.8, 'America/New_York', '12:00', '2025-06-02'); + + const { ics } = svc.exportICS(trip.id); + + expect(ics).toContain('SUMMARY:CDG → JFK'); + // Departure endpoint zone drives DTSTART, arrival zone drives DTEND, so the + // subscriber sees TREK's zones instead of their own (#1453). + expect(ics).toContain('DTSTART;TZID=Europe/Paris:20250602T090000'); + expect(ics).toContain('DTEND;TZID=America/New_York:20250602T120000'); + expect(ics).not.toContain('DTSTART:20250602T090000'); + // Each referenced zone gets a VTIMEZONE definition. + expect(ics).toContain('BEGIN:VTIMEZONE\r\nTZID:Europe/Paris'); + expect(ics).toContain('BEGIN:VTIMEZONE\r\nTZID:America/New_York'); + expect(ics).toContain('Route: CDG → JFK'); + }); + + it('TRIP-SVC-024b: an invalid endpoint timezone degrades to floating time instead of crashing the export', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Bad TZ Trip' }); + const reservation = createReservation(testDb, trip.id, { title: 'CDG → JFK', type: 'flight' }); + testDb.prepare('UPDATE reservations SET reservation_time=NULL, reservation_end_time=NULL WHERE id=?').run(reservation.id); + const insertEp = testDb.prepare( + 'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' + ); + // A stored/plugin-written timezone can be any string; it must never reach Intl. + // The bogus zone takes precedence over the coordinates (first.timezone || resolveZone). + insertEp.run(reservation.id, 'from', 0, 'Paris CDG', 'CDG', 49.0, 2.5, 'Not/AZone', '09:00', '2025-06-02'); + insertEp.run(reservation.id, 'to', 1, 'New York JFK', 'JFK', 40.6, -73.8, 'garbage', '12:00', '2025-06-02'); + + let ics = ''; + expect(() => { ics = svc.exportICS(trip.id).ics; }).not.toThrow(); + // Falls back to a floating local time (no TZID) and never emits a bogus VTIMEZONE. + expect(ics).toContain('DTSTART:20250602T090000'); + expect(ics).not.toContain('TZID=Not/AZone'); + expect(ics).not.toContain('garbage'); + }); + + it('TRIP-SVC-025: flight endpoint with no local_date is skipped (relative Day-N trips)', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Relative Trip' }); + const reservation = createReservation(testDb, trip.id, { + title: 'Timeless Flight', + type: 'flight', + }); + testDb.prepare('UPDATE reservations SET reservation_time=NULL WHERE id=?').run(reservation.id); + testDb.prepare( + 'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' + ).run(reservation.id, 'from', 0, 'Origin', 'AAA', 1.0, 1.0, null, '09:00', null); + + const { ics } = svc.exportICS(trip.id); + + expect(ics).not.toContain('SUMMARY:Timeless Flight'); + }); + + it('TRIP-SVC-026: timed assignment gets a TZID derived from the place coordinates', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Tokyo Trip' }); + const day = createDay(testDb, trip.id, { date: '2025-06-02' }); + // Tokyo coordinates → Asia/Tokyo via tz-lookup. + const place = createPlace(testDb, trip.id, { name: 'Senso-ji', lat: 35.7148, lng: 139.7967 }); + const assignment = createDayAssignment(testDb, day.id, place.id); + testDb + .prepare('UPDATE day_assignments SET assignment_time=? WHERE id=?') + .run('09:00', assignment.id); + + const { ics } = svc.exportICS(trip.id); + + expect(ics).toContain('DTSTART;TZID=Asia/Tokyo:20250602T090000'); + expect(ics).toContain('BEGIN:VTIMEZONE\r\nTZID:Asia/Tokyo'); + expect(ics).not.toContain('DTSTART:20250602T090000'); + }); +}); + +describe('folded quirk branches', () => { + it('TRIP-SVC-048: exportICS renders untimed/notes all-day summaries, multi-leg routes, endpoint routes and train/location fields', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Branchy' }); + const day = createDay(testDb, trip.id, { date: '2025-06-02' }); + const place = createPlace(testDb, trip.id, { name: 'Untimed Spot' }); + testDb.prepare("UPDATE places SET address = '1 Rue Test' WHERE id = ?").run(place.id); + const assignment = createDayAssignment(testDb, day.id, place.id); + testDb.prepare("UPDATE day_assignments SET notes = 'bring hat' WHERE id = ?").run(assignment.id); + createDayNote(testDb, day.id, trip.id, { text: 'timed note', time: '10:00' }); + createDayNote(testDb, day.id, trip.id, { text: 'plain note' }); + + // Multi-leg flight metadata → Route: A → B → C, plus train + notes + location. + const flight = createReservation(testDb, trip.id, { title: 'Legs', type: 'flight' }); + testDb.prepare('UPDATE reservations SET reservation_time=?, metadata=?, notes=?, location=?, confirmation_number=? WHERE id=?').run( + '2025-06-02T09:00', + JSON.stringify({ legs: [{ from: 'FRA', to: 'BER' }, { to: 'HND' }], train_number: 'ICE 100' }), + 'window seat', 'Gate 4', 'ABC123', flight.id, + ); + // Endpoint-derived route (no route metadata) with a date-only endpoint fallback. + const transport = createReservation(testDb, trip.id, { title: 'Ferry', type: 'transport' }); + testDb.prepare('UPDATE reservations SET reservation_time=NULL WHERE id=?').run(transport.id); + const insertEp = testDb.prepare( + 'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' + ); + insertEp.run(transport.id, 'from', 0, 'Pier A', null, 1.0, 1.0, null, null, '2025-06-03'); + insertEp.run(transport.id, 'to', 1, 'Pier B', 'PB', 1.1, 1.1, null, null, '2025-06-03'); + + // Unfold the RFC 5545 75-octet folding so substring assertions see whole lines. + const ics = svc.exportICS(trip.id).ics.replace(/\r\n /g, ''); + + expect(ics).toContain('SUMMARY:Day 1'); + expect(ics).toContain('• Untimed Spot (1 Rue Test) — bring hat'); + expect(ics).toContain('Notes:\\n10:00 — timed note\\n• plain note'); + expect(ics).toContain('Route: FRA → BER → HND'); + expect(ics).toContain('Train: ICE 100'); + expect(ics).toContain('Confirmation: ABC123'); + expect(ics).toContain('window seat'); + expect(ics).toContain('LOCATION:Gate 4'); + // Date-only endpoint → all-day DTSTART for the transport. + expect(ics).toContain('SUMMARY:Ferry'); + expect(ics).toContain('DTSTART;VALUE=DATE:20250603'); + }); +}); + + +describe('CalendarService wiring', () => { + it('CAL-001: the module registers the service, so injection cannot silently fail', () => { + expectRegisteredProvider(CalendarModule, CalendarService); + const exports = Reflect.getMetadata('exports', CalendarModule) as unknown[]; + expect(Array.isArray(exports)).toBe(true); + expect(exports).toEqual(expect.arrayContaining([CalendarService])); + }); +}); diff --git a/server/tests/unit/nest/trips.controller.test.ts b/server/tests/unit/nest/trips.controller.test.ts index 76abf4144..21336a4d4 100644 --- a/server/tests/unit/nest/trips.controller.test.ts +++ b/server/tests/unit/nest/trips.controller.test.ts @@ -18,6 +18,7 @@ const unsplashStub = { import { TripsController } from '../../../src/nest/trips/trips.controller'; import type { TripsService } from '../../../src/nest/trips/trips.service'; +import type { CalendarService } from '../../../src/nest/calendar/calendar.service'; import { NotFoundError, ValidationError } from '../../../src/nest/trips/trips.service'; import type { User } from '../../../src/types'; import { activeTripResponseSchema, tripCreateRequestSchema, tripTransferOwnershipRequestSchema } from '@trek/shared'; @@ -29,7 +30,10 @@ const req = { headers: {} } as Request; // wrapper keeps the historical construction sites positional. const writeAudit = vi.fn(); const audit = { writeAudit } as unknown as AuditService; -const tc = (s: TripsService) => new TripsController(s, audit, new RuntimeEnvService(), unsplashStub); +// calendar is optional so the call sites that never reach the ICS route stay as +// they were; the two that do pass a stub. +const tc = (s: TripsService, calendar?: Partial) => + new TripsController(s, audit, new RuntimeEnvService(), unsplashStub, (calendar ?? {}) as CalendarService); function svc(o: Partial = {}): TripsService { return { @@ -403,18 +407,18 @@ describe('TripsController (parity with the legacy /api/trips route)', () => { it('404 without access, else sends the calendar with headers', () => { expect(thrown(() => tc(svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) })).exportIcs(user, '9', makeRes()))).toEqual({ status: 404, body: { error: 'Trip not found' } }); const res = { setHeader: vi.fn(), send: vi.fn() }; - const s = svc({ exportICS: vi.fn().mockReturnValue({ ics: 'BEGIN:VCALENDAR', filename: 'trip.ics' }) } as Partial); - tc(s).exportIcs(user, '9', res as never); + const cal = { exportICS: vi.fn().mockReturnValue({ ics: 'BEGIN:VCALENDAR', filename: 'trip.ics' }) }; + tc(svc(), cal).exportIcs(user, '9', res as never); expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'text/calendar; charset=utf-8'); expect(res.setHeader).toHaveBeenCalledWith('Content-Disposition', 'attachment; filename="trip.ics"'); expect(res.send).toHaveBeenCalledWith('BEGIN:VCALENDAR'); }); it('maps a NotFoundError from the export to 404 and re-throws others', () => { - const nf = svc({ exportICS: vi.fn().mockImplementation(() => { throw new NotFoundError('gone'); }) } as Partial); - expect(thrown(() => tc(nf).exportIcs(user, '9', makeRes()))).toEqual({ status: 404, body: { error: 'gone' } }); - const other = svc({ exportICS: vi.fn().mockImplementation(() => { throw new Error('boom'); }) } as Partial); - expect(() => tc(other).exportIcs(user, '9', makeRes())).toThrow('boom'); + const nf = { exportICS: vi.fn().mockImplementation(() => { throw new NotFoundError('gone'); }) }; + expect(thrown(() => tc(svc(), nf).exportIcs(user, '9', makeRes()))).toEqual({ status: 404, body: { error: 'gone' } }); + const other = { exportICS: vi.fn().mockImplementation(() => { throw new Error('boom'); }) }; + expect(() => tc(svc(), other).exportIcs(user, '9', makeRes())).toThrow('boom'); }); }); diff --git a/server/tests/unit/nest/trips.service.test.ts b/server/tests/unit/nest/trips.service.test.ts index 3c0be7f7a..43342bdcb 100644 --- a/server/tests/unit/nest/trips.service.test.ts +++ b/server/tests/unit/nest/trips.service.test.ts @@ -330,262 +330,6 @@ describe('generateDays', () => { }); }); -describe('exportICS', () => { - it('TRIP-SVC-001: returns VCALENDAR wrapper', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id, { - title: 'My Vacation', - start_date: '2025-06-01', - end_date: '2025-06-07', - }); - - const { ics } = svc.exportICS(trip.id); - - expect(ics).toContain('BEGIN:VCALENDAR'); - expect(ics).toContain('END:VCALENDAR'); - }); - - it('TRIP-SVC-002: trip with start_date + end_date includes all-day VEVENT', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id, { - title: 'Summer Holiday', - start_date: '2025-06-01', - end_date: '2025-06-07', - }); - - const { ics } = svc.exportICS(trip.id); - - expect(ics).toContain('DTSTART;VALUE=DATE:20250601'); - // DTEND is exclusive — the day *after* the last day, or the trip loses a day. - expect(ics).toContain('DTEND;VALUE=DATE:20250608'); - expect(ics).toContain('SUMMARY:Summer Holiday'); - }); - - describe('#1453 all-day DTEND is timezone-independent', () => { - const originalTz = process.env.TZ; - - afterAll(() => { - process.env.TZ = originalTz; - }); - - // The old code did `new Date(date + 'T00:00:00')` — no Z, so parsed as *server-local* - // midnight — then setDate(+1) and .toISOString(). East of Greenwich that round-trip - // lands a day early, and since DTEND is exclusive the trip's last day was dropped. - // Only invisible in CI because containers default to TZ=UTC. - for (const tz of ['Europe/Berlin', 'Asia/Tokyo', 'Pacific/Kiritimati', 'America/New_York', 'UTC']) { - it(`TRIP-SVC-002b: DTEND is the day after the last day under TZ=${tz}`, () => { - process.env.TZ = tz; - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id, { - title: 'TZ Trip', - start_date: '2026-03-28', - end_date: '2026-03-30', - }); - - const { ics } = svc.exportICS(trip.id); - - expect(ics).toContain('DTSTART;VALUE=DATE:20260328'); - expect(ics).toContain('DTEND;VALUE=DATE:20260331'); - }); - } - - it('TRIP-SVC-002c: a per-day all-day summary event has the same exclusive DTEND', () => { - process.env.TZ = 'Asia/Tokyo'; - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id, { title: 'Day Note Trip' }); - const day = createDay(testDb, trip.id, { date: '2026-03-30', day_number: 1 }); - createDayNote(testDb, day.id, trip.id, { text: 'Pack the bags' }); - - const { ics } = svc.exportICS(trip.id); - - expect(ics).toContain('DTSTART;VALUE=DATE:20260330'); - expect(ics).toContain('DTEND;VALUE=DATE:20260331'); - }); - }); - - it('TRIP-SVC-003: reservation with full datetime (includes T) → DTSTART without VALUE=DATE', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id, { title: 'Paris Trip' }); - const reservation = createReservation(testDb, trip.id, { - title: 'Morning Flight', - type: 'flight', - }); - testDb - .prepare('UPDATE reservations SET reservation_time=? WHERE id=?') - .run('2025-06-02T09:00', reservation.id); - - const { ics } = svc.exportICS(trip.id); - - expect(ics).toContain('DTSTART:20250602T090000'); - expect(ics).not.toContain('DTSTART;VALUE=DATE'); - }); - - it('TRIP-SVC-004: reservation with date-only → DTSTART;VALUE=DATE', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id, { title: 'Paris Trip' }); - const reservation = createReservation(testDb, trip.id, { - title: 'Hotel Check-in', - type: 'hotel', - }); - testDb - .prepare('UPDATE reservations SET reservation_time=? WHERE id=?') - .run('2025-06-02', reservation.id); - - const { ics } = svc.exportICS(trip.id); - - expect(ics).toContain('DTSTART;VALUE=DATE:20250602'); - }); - - it('TRIP-SVC-005: reservation metadata with flight info appears in DESCRIPTION', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id, { title: 'Paris Trip' }); - const reservation = createReservation(testDb, trip.id, { - title: 'CDG to JFK', - type: 'flight', - }); - testDb - .prepare('UPDATE reservations SET reservation_time=?, metadata=? WHERE id=?') - .run( - '2025-06-02T09:00', - JSON.stringify({ - airline: 'Air Test', - flight_number: 'AT100', - departure_airport: 'CDG', - arrival_airport: 'JFK', - }), - reservation.id - ); - - const { ics } = svc.exportICS(trip.id); - - expect(ics).toContain('Airline: Air Test'); - expect(ics).toContain('Flight: AT100'); - }); - - it('TRIP-SVC-006: special characters in title are escaped', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id, { title: 'Trip; First, Best' }); - - const { ics } = svc.exportICS(trip.id); - - expect(ics).toContain('Trip\\; First\\, Best'); - }); - - it('TRIP-SVC-007: throws NotFoundError for non-existent trip', () => { - expect(() => svc.exportICS(99999)).toThrow(); - }); - - it('TRIP-SVC-008: returns a filename derived from trip title', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id, { title: 'My Trip 2025' }); - - const { filename } = svc.exportICS(trip.id); - - expect(filename).toMatch(/My.Trip.2025\.ics/); - }); - - it('TRIP-SVC-009: reservation with end time includes DTEND', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id, { title: 'Paris Trip' }); - const reservation = createReservation(testDb, trip.id, { - title: 'Afternoon Tour', - type: 'activity', - }); - testDb - .prepare('UPDATE reservations SET reservation_time=?, reservation_end_time=? WHERE id=?') - .run('2025-06-02T14:00', '2025-06-02T16:00', reservation.id); - - const { ics } = svc.exportICS(trip.id); - - expect(ics).toContain('DTEND:20250602T160000'); - }); - - it('TRIP-SVC-024: flight with endpoint times but no reservation_time is included', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id, { title: 'Paris Trip' }); - const reservation = createReservation(testDb, trip.id, { - title: 'CDG → JFK', - type: 'flight', - }); - // Confirmed flights store times per endpoint, never as reservation_time. - testDb.prepare('UPDATE reservations SET reservation_time=NULL, reservation_end_time=NULL WHERE id=?').run(reservation.id); - const insertEp = testDb.prepare( - 'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' - ); - insertEp.run(reservation.id, 'from', 0, 'Paris CDG', 'CDG', 49.0, 2.5, 'Europe/Paris', '09:00', '2025-06-02'); - insertEp.run(reservation.id, 'to', 1, 'New York JFK', 'JFK', 40.6, -73.8, 'America/New_York', '12:00', '2025-06-02'); - - const { ics } = svc.exportICS(trip.id); - - expect(ics).toContain('SUMMARY:CDG → JFK'); - // Departure endpoint zone drives DTSTART, arrival zone drives DTEND, so the - // subscriber sees TREK's zones instead of their own (#1453). - expect(ics).toContain('DTSTART;TZID=Europe/Paris:20250602T090000'); - expect(ics).toContain('DTEND;TZID=America/New_York:20250602T120000'); - expect(ics).not.toContain('DTSTART:20250602T090000'); - // Each referenced zone gets a VTIMEZONE definition. - expect(ics).toContain('BEGIN:VTIMEZONE\r\nTZID:Europe/Paris'); - expect(ics).toContain('BEGIN:VTIMEZONE\r\nTZID:America/New_York'); - expect(ics).toContain('Route: CDG → JFK'); - }); - - it('TRIP-SVC-024b: an invalid endpoint timezone degrades to floating time instead of crashing the export', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id, { title: 'Bad TZ Trip' }); - const reservation = createReservation(testDb, trip.id, { title: 'CDG → JFK', type: 'flight' }); - testDb.prepare('UPDATE reservations SET reservation_time=NULL, reservation_end_time=NULL WHERE id=?').run(reservation.id); - const insertEp = testDb.prepare( - 'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' - ); - // A stored/plugin-written timezone can be any string; it must never reach Intl. - // The bogus zone takes precedence over the coordinates (first.timezone || resolveZone). - insertEp.run(reservation.id, 'from', 0, 'Paris CDG', 'CDG', 49.0, 2.5, 'Not/AZone', '09:00', '2025-06-02'); - insertEp.run(reservation.id, 'to', 1, 'New York JFK', 'JFK', 40.6, -73.8, 'garbage', '12:00', '2025-06-02'); - - let ics = ''; - expect(() => { ics = svc.exportICS(trip.id).ics; }).not.toThrow(); - // Falls back to a floating local time (no TZID) and never emits a bogus VTIMEZONE. - expect(ics).toContain('DTSTART:20250602T090000'); - expect(ics).not.toContain('TZID=Not/AZone'); - expect(ics).not.toContain('garbage'); - }); - - it('TRIP-SVC-025: flight endpoint with no local_date is skipped (relative Day-N trips)', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id, { title: 'Relative Trip' }); - const reservation = createReservation(testDb, trip.id, { - title: 'Timeless Flight', - type: 'flight', - }); - testDb.prepare('UPDATE reservations SET reservation_time=NULL WHERE id=?').run(reservation.id); - testDb.prepare( - 'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' - ).run(reservation.id, 'from', 0, 'Origin', 'AAA', 1.0, 1.0, null, '09:00', null); - - const { ics } = svc.exportICS(trip.id); - - expect(ics).not.toContain('SUMMARY:Timeless Flight'); - }); - - it('TRIP-SVC-026: timed assignment gets a TZID derived from the place coordinates', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id, { title: 'Tokyo Trip' }); - const day = createDay(testDb, trip.id, { date: '2025-06-02' }); - // Tokyo coordinates → Asia/Tokyo via tz-lookup. - const place = createPlace(testDb, trip.id, { name: 'Senso-ji', lat: 35.7148, lng: 139.7967 }); - const assignment = createDayAssignment(testDb, day.id, place.id); - testDb - .prepare('UPDATE day_assignments SET assignment_time=? WHERE id=?') - .run('09:00', assignment.id); - - const { ics } = svc.exportICS(trip.id); - - expect(ics).toContain('DTSTART;TZID=Asia/Tokyo:20250602T090000'); - expect(ics).toContain('BEGIN:VTIMEZONE\r\nTZID:Asia/Tokyo'); - expect(ics).not.toContain('DTSTART:20250602T090000'); - }); -}); - // ── deleteOldCover — path containment ────────────────────────────────────────── describe('deleteOldCover', () => { @@ -1068,49 +812,6 @@ describe('folded quirk branches', () => { expect(() => svc.updateTrip(trip.id, owner.id, { start_date: '2025-06-10', end_date: '2025-06-01' }, 'user')).toThrow('End date must be after start date'); }); - it('TRIP-SVC-048: exportICS renders untimed/notes all-day summaries, multi-leg routes, endpoint routes and train/location fields', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id, { title: 'Branchy' }); - const day = createDay(testDb, trip.id, { date: '2025-06-02' }); - const place = createPlace(testDb, trip.id, { name: 'Untimed Spot' }); - testDb.prepare("UPDATE places SET address = '1 Rue Test' WHERE id = ?").run(place.id); - const assignment = createDayAssignment(testDb, day.id, place.id); - testDb.prepare("UPDATE day_assignments SET notes = 'bring hat' WHERE id = ?").run(assignment.id); - createDayNote(testDb, day.id, trip.id, { text: 'timed note', time: '10:00' }); - createDayNote(testDb, day.id, trip.id, { text: 'plain note' }); - - // Multi-leg flight metadata → Route: A → B → C, plus train + notes + location. - const flight = createReservation(testDb, trip.id, { title: 'Legs', type: 'flight' }); - testDb.prepare('UPDATE reservations SET reservation_time=?, metadata=?, notes=?, location=?, confirmation_number=? WHERE id=?').run( - '2025-06-02T09:00', - JSON.stringify({ legs: [{ from: 'FRA', to: 'BER' }, { to: 'HND' }], train_number: 'ICE 100' }), - 'window seat', 'Gate 4', 'ABC123', flight.id, - ); - // Endpoint-derived route (no route metadata) with a date-only endpoint fallback. - const transport = createReservation(testDb, trip.id, { title: 'Ferry', type: 'transport' }); - testDb.prepare('UPDATE reservations SET reservation_time=NULL WHERE id=?').run(transport.id); - const insertEp = testDb.prepare( - 'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' - ); - insertEp.run(transport.id, 'from', 0, 'Pier A', null, 1.0, 1.0, null, null, '2025-06-03'); - insertEp.run(transport.id, 'to', 1, 'Pier B', 'PB', 1.1, 1.1, null, null, '2025-06-03'); - - // Unfold the RFC 5545 75-octet folding so substring assertions see whole lines. - const ics = svc.exportICS(trip.id).ics.replace(/\r\n /g, ''); - - expect(ics).toContain('SUMMARY:Day 1'); - expect(ics).toContain('• Untimed Spot (1 Rue Test) — bring hat'); - expect(ics).toContain('Notes:\\n10:00 — timed note\\n• plain note'); - expect(ics).toContain('Route: FRA → BER → HND'); - expect(ics).toContain('Train: ICE 100'); - expect(ics).toContain('Confirmation: ABC123'); - expect(ics).toContain('window seat'); - expect(ics).toContain('LOCATION:Gate 4'); - // Date-only endpoint → all-day DTSTART for the transport. - expect(ics).toContain('SUMMARY:Ferry'); - expect(ics).toContain('DTSTART;VALUE=DATE:20250603'); - }); - it('TRIP-SVC-049: addMember inserts the membership and reports the trip title; removeMember deletes it', () => { const { user: owner } = createUser(testDb); const { user: invitee } = createUser(testDb); diff --git a/server/vitest.config.ts b/server/vitest.config.ts index 0ec9751f5..cc4068213 100644 --- a/server/vitest.config.ts +++ b/server/vitest.config.ts @@ -72,6 +72,7 @@ export default defineConfig({ 'src/nest/backup/**/*.ts': { statements: 96, branches: 92, functions: 99, lines: 98 }, 'src/nest/booking-import/**/*.ts': { statements: 48, branches: 28, functions: 56, lines: 52 }, 'src/nest/budget/**/*.ts': { statements: 91, branches: 77, functions: 97, lines: 95 }, + 'src/nest/calendar/**/*.ts': { statements: 92, branches: 82, functions: 91, lines: 94 }, 'src/nest/categories/**/*.ts': { statements: 99, branches: 80, functions: 99, lines: 99 }, 'src/nest/collab/**/*.ts': { statements: 92, branches: 85, functions: 94, lines: 97 }, 'src/nest/collections/**/*.ts': { statements: 83, branches: 70, functions: 91, lines: 92 },