mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
Move the calendar export off the trip aggregate
exportICS and its four module helpers were 330 lines of RFC 5545 inside TripsService, and FeedsService had to import the whole trip aggregate to get an ICS string out of it. They move to a calendar domain unchanged - same statements, same escaping, same folding, same VTIMEZONE fallback - so the emitted bytes are identical on both the download and the feed. The 21 unit cases move with the code and keep every assertion, which is what makes that claim checkable rather than asserted. FeedsModule now imports CalendarModule instead of TripsModule, and its e2e container drops from the trip aggregate to two small modules. NotFoundError/ValidationError move to common/ so a second domain can raise them without importing the first one for its error types; trips re-exports them, so the nine files that import them from there are untouched. Fixes a harness that tsc cannot see on the way past: mcp-test-controllers built TripsService with 12 arguments for 14 parameters, leaving unsplash and userCleanup undefined.
This commit is contained in:
@@ -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 {}
|
||||
@@ -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<string, boolean>();
|
||||
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<string, string>();
|
||||
|
||||
// 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` };
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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<string, boolean>();
|
||||
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<string, string>();
|
||||
|
||||
// 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 };
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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]));
|
||||
});
|
||||
});
|
||||
@@ -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<CalendarService>) =>
|
||||
new TripsController(s, audit, new RuntimeEnvService(), unsplashStub, (calendar ?? {}) as CalendarService);
|
||||
|
||||
function svc(o: Partial<TripsService> = {}): 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<TripsService>);
|
||||
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<TripsService>);
|
||||
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<TripsService>);
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user