diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index bc77e7304..75befcd4e 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -4136,6 +4136,24 @@ function runMigrations(db: Database.Database): void { console.warn('[migrations] Non-fatal migration step failed:', err); } }, + + // Reservations an automated ingest parked for review are 'staged' and stay + // out of the two anonymous exports (ICS feed, shared trip) until a person + // confirms them. Nothing writes 'staged' yet; this is the gate the mail + // ingest writes through. + // + // 'live' as the default is what keeps this from being a breaking change: + // SQLite fills every existing row on the ALTER, and no current writer names + // the column, so every booking that is visible today stays visible. + // Appended LAST, the array is index-addressed against schema_version. + () => { + const hasColumn = db + .prepare("SELECT 1 FROM pragma_table_info('reservations') WHERE name = 'ingest_state'") + .get(); + if (!hasColumn) { + db.exec("ALTER TABLE reservations ADD COLUMN ingest_state TEXT NOT NULL DEFAULT 'live'"); + } + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/nest/calendar/calendar.service.ts b/server/src/nest/calendar/calendar.service.ts index 7288138e9..fcdf259ef 100644 --- a/server/src/nest/calendar/calendar.service.ts +++ b/server/src/nest/calendar/calendar.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { DatabaseService } from '../database/database.service'; import { ReservationsService } from '../reservations/reservations.service'; +import { publicReservationSql, publicStaySql } from '../reservations/reservation-visibility'; import { addDays } from '../days/days.service'; import { resolveTimeZone } from '../common/timezoneService'; import { NotFoundError } from '../common/domain-errors'; @@ -163,7 +164,7 @@ export class CalendarService { LEFT JOIN days ed ON a.end_day_id = ed.id LEFT JOIN days rd ON r.day_id = rd.id LEFT JOIN days red ON r.end_day_id = red.id - WHERE r.trip_id = ?`, + WHERE r.trip_id = ? AND ${publicReservationSql('r')}`, ) .all(tripId) as any[]; @@ -569,13 +570,13 @@ export class CalendarService { sd.date AS start_date, ed.date AS end_date, p.name AS place_name, p.address AS place_address, p.lat AS place_lat, p.lng AS place_lng, (SELECT r.title FROM reservations r - WHERE r.accommodation_id = a.id + WHERE r.accommodation_id = a.id AND ${publicReservationSql('r')} ORDER BY r.id ASC LIMIT 1) AS reservation_title FROM day_accommodations a LEFT JOIN days sd ON a.start_day_id = sd.id LEFT JOIN days ed ON a.end_day_id = ed.id LEFT JOIN places p ON a.place_id = p.id - WHERE a.trip_id = ? + WHERE a.trip_id = ? AND ${publicStaySql('a')} ORDER BY a.id ASC `).all(tripId) as any[]; diff --git a/server/src/nest/reservations/reservation-visibility.ts b/server/src/nest/reservations/reservation-visibility.ts new file mode 100644 index 000000000..7853a2686 --- /dev/null +++ b/server/src/nest/reservations/reservation-visibility.ts @@ -0,0 +1,34 @@ +/** + * Which reservation rows a viewer without a TREK session may see. + * + * `ingest_state` is 'live' for everything a person put on a trip, and 'staged' + * for a row an automated ingest parked for review. Only the two anonymous + * exports filter on it. The authenticated lists deliberately do not: the + * staging inbox has to see its own rows, or nobody can confirm them. + * + * These are SQL fragments rather than a service. calendar/ and share/ are + * separate modules and neither should have to import the other for a WHERE + * clause. They take the table alias because share.service.ts queries + * `reservations` without one while calendar.service.ts uses `r`. + * + * The COALESCE guards a NULL in the column, not a missing column: a table + * without `ingest_state` fails the query outright rather than returning NULL. + * The migration adds the column NOT NULL DEFAULT 'live', so a NULL should not + * occur; the COALESCE costs nothing and keeps a row that predates a botched + * ALTER visible rather than silently dropping it out of a live calendar feed. + */ +export const publicReservationSql = (alias = 'r'): string => `COALESCE(${alias}.ingest_state, 'live') <> 'staged'`; + +/** + * A stay is public when nothing points at it (somebody added it by hand), or + * when a live booking does. Both halves matter: a plain EXISTS would drop every + * hand-added hotel out of trips that are shared today. + * + * `accommodation_id` is a TEXT column that reads back as "14.0", hence the + * CAST. Same coercion as reservations.service.ts. + */ +export const publicStaySql = (alias = 'a'): string => `( + NOT EXISTS (SELECT 1 FROM reservations vr WHERE CAST(vr.accommodation_id AS INTEGER) = ${alias}.id) + OR EXISTS (SELECT 1 FROM reservations vr WHERE CAST(vr.accommodation_id AS INTEGER) = ${alias}.id + AND ${publicReservationSql('vr')}) + )`; diff --git a/server/src/nest/share/share.service.ts b/server/src/nest/share/share.service.ts index 33089a1e8..5a266ec6f 100644 --- a/server/src/nest/share/share.service.ts +++ b/server/src/nest/share/share.service.ts @@ -5,6 +5,7 @@ import type { TripAccess } from '../database/database.service'; import { PermissionsService } from '../permissions/permissions.service'; import { QueryHelpersService } from '../query-helpers/query-helpers.service'; import { PlacePhotoCacheService } from '../place-photos/place-photo-cache.service'; +import { publicReservationSql, publicStaySql } from '../reservations/reservation-visibility'; import { SettingsService } from '../settings/settings.service'; import type { User } from '../../types'; @@ -241,13 +242,18 @@ export class ShareService { if (!posMap.has(dp.reservation_id)) posMap.set(dp.reservation_id, {}); posMap.get(dp.reservation_id)![dp.day_id] = dp.position; } - reservations = this.dbs.all('SELECT * FROM reservations WHERE trip_id = ? ORDER BY reservation_time ASC', tripId) + // The alias is not cosmetic: the visibility predicate qualifies its column, + // and this query had no alias to qualify against. + reservations = this.dbs.all( + `SELECT r.* FROM reservations r + WHERE r.trip_id = ? AND ${publicReservationSql('r')} + ORDER BY r.reservation_time ASC`, tripId) .map((r) => ({ ...r, day_positions: posMap.get(r.id) ?? null })); accommodations = this.dbs.all(` SELECT a.*, p.name as place_name, p.address as place_address, p.lat as place_lat, p.lng as place_lng FROM day_accommodations a JOIN places p ON a.place_id = p.id - WHERE a.trip_id = ? + WHERE a.trip_id = ? AND ${publicStaySql('a')} `, tripId); } diff --git a/server/src/nest/trips/trips.service.ts b/server/src/nest/trips/trips.service.ts index be1f14b1e..c8f615fe8 100644 --- a/server/src/nest/trips/trips.service.ts +++ b/server/src/nest/trips/trips.service.ts @@ -625,8 +625,8 @@ export class TripsService { const reservationMap = new Map(); const insertReservation = this.db.prepare(` INSERT INTO reservations (trip_id, day_id, end_day_id, place_id, assignment_id, accommodation_id, title, reservation_time, reservation_end_time, - location, confirmation_number, notes, url, status, type, metadata, day_plan_position, needs_review) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + location, confirmation_number, notes, url, status, type, metadata, day_plan_position, needs_review, ingest_state) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); for (const r of oldReservations) { const rr = insertReservation.run(newTripId, @@ -641,7 +641,10 @@ export class TripsService { r.accommodation_id != null ? (accomMap.get(Number(r.accommodation_id)) ?? null) : null, r.title, r.reservation_time, r.reservation_end_time, r.location, r.confirmation_number, r.notes, r.url, r.status, r.type, - r.metadata, r.day_plan_position, r.needs_review ?? 0); + // ingest_state travels with the copy: a staged booking must not turn + // 'live' just because the trip was duplicated, or it lands in the + // duplicate's public feed. + r.metadata, r.day_plan_position, r.needs_review ?? 0, r.ingest_state ?? 'live'); reservationMap.set(r.id, rr.lastInsertRowid); } diff --git a/server/tests/e2e/calendar-feed-visibility.e2e.test.ts b/server/tests/e2e/calendar-feed-visibility.e2e.test.ts new file mode 100644 index 000000000..184491759 --- /dev/null +++ b/server/tests/e2e/calendar-feed-visibility.e2e.test.ts @@ -0,0 +1,88 @@ +/** + * Calendar-feed visibility e2e — proves the staged-booking gate holds on the + * one path that needs no session at all: the public /api/feed/trip/:token.ics. + * + * tests/e2e/feeds.e2e.test.ts cannot cover this. It mocks buildTripCalendar and + * never creates a reservations table, so it owns the calendar parts and the SQL + * filter is invisible to it. This file runs the REAL CalendarService against a + * real migrated SQLite instead, and asserts on the bytes a calendar client + * would receive. + */ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import request from 'supertest'; +import type { Server } from 'http'; +import { Test } from '@nestjs/testing'; + +const { db } = vi.hoisted(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const Database = require('better-sqlite3'); + const tmp = new Database(':memory:'); + tmp.exec('PRAGMA journal_mode = WAL'); + return { db: tmp }; +}); + +vi.mock('../../src/db/database', () => ({ + db, + canAccessTrip: (tripId: number | string, userId: number) => + db.prepare('SELECT id, user_id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId), + isOwner: () => true, + getPlaceWithTags: () => null, + closeDb: () => {}, + reinitialize: () => {}, +})); + +import { createTables } from '../../src/db/schema'; +import { runMigrations } from '../../src/db/migrations'; +import { DatabaseModule } from '../../src/nest/database/database.module'; +import { FeedsModule } from '../../src/nest/feeds/feeds.module'; +import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; + +describe('Calendar feed visibility e2e (real CalendarService over temp SQLite)', () => { + let server: Server; + let app: Awaited>; + let feedToken: string; + + async function build() { + const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, FeedsModule] }).compile(); + const nest = moduleRef.createNestApplication(); + nest.useGlobalFilters(new TrekExceptionFilter()); + await nest.init(); + return nest; + } + + beforeAll(async () => { + createTables(db); + runMigrations(db); + db.prepare( + "INSERT INTO users (id, username, email, password_hash, role) VALUES (1, 'e2e-user', 'e2e@example.test', 'x', 'user')", + ).run(); + feedToken = 'feed-token-visibility'; + db.prepare( + "INSERT INTO trips (id, user_id, title, start_date, end_date, feed_token) VALUES (1, 1, 'Kyoto', '2026-09-01', '2026-09-05', ?)", + ).run(feedToken); + db.prepare("INSERT INTO days (id, trip_id, day_number, date) VALUES (1, 1, 1, '2026-09-01')").run(); + db.prepare(`INSERT INTO reservations (trip_id, day_id, title, type, status, reservation_time, confirmation_number, ingest_state) + VALUES (1, 1, 'Parked Flight', 'flight', 'confirmed', '2026-09-01T08:00', 'SECRET1', 'staged')`).run(); + db.prepare(`INSERT INTO reservations (trip_id, day_id, title, type, status, reservation_time, confirmation_number) + VALUES (1, 1, 'Booked Flight', 'flight', 'confirmed', '2026-09-01T12:00', 'OPEN1')`).run(); + + app = await build(); + server = app.getHttpServer(); + }); + + afterAll(async () => { + await app.close(); + }); + + it('the public feed serves the live booking and neither the staged one nor its confirmation number', async () => { + const res = await request(server).get(`/api/feed/trip/${feedToken}.ics`); + + expect(res.status).toBe(200); + expect(res.headers['content-type']).toContain('text/calendar'); + expect(res.text).toContain('SUMMARY:Booked Flight'); + expect(res.text).not.toContain('Parked Flight'); + // The number rides in the DESCRIPTION, so search the whole document. + expect(res.text).not.toContain('SECRET1'); + expect(res.text).toContain('OPEN1'); + }); +}); diff --git a/server/tests/unit/db/reservation-ingest-state-migration.test.ts b/server/tests/unit/db/reservation-ingest-state-migration.test.ts new file mode 100644 index 000000000..c69f85b97 --- /dev/null +++ b/server/tests/unit/db/reservation-ingest-state-migration.test.ts @@ -0,0 +1,56 @@ +/** + * Boot migration: reservations.ingest_state. + * + * The column gates the two anonymous exports (ICS feed, shared trip) so an + * automated ingest can park a booking for review without publishing it. Every + * row that exists before the ALTER has to come out 'live', or the migration + * would empty a calendar subscription that works today. + */ +import { describe, it, expect } from 'vitest'; +import Database from 'better-sqlite3'; +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; + +function dbWithReservations(): Database.Database { + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA busy_timeout = 5000'); + db.exec('PRAGMA foreign_keys = ON'); + createTables(db); + db.prepare("INSERT INTO users (id, username, email, password_hash) VALUES (1, 'u', 'u@example.test', 'x')").run(); + db.prepare("INSERT INTO trips (id, user_id, title) VALUES (1, 1, 'T')").run(); + db.prepare("INSERT INTO reservations (id, trip_id, title, type) VALUES (1, 1, 'Old Flight', 'flight')").run(); + return db; +} + +describe('reservations ingest_state migration', () => { + it('MIGRATE-INGEST-001: every pre-existing row comes out live', () => { + const db = dbWithReservations(); + try { + runMigrations(db); + const row = db.prepare('SELECT ingest_state FROM reservations WHERE id = 1').get() as { ingest_state: string }; + expect(row.ingest_state).toBe('live'); + } finally { + db.close(); + } + }); + + it('MIGRATE-INGEST-002: running the migration twice is a no-op', () => { + const db = dbWithReservations(); + try { + runMigrations(db); + // Rewind the version so the step replays against a table that already has + // the column. Without the pragma_table_info guard the ALTER throws and + // runMigrations exits the process. + const version = (db.prepare('SELECT version FROM schema_version').get() as { version: number }).version; + db.prepare('UPDATE schema_version SET version = ?').run(version - 1); + runMigrations(db); + + const cols = db.prepare("SELECT name FROM pragma_table_info('reservations') WHERE name = 'ingest_state'").all(); + expect(cols).toHaveLength(1); + expect((db.prepare('SELECT version FROM schema_version').get() as { version: number }).version).toBe(version); + } finally { + db.close(); + } + }); +}); diff --git a/server/tests/unit/nest/calendar.service.test.ts b/server/tests/unit/nest/calendar.service.test.ts index 3421abfef..d77d5d499 100644 --- a/server/tests/unit/nest/calendar.service.test.ts +++ b/server/tests/unit/nest/calendar.service.test.ts @@ -1243,6 +1243,98 @@ describe('car rentals', () => { }); }); +describe('staged bookings', () => { + /** A stay plus, optionally, the bookings that point at it. */ + const stayWith = (tripId: number, states: Array<{ state: string; title: string }>) => { + const place = createPlace(testDb, tripId, { name: 'Hotel Bellevue', lat: 48.8566, lng: 2.3522 }); + const startDay = createDay(testDb, tripId, { date: '2026-09-01' }); + const endDay = createDay(testDb, tripId, { date: '2026-09-04' }); + const stayId = testDb.prepare(` + INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out) + VALUES (?, ?, ?, ?, '15:00', '11:00') + `).run(tripId, place.id, startDay.id, endDay.id).lastInsertRowid as number; + + for (const s of states) { + testDb.prepare(` + INSERT INTO reservations (trip_id, day_id, title, reservation_time, status, type, accommodation_id, ingest_state) + VALUES (?, ?, ?, '2026-09-01T15:00', 'confirmed', 'hotel', ?, ?) + `).run(tripId, startDay.id, s.title, String(stayId), s.state); + } + return { stayId, placeId: place.id }; + }; + + it('CAL-055: a staged booking is left out of the calendar, confirmation number included', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Kyoto' }); + const day = createDay(testDb, trip.id, { date: '2026-09-01' }); + const staged = createReservation(testDb, trip.id, { title: 'Parked Flight', type: 'flight', day_id: day.id }); + testDb.prepare(`UPDATE reservations SET ingest_state='staged', reservation_time='2026-09-01T08:00', + confirmation_number='ABC123' WHERE id=?`).run(staged.id); + const live = createReservation(testDb, trip.id, { title: 'Booked Flight', type: 'flight', day_id: day.id }); + testDb.prepare("UPDATE reservations SET reservation_time='2026-09-01T12:00' WHERE id=?").run(live.id); + + const { ics } = svc.exportICS(trip.id); + + expect(ics).toContain('SUMMARY:Booked Flight'); + expect(ics).not.toContain('Parked Flight'); + // The number rides in the DESCRIPTION, not the SUMMARY, so search the whole document. + expect(ics).not.toContain('ABC123'); + }); + + it('CAL-056: a booking created before the column existed still exports', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Lisbon' }); + const day = createDay(testDb, trip.id, { date: '2026-09-02' }); + // No ingest_state named on the insert: exactly what every writer does today, + // and what the ALTER backfilled onto every pre-existing row. + const old = createReservation(testDb, trip.id, { title: 'Legacy Booking', type: 'flight', day_id: day.id }); + testDb.prepare("UPDATE reservations SET reservation_time='2026-09-02T09:00' WHERE id=?").run(old.id); + + const { ics } = svc.exportICS(trip.id); + + expect(ics).toContain('SUMMARY:Legacy Booking'); + }); + + it('CAL-057: an accommodation whose only booking is staged emits no stay, check-in or check-out', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Osaka' }); + stayWith(trip.id, [{ state: 'staged', title: 'Parked Hotel' }]); + + const { ics } = svc.exportICS(trip.id); + + expect(ics).not.toContain('Parked Hotel'); + expect(ics).not.toMatch(/UID:trek-checkin-\d+@trek/); + expect(ics).not.toMatch(/UID:trek-checkout-\d+@trek/); + }); + + it('CAL-058: an accommodation with no linked booking at all still emits its stay', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Nara' }); + // The regression this pins: a plain EXISTS(live) instead of + // NOT EXISTS(any) OR EXISTS(live) would drop every hand-added hotel out of + // trips that are shared today. + stayWith(trip.id, []); + + const { ics } = svc.exportICS(trip.id); + + expect(ics).toContain('Hotel Bellevue'); + expect(ics).toMatch(/UID:trek-checkin-\d+@trek/); + }); + + it('CAL-059: a stay with both a staged and a live booking takes its title from the live one', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Kobe' }); + // The staged row gets the lower id, so the subquery's ORDER BY r.id ASC + // would pick it without the predicate. + stayWith(trip.id, [{ state: 'staged', title: 'Parked Name' }, { state: 'live', title: 'Real Name' }]); + + const { ics } = svc.exportICS(trip.id); + + expect(ics).toContain('Real Name'); + expect(ics).not.toContain('Parked Name'); + }); +}); + 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); diff --git a/server/tests/unit/nest/reservations.service.test.ts b/server/tests/unit/nest/reservations.service.test.ts index cd3f50fd8..981ac0cb6 100644 --- a/server/tests/unit/nest/reservations.service.test.ts +++ b/server/tests/unit/nest/reservations.service.test.ts @@ -877,6 +877,18 @@ describe('ReservationsService — legacy branch parity (coverage of the folded c svc.syncBudgetOnUpdate(String(trip.id), '999999', 'X', 'flight', 'X', 'flight', { total_price: 0 }, undefined); expect(budget.deleteBudgetItem).not.toHaveBeenCalled(); }); + + it('RESV-SVC-031: list returns staged bookings, the staging inbox has to see its own rows', () => { + const { trip } = ownerTrip(); + createReservation(testDb, trip.id, { title: 'Live One' }); + const staged = createReservation(testDb, trip.id, { title: 'Parked One' }); + testDb.prepare("UPDATE reservations SET ingest_state = 'staged' WHERE id = ?").run(staged.id); + + // The visibility predicate belongs to the anonymous exports (ICS feed, + // shared trip) and must never be pulled into the authenticated list, or + // nobody can confirm what the ingest parked. + expect(svc.list(String(trip.id)).map((r: any) => r.title).sort()).toEqual(['Live One', 'Parked One']); + }); }); describe('ReservationsService — quirk fixes (post-fold)', () => { diff --git a/server/tests/unit/nest/share.service.test.ts b/server/tests/unit/nest/share.service.test.ts index 00409482e..1aeedf23a 100644 --- a/server/tests/unit/nest/share.service.test.ts +++ b/server/tests/unit/nest/share.service.test.ts @@ -384,6 +384,48 @@ describe('getSharedTripData', () => { testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'carto_api_key', ' owner-key ')").run(user.id); expect(svc.getSharedTripData(token)!.cartoApiKey).toBe('owner-key'); }); + + it('SHARE-SVC-029: staged bookings stay out of the public payload, confirmation number included', () => { + const { trip, token } = seedSharedTrip(); + testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status, confirmation_number, ingest_state) + VALUES (?, 'Parked Flight', 'flight', 'confirmed', 'SECRET1', 'staged')`).run(trip.id); + testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status, confirmation_number) + VALUES (?, 'Booked Flight', 'flight', 'confirmed', 'OPEN1')`).run(trip.id); + + const data = svc.getSharedTripData(token)!; + + expect((data.reservations as any[]).map((r) => r.title)).toEqual(['Booked Flight']); + // SELECT * hands out notes, url and metadata too, so check the whole payload. + expect(JSON.stringify(data)).not.toContain('SECRET1'); + }); + + it('SHARE-SVC-030: every booking that predates the column stays in the payload', () => { + const { trip, token } = seedSharedTrip(); + for (let i = 0; i < 5; i++) { + testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status) + VALUES (?, ?, 'flight', 'confirmed')`).run(trip.id, `Booking ${i}`); + } + + expect((svc.getSharedTripData(token)!.reservations as any[])).toHaveLength(5); + }); + + it('SHARE-SVC-031: accommodations backed only by a staged booking are withheld, unlinked ones are kept', () => { + const { trip, token } = seedSharedTrip(); + const place = createPlace(testDb, trip.id, { name: 'Hotel Bellevue' }); + const day = createDay(testDb, trip.id, { date: '2026-09-01' }); + const stay = (): number => testDb.prepare(` + INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id) VALUES (?, ?, ?, ?) + `).run(trip.id, place.id, day.id, day.id).lastInsertRowid as number; + + const stagedStay = stay(); + testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status, accommodation_id, ingest_state) + VALUES (?, 'Parked Hotel', 'hotel', 'confirmed', ?, 'staged')`).run(trip.id, String(stagedStay)); + const unlinkedStay = stay(); + + const rows = svc.getSharedTripData(token)!.accommodations as any[]; + + expect(rows.map((a) => a.id)).toEqual([unlinkedStay]); + }); }); // ── getSharedPlacePhotoKey ─────────────────────────────────────────────────── diff --git a/server/tests/unit/nest/trips.service.test.ts b/server/tests/unit/nest/trips.service.test.ts index b03919a43..a5aed0d74 100644 --- a/server/tests/unit/nest/trips.service.test.ts +++ b/server/tests/unit/nest/trips.service.test.ts @@ -754,6 +754,26 @@ describe('folded trip CRUD', () => { expect((testDb.prepare('SELECT title FROM trips WHERE id = ?').get(secondCopy) as any).title).toBe('Origin'); }); + it('TRIP-SVC-060: copying a trip keeps a staged booking staged', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Origin', start_date: '2025-06-01', end_date: '2025-06-02' }); + testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status, ingest_state) + VALUES (?, 'Parked', 'flight', 'confirmed', 'staged')`).run(trip.id); + testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status) + VALUES (?, 'Booked', 'flight', 'confirmed')`).run(trip.id); + + const newTripId = svc.copy(trip.id, user.id, 'Clone'); + + // Without ingest_state on the duplicate INSERT the staged row falls back to + // the column default and shows up in the copy's public feed. + const rows = testDb.prepare('SELECT title, ingest_state FROM reservations WHERE trip_id = ? ORDER BY title') + .all(newTripId) as any[]; + expect(rows).toEqual([ + { title: 'Booked', ingest_state: 'live' }, + { title: 'Parked', ingest_state: 'staged' }, + ]); + }); + /** * Copying a trip used to take every packing row and re-insert it without * is_private/owner_id, so both fell back to the column defaults and another diff --git a/shared/src/reservation/reservation.schema.ts b/shared/src/reservation/reservation.schema.ts index ab5e66fae..b2349fdef 100644 --- a/shared/src/reservation/reservation.schema.ts +++ b/shared/src/reservation/reservation.schema.ts @@ -153,6 +153,9 @@ export const reservationSchema = z.object({ accommodation_id: z.union([z.number(), z.string()]).nullable().optional(), metadata: z.string().nullable().optional(), needs_review: z.number().optional(), + // 'live' for anything a person put on the trip, 'staged' while an automated + // ingest waits for confirmation. Only the anonymous exports filter on it. + ingest_state: z.string().optional(), day_plan_position: z.number().nullable().optional(), created_at: z.string().optional(), // AirTrail (or future provider) linkage — drives the "synced" badge (#214). @@ -309,6 +312,12 @@ const bookingImportAccommodationSchema = z.object({ export const bookingImportPreviewItemSchema = z.object({ type: z.string(), + /** + * The extractor could not read a type and filled one in, so the import UI + * should offer the form the user was importing into rather than trusting the + * placeholder (#2076). + */ + type_guessed: z.boolean().optional(), title: z.string().min(1), reservation_time: z.string().nullable().optional(), reservation_end_time: z.string().nullable().optional(),