diff --git a/client/src/pages/SharedTripPage.test.tsx b/client/src/pages/SharedTripPage.test.tsx index 6737651f0..68efc409a 100644 --- a/client/src/pages/SharedTripPage.test.tsx +++ b/client/src/pages/SharedTripPage.test.tsx @@ -1352,4 +1352,102 @@ describe('SharedTripPage', () => { await waitFor(() => expect(screen.getByText(/Airport Parking/)).toBeInTheDocument()); }); }); + + // ── #2320: the richer read-only detail ───────────────────────────────── + + describe('FE-PAGE-SHARED-038: a stop shows its address, description, both notes, duration and contact (#2320)', () => { + const days = [{ id: 21, trip_id: 1, day_number: 1, date: '2026-07-01', title: 'Day One' }]; + const assignments = { + 21: [ + { + id: 301, day_id: 21, order_index: 0, notes: 'go early, before the coaches', + place: { + id: 501, name: 'Louvre', description: 'The big one', address: 'Rue de Rivoli, Paris', + lat: 48.86, lng: 2.34, category_id: null, price: null, place_time: '09:00', end_time: null, + duration_minutes: 150, notes: 'Skip the pyramid queue', website: 'https://louvre.fr', + phone: '+33 1 40 20 50 50', image_url: null, transport_mode: 'walking', category: null, tags: [], + }, + }, + ], + }; + + it('renders every field the owner filled, as separate lines', async () => { + await open('detail-token', payload({ days, assignments })); + fireEvent.click(screen.getByText('Day One')); + // The name is also on the map marker's tooltip, so the list row is the one with the address under it. + await waitFor(() => expect(screen.getByText('Rue de Rivoli, Paris')).toBeInTheDocument()); + expect(screen.getAllByText('Louvre').length).toBeGreaterThan(0); + + expect(screen.getByText('The big one')).toBeInTheDocument(); + expect(screen.getByText('go early, before the coaches')).toBeInTheDocument(); + expect(screen.getByText('Skip the pyramid queue')).toBeInTheDocument(); + expect(screen.getByText('2 h 30 min')).toBeInTheDocument(); + + const website = screen.getByRole('link', { name: /website/i }); + expect(website).toHaveAttribute('href', 'https://louvre.fr'); + expect(website).toHaveAttribute('target', '_blank'); + expect(website).toHaveAttribute('rel', 'noopener noreferrer'); + + const maps = screen.getByRole('link', { name: /google maps/i }); + expect(maps.getAttribute('href')).toContain('google.com/maps/search/'); + expect(maps.getAttribute('href')).toContain(encodeURIComponent('Louvre, Rue de Rivoli, Paris')); + expect(maps).toHaveAttribute('rel', 'noopener noreferrer'); + + const phone = screen.getByRole('link', { name: '+33 1 40 20 50 50' }); + expect(phone).toHaveAttribute('href', 'tel:+33140205050'); + }); + + it('leaves out what is empty and never renders a link that is not http(s)', async () => { + const bare = { + 21: [{ + id: 302, day_id: 21, order_index: 0, notes: null, + place: { + id: 502, name: 'Somewhere', description: null, address: null, lat: null, lng: null, + duration_minutes: 0, notes: ' ', website: 'javascript:alert(1)', phone: null, + category_id: null, price: null, place_time: null, end_time: null, image_url: null, + transport_mode: 'walking', category: null, tags: [], + }, + }], + }; + await open('bare-token', payload({ days, assignments: bare })); + fireEvent.click(screen.getByText('Day One')); + await waitFor(() => expect(screen.getByText('Somewhere')).toBeInTheDocument()); + + expect(screen.queryByRole('link', { name: /website/i })).toBeNull(); + expect(screen.queryByRole('link', { name: /google maps/i })).toBeNull(); + expect(document.querySelector('a[href^="javascript:"]')).toBeNull(); + expect(screen.queryByText(/min$/)).toBeNull(); + }); + }); + + describe('FE-PAGE-SHARED-039: a booking shows its note and its link, and nothing that is not http(s) (#2320)', () => { + it('renders the note and a link labelled by its host', async () => { + await open('booking-token', payload({ + permissions: { share_bookings: true, share_packing: false, share_budget: false, share_collab: false }, + reservations: [ + { + id: 91, title: 'Night train', type: 'train', status: 'confirmed', day_id: null, end_day_id: null, + reservation_time: '2026-07-01T21:00:00', reservation_end_time: null, metadata: null, + notes: 'Meet at the north entrance', url: 'https://www.bahn.example/booking/abc', + }, + { + id: 92, title: 'Sketchy', type: 'other', status: 'pending', day_id: null, end_day_id: null, + reservation_time: null, reservation_end_time: null, metadata: null, + notes: null, url: 'javascript:alert(1)', + }, + ], + })); + fireEvent.click(screen.getByText('Bookings')); + await waitFor(() => expect(screen.getByText('Night train')).toBeInTheDocument()); + + expect(screen.getByText('Meet at the north entrance')).toBeInTheDocument(); + const link = screen.getByRole('link', { name: 'bahn.example' }); + expect(link).toHaveAttribute('href', 'https://www.bahn.example/booking/abc'); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + + expect(screen.getByText('Sketchy')).toBeInTheDocument(); + expect(document.querySelector('a[href^="javascript:"]')).toBeNull(); + }); + }); }); diff --git a/client/src/pages/SharedTripPage.tsx b/client/src/pages/SharedTripPage.tsx index 169e5d4db..c9dadaf61 100644 --- a/client/src/pages/SharedTripPage.tsx +++ b/client/src/pages/SharedTripPage.tsx @@ -32,6 +32,8 @@ import { splitReservationDateTime } from '../utils/formatters'; import { computeMapViewport, TILE_SIZE_RASTER } from '../utils/mapViewport'; import { resolveBasemap } from '../utils/tileUrl'; import { useSharedTrip } from './sharedTrip/useSharedTrip'; +import { SharedPlaceDetails } from './sharedTrip/SharedPlaceDetails'; +import { SharedBookingDetails } from './sharedTrip/SharedBookingDetails'; const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }; @@ -738,7 +740,7 @@ export default function SharedTripPage() { key={`p-${item.data.id}`} style={{ display: 'flex', - alignItems: 'center', + alignItems: 'flex-start', gap: 10, padding: '6px 8px', borderRadius: 6, @@ -773,19 +775,7 @@ export default function SharedTripPage() { > {place.name} - {(place.address || place.description) && ( -
- {place.address || place.description} -
- )} + {place.place_time && (
)}
+ + {note && ( +
{/* theme-lint-disable — public page, no user theme */} + + {note} +
+ )} + {link && ( + + + {linkHost(link)} + + )} + + ) +} diff --git a/client/src/pages/sharedTrip/SharedPlaceDetails.tsx b/client/src/pages/sharedTrip/SharedPlaceDetails.tsx new file mode 100644 index 000000000..0526ed858 --- /dev/null +++ b/client/src/pages/sharedTrip/SharedPlaceDetails.tsx @@ -0,0 +1,103 @@ +import { Clock, ExternalLink, FileText, Globe, MapPin, Phone } from 'lucide-react' +import { useTranslation } from '../../i18n' +import { getGoogleMapsUrlForPlace } from '../../components/Planner/placeGoogleMaps' +import { formatDurationMinutes, isHttpUrl } from './sharedTripModel' + +/** + * The read-only detail of one stop on a shared plan (#2320). + * + * The page used to show a place as a name and, under it, the address *or* the + * description, one string on one line. The owner had written both, and had + * usually also left a note on the place and another on the day it was + * planned for, and a link would show none of that. This shows what the owner + * chose to share, laid out the way the planner lays it out: address, then + * description, then the two notes, then how long they meant to stay, then + * the ways to reach the place. + * + * Nothing here is editable and nothing here is a route into the app: every + * link leaves the page, in a new tab, with no opener. The server has already + * decided what a public viewer may see; this only renders what arrived. + */ +export interface SharedPlaceLike { + name: string + address?: string | null + description?: string | null + notes?: string | null + lat?: number | null + lng?: number | null + duration_minutes?: number | null + website?: string | null + phone?: string | null + place_time?: string | null + end_time?: string | null +} + +export function SharedPlaceDetails({ place, assignmentNotes }: { place: SharedPlaceLike; assignmentNotes?: string | null }) { + const { t } = useTranslation() + const website = isHttpUrl(place.website) ? place.website : null + const phone = place.phone?.trim() || null + const mapsUrl = getGoogleMapsUrlForPlace({ + name: place.name, address: place.address ?? null, lat: place.lat ?? null, lng: place.lng ?? null, + google_place_id: null, google_ftid: null, + }) + const duration = formatDurationMinutes(place.duration_minutes) + const dayNote = assignmentNotes?.trim() || null + const placeNote = place.notes?.trim() || null + const hasLinks = !!(website || phone || mapsUrl) + + return ( +
+ {place.address && ( +
{/* theme-lint-disable — public page, no user theme */} + + {place.address} +
+ )} + {place.description && ( +
{/* theme-lint-disable — public page, no user theme */} + {place.description} +
+ )} + {dayNote && ( +
{/* theme-lint-disable — public page, no user theme */} + + {dayNote} +
+ )} + {placeNote && ( +
{/* theme-lint-disable — public page, no user theme */} + + {placeNote} +
+ )} + {(duration || hasLinks) && ( + + )} +
+ ) +} diff --git a/client/src/pages/sharedTrip/sharedTripModel.test.ts b/client/src/pages/sharedTrip/sharedTripModel.test.ts new file mode 100644 index 000000000..7858e44f6 --- /dev/null +++ b/client/src/pages/sharedTrip/sharedTripModel.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest' +import { formatDurationMinutes, isHttpUrl, linkHost } from './sharedTripModel' + +describe('sharedTripModel (#2320)', () => { + it('accepts http and https and nothing else', () => { + expect(isHttpUrl('https://a.example/x')).toBe(true) + expect(isHttpUrl(' http://a.example ')).toBe(true) + expect(isHttpUrl('javascript:alert(1)')).toBe(false) + expect(isHttpUrl('data:text/html,hi')).toBe(false) + expect(isHttpUrl('ftp://a.example')).toBe(false) + expect(isHttpUrl('not a url')).toBe(false) + expect(isHttpUrl('')).toBe(false) + expect(isHttpUrl(null)).toBe(false) + expect(isHttpUrl(42)).toBe(false) + }) + + it('writes a planned stay as minutes, hours, or both', () => { + expect(formatDurationMinutes(45)).toBe('45 min') + expect(formatDurationMinutes(60)).toBe('1 h') + expect(formatDurationMinutes(150)).toBe('2 h 30 min') + expect(formatDurationMinutes(89.6)).toBe('1 h 30 min') + }) + + it('writes nothing for a missing, zero or nonsense figure', () => { + expect(formatDurationMinutes(null)).toBeNull() + expect(formatDurationMinutes(undefined)).toBeNull() + expect(formatDurationMinutes(0)).toBeNull() + expect(formatDurationMinutes(-5)).toBeNull() + expect(formatDurationMinutes(Number.NaN)).toBeNull() + }) + + it('labels a link by its host, without the www', () => { + expect(linkHost('https://www.bahn.example/booking/abc')).toBe('bahn.example') + expect(linkHost('https://booking.example')).toBe('booking.example') + expect(linkHost('nonsense')).toBe('nonsense') + }) +}) diff --git a/client/src/pages/sharedTrip/sharedTripModel.ts b/client/src/pages/sharedTrip/sharedTripModel.ts new file mode 100644 index 000000000..4eff44662 --- /dev/null +++ b/client/src/pages/sharedTrip/sharedTripModel.ts @@ -0,0 +1,48 @@ +/** + * Pure helpers for the public shared-trip page (#2320). React-free, so the + * page and its detail blocks can share them and a test can drive them + * without rendering. + */ + +/** + * Whether a string is a link the page may render as one. + * + * The server already drops anything that is not http(s) (share.service.ts), + * so this is the second gate rather than the first — the page must not become + * the place where a `javascript:` value turns into an anchor because a future + * payload forgot to filter it. + */ +export function isHttpUrl(value: unknown): value is string { + if (typeof value !== 'string' || !value.trim()) return false + try { + const url = new URL(value.trim()) + return url.protocol === 'http:' || url.protocol === 'https:' + } catch { + return false + } +} + +/** + * "45 min", "2 h", "1 h 30 min" — the planned time at a place. + * + * Nothing for a missing, zero or negative figure: the planner's default of an + * hour is a default, not a plan, and the field stores what the owner typed. + */ +export function formatDurationMinutes(minutes: number | null | undefined): string | null { + if (typeof minutes !== 'number' || !Number.isFinite(minutes) || minutes <= 0) return null + const whole = Math.round(minutes) + const h = Math.floor(whole / 60) + const m = whole % 60 + if (h === 0) return `${m} min` + if (m === 0) return `${h} h` + return `${h} h ${m} min` +} + +/** The hostname a booking link points at, for a label that says where it goes. */ +export function linkHost(url: string): string { + try { + return new URL(url).hostname.replace(/^www\./, '') + } catch { + return url + } +} diff --git a/server/src/nest/share/share.service.ts b/server/src/nest/share/share.service.ts index 5a266ec6f..9b36e992b 100644 --- a/server/src/nest/share/share.service.ts +++ b/server/src/nest/share/share.service.ts @@ -51,6 +51,102 @@ export interface ShareTokenInfo { * DatabaseService. Trip access and the 'share_manage' permission gate * create/delete; the shared read is public. */ +/** + * What a place shows the public: where it is, what it is, how to reach it. + * + * Left out on purpose: the owner's booking notes and status on the place, the + * Google identifiers, the routing bookkeeping and the fill figures a road trip + * keeps for itself. `notes` stays — it is the note somebody wrote to be read + * on the plan, and the plan is what a link shares. + */ +const PUBLIC_PLACE_COLUMNS = [ + 'id', 'trip_id', 'name', 'description', 'lat', 'lng', 'address', 'category_id', 'price', 'currency', + 'place_time', 'end_time', 'duration_minutes', 'notes', 'image_url', 'website', 'phone', + 'transport_mode', 'created_at', 'updated_at', +].map(c => `p.${c}`).join(', '); + +/** + * What a booking shows the public: when, where, what kind, and the note and + * link the owner attached to it. Never the confirmation number, never the + * import trail, never who is travelling on it. + */ +const PUBLIC_RESERVATION_COLUMNS = [ + 'id', 'trip_id', 'day_id', 'end_day_id', 'place_id', 'accommodation_id', 'title', 'type', 'status', 'location', + 'reservation_time', 'reservation_end_time', 'notes', 'url', 'metadata', 'created_at', +].map(c => `r.${c}`).join(', '); + +/** A stay: which place, which nights, when the desk opens. Not the confirmation. */ +const PUBLIC_ACCOMMODATION_COLUMNS = [ + 'id', 'trip_id', 'place_id', 'start_day_id', 'end_day_id', 'check_in', 'check_in_end', 'check_out', 'notes', +].map(c => `a.${c}`).join(', '); + +/** + * The parts of a booking's metadata that describe the journey rather than the + * ticket. Legs keep their route, carrier and times; the record locator on a + * leg, the seat, the price and anything this list does not name stay behind. + * An allow-list rather than a block-list, because the import writes whatever a + * provider's confirmation carried, and a new field must not become public by + * appearing. + */ +const PUBLIC_METADATA_KEYS = new Set([ + 'airline', 'flight_number', 'departure_airport', 'arrival_airport', + 'train_number', 'platform', 'operator', 'from', 'to', + 'check_in_time', 'check_in_end_time', 'check_out_time', 'hotel', + 'pickup_location', 'dropoff_location', 'vehicle', +]); +const PUBLIC_LEG_KEYS = new Set([ + 'from', 'to', 'airline', 'flight_number', 'train_number', 'platform', 'operator', + 'dep_day_id', 'dep_time', 'arr_day_id', 'arr_time', +]); + +function pickKeys(source: Record, keys: Set): Record { + const out: Record = {}; + for (const key of keys) { + if (source[key] !== undefined) out[key] = source[key]; + } + return out; +} + +/** The public face of a booking's metadata, as the JSON string the row stores. */ +export function publicReservationMetadata(raw: unknown): string | null { + if (raw == null) return null; + let parsed: unknown = raw; + if (typeof raw === 'string') { + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; + const source = parsed as Record; + const out = pickKeys(source, PUBLIC_METADATA_KEYS); + if (Array.isArray(source.legs)) { + out.legs = source.legs + .filter((leg): leg is Record => !!leg && typeof leg === 'object' && !Array.isArray(leg)) + .map(leg => pickKeys(leg, PUBLIC_LEG_KEYS)); + } + return JSON.stringify(out); +} + +/** + * A link the page may render as one: http or https, nothing else. + * + * The owner types these, and a `javascript:` or `data:` value would otherwise + * become an anchor on a page anyone with the link can open. + */ +export function publicHttpUrl(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + if (!trimmed) return null; + try { + const parsed = new URL(trimmed); + return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? trimmed : null; + } catch { + return null; + } +} + @Injectable() export class ShareService { constructor( @@ -140,6 +236,28 @@ export class ShareService { * is never even queried. share_map covers the whole itinerary: days, their * assignments/notes, and the place list with coordinates, addresses and notes. */ + /** + * The ordered stops of every public booking on the trip, by booking id. + * + * One query for the lot rather than one per booking: the map draws the + * route from these, and a trip with forty bookings is not forty round trips + * to the database. + */ + private publicEndpointsByReservation(tripId: number): Map>> { + const rows = this.dbs.all<{ reservation_id: number } & Record>( + `SELECT e.reservation_id, e.role, e.sequence, e.name, e.code, e.lat, e.lng, e.timezone, e.local_date, e.local_time + FROM reservation_endpoints e JOIN reservations r ON r.id = e.reservation_id + WHERE r.trip_id = ? ORDER BY e.reservation_id ASC, e.sequence ASC`, + tripId, + ); + const out = new Map>>(); + for (const { reservation_id, ...endpoint } of rows) { + if (!out.has(reservation_id)) out.set(reservation_id, []); + out.get(reservation_id)!.push(endpoint); + } + return out; + } + getSharedTripData(token: string): Record | null { const shareRow = this.dbs.get( "SELECT * FROM share_tokens WHERE token = ? AND (expires_at IS NULL OR expires_at > datetime('now'))", @@ -181,6 +299,7 @@ export class ShareService { COALESCE(da.assignment_time, p.place_time) as place_time, COALESCE(da.assignment_end_time, p.end_time) as end_time, p.duration_minutes, p.notes as place_notes, p.image_url, p.transport_mode, + p.website, p.phone, c.name as category_name, c.color as category_color, c.icon as category_icon FROM day_assignments da JOIN places p ON da.place_id = p.id @@ -201,6 +320,8 @@ export class ShareService { id: a.place_id, name: a.place_name, description: a.place_description, lat: a.lat, lng: a.lng, address: a.address, category_id: a.category_id, price: a.price, place_time: a.place_time, end_time: a.end_time, + duration_minutes: a.duration_minutes, notes: a.place_notes, + website: publicHttpUrl(a.website), phone: a.phone, image_url: rewritePlacePhotoUrl(a.image_url, token), transport_mode: a.transport_mode, category: a.category_id ? { id: a.category_id, name: a.category_name, color: a.category_color, icon: a.category_icon } : null, tags: tagsByPlace[a.place_id] ?? [], @@ -218,11 +339,18 @@ export class ShareService { dayNotes = notesByDay; } + // Named columns, not p.*: the pool used to travel whole, which put the + // owner's booking notes on the place, the Google ids and the import + // bookkeeping in front of anybody holding the link (#2320). places = this.dbs.all(` - SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon + SELECT ${PUBLIC_PLACE_COLUMNS}, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.trip_id = ? ORDER BY p.created_at DESC - `, tripId).map((p) => ({ ...p, image_url: rewritePlacePhotoUrl(p.image_url, token) })); + `, tripId).map((p) => ({ + ...p, + image_url: rewritePlacePhotoUrl(p.image_url, token), + website: publicHttpUrl(p.website), + })); } // Bookings — reservations carry per-day positions so the client can render @@ -244,14 +372,27 @@ export class ShareService { } // The alias is not cosmetic: the visibility predicate qualifies its column, // and this query had no alias to qualify against. + // Named columns here too. r.* carried the confirmation number, the + // import bookkeeping and the raw metadata — and the metadata is where + // an imported ticket keeps its seat and its record locator. A public + // link shows what the booking is, not what it would take to change it + // (#2320). The endpoints ride along, since the map draws from them. + const endpoints = this.publicEndpointsByReservation(tripId); reservations = this.dbs.all( - `SELECT r.* FROM reservations r + `SELECT ${PUBLIC_RESERVATION_COLUMNS} 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 })); + .map((r) => ({ + ...r, + url: publicHttpUrl(r.url), + metadata: publicReservationMetadata(r.metadata), + endpoints: endpoints.get(r.id) ?? [], + 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 + SELECT ${PUBLIC_ACCOMMODATION_COLUMNS}, + 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 = ? AND ${publicStaySql('a')} `, tripId); diff --git a/server/tests/unit/nest/share.service.test.ts b/server/tests/unit/nest/share.service.test.ts index 1aeedf23a..604eaf0cb 100644 --- a/server/tests/unit/nest/share.service.test.ts +++ b/server/tests/unit/nest/share.service.test.ts @@ -56,7 +56,7 @@ import { } from '../../helpers/factories'; import { DatabaseService } from '../../../src/nest/database/database.service'; import type { PermissionsService } from '../../../src/nest/permissions/permissions.service'; -import { ShareService } from '../../../src/nest/share/share.service'; +import { ShareService, publicReservationMetadata } from '../../../src/nest/share/share.service'; import { SettingsService } from '../../../src/nest/settings/settings.service'; import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service'; import type { User } from '../../../src/types'; @@ -428,6 +428,140 @@ describe('getSharedTripData', () => { }); }); +// ── #2320: what the public payload carries, and what it withholds ─────────── + +describe('getSharedTripData redaction (#2320)', () => { + it('SHARE-SVC-032: a booking travels without its confirmation, import trail or travellers', () => { + const { trip, token } = seedSharedTrip(); + testDb.prepare(`INSERT INTO reservations + (trip_id, title, type, status, confirmation_number, notes, url, external_source, external_id, sync_enabled, needs_review) + VALUES (?, 'Night train', 'train', 'confirmed', 'PNR9XY', 'Bring the tickets', 'https://bahn.example/booking/1', 'kitinerary', 'ext-42', 1, 0)`) + .run(trip.id); + + const [row] = svc.getSharedTripData(token)!.reservations as any[]; + + expect(Object.keys(row).sort()).toEqual([ + 'accommodation_id', 'created_at', 'day_id', 'day_positions', 'end_day_id', 'endpoints', 'id', 'location', + 'metadata', 'notes', 'place_id', 'reservation_end_time', 'reservation_time', 'status', 'title', 'trip_id', 'type', 'url', + ]); + expect(row.notes).toBe('Bring the tickets'); + expect(row.url).toBe('https://bahn.example/booking/1'); + expect(JSON.stringify(svc.getSharedTripData(token))).not.toContain('PNR9XY'); + expect(JSON.stringify(svc.getSharedTripData(token))).not.toContain('ext-42'); + }); + + it('SHARE-SVC-033: booking metadata keeps the journey and drops the ticket', () => { + const { trip, token } = seedSharedTrip(); + const metadata = JSON.stringify({ + airline: 'LH', flight_number: 'LH400', departure_airport: 'FRA', arrival_airport: 'JFK', + seat: '14A', price: 812, passenger_name: 'Ada Lovelace', confirmation_number: 'LOC123', + legs: [ + { from: 'FRA', to: 'BER', airline: 'LH', flight_number: 'LH170', dep_time: '08:00', arr_time: '09:05', confirmation_number: 'LEG1', seat: '3C' }, + { from: 'BER', to: 'JFK', airline: 'LH', flight_number: 'LH400', dep_time: '11:00', arr_time: '14:10', confirmation_number: 'LEG2' }, + ], + }); + testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status, metadata) + VALUES (?, 'To New York', 'flight', 'confirmed', ?)`).run(trip.id, metadata); + + const data = svc.getSharedTripData(token)!; + const [row] = data.reservations as any[]; + const meta = JSON.parse(row.metadata); + + expect(meta).toEqual({ + airline: 'LH', flight_number: 'LH400', departure_airport: 'FRA', arrival_airport: 'JFK', + legs: [ + { from: 'FRA', to: 'BER', airline: 'LH', flight_number: 'LH170', dep_time: '08:00', arr_time: '09:05' }, + { from: 'BER', to: 'JFK', airline: 'LH', flight_number: 'LH400', dep_time: '11:00', arr_time: '14:10' }, + ], + }); + const whole = JSON.stringify(data); + for (const secret of ['14A', '812', 'Ada Lovelace', 'LOC123', 'LEG1', 'LEG2', '3C']) { + expect(whole, secret).not.toContain(secret); + } + }); + + it('SHARE-SVC-034: a booking carries its ordered stops', () => { + const { trip, token } = seedSharedTrip(); + const id = testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status) + VALUES (?, 'Coach', 'bus', 'confirmed')`).run(trip.id).lastInsertRowid; + const ins = testDb.prepare(`INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng) + VALUES (?, ?, ?, ?, ?, ?, ?)`); + ins.run(id, 'to', 1, 'Lyon', null, 45.76, 4.84); + ins.run(id, 'from', 0, 'Paris', 'PAR', 48.85, 2.35); + + const [row] = svc.getSharedTripData(token)!.reservations as any[]; + + expect(row.endpoints.map((e: any) => [e.role, e.name])).toEqual([['from', 'Paris'], ['to', 'Lyon']]); + expect(Object.keys(row.endpoints[0]).sort()).toEqual( + ['code', 'lat', 'lng', 'local_date', 'local_time', 'name', 'role', 'sequence', 'timezone'], + ); + }); + + it('SHARE-SVC-035: only http(s) links reach the page, on bookings and places alike', () => { + const { trip, token } = seedSharedTrip(); + const day = createDay(testDb, trip.id, { date: '2026-09-01' }); + for (const [title, url] of [['ok', 'https://example.com/x'], ['js', 'javascript:alert(1)'], ['data', 'data:text/html,hi'], ['junk', 'not a url'], ['blank', ' ']]) { + testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status, url) VALUES (?, ?, 'other', 'confirmed', ?)`).run(trip.id, title, url); + } + const place = createPlace(testDb, trip.id, { name: 'Museum' }); + testDb.prepare('UPDATE places SET website = ?, phone = ? WHERE id = ?').run('javascript:alert(2)', '+43 1 234', place.id); + createDayAssignment(testDb, day.id, place.id, {}); + + const data = svc.getSharedTripData(token)!; + const urls = Object.fromEntries((data.reservations as any[]).map((r) => [r.title, r.url])); + expect(urls).toEqual({ ok: 'https://example.com/x', js: null, data: null, junk: null, blank: null }); + expect(data.assignments[day.id][0].place.website).toBeNull(); + expect(data.assignments[day.id][0].place.phone).toBe('+43 1 234'); + expect((data.places as any[])[0].website).toBeNull(); + }); + + it('SHARE-SVC-036: an assignment carries the place notes, duration and contact, the pool carries no booking notes', () => { + const { trip, token } = seedSharedTrip(); + const day = createDay(testDb, trip.id, { date: '2026-09-01' }); + const place = createPlace(testDb, trip.id, { name: 'Louvre' }); + testDb.prepare(`UPDATE places SET description = 'Big museum', address = 'Rue de Rivoli', notes = 'Skip the pyramid queue', + duration_minutes = 180, website = 'https://louvre.fr', phone = '+33 1', reservation_notes = 'Booked under Ada', google_place_id = 'ChIJ123' WHERE id = ?`).run(place.id); + createDayAssignment(testDb, day.id, place.id, { notes: 'go early' }); + + const data = svc.getSharedTripData(token)!; + const entry = data.assignments[day.id][0]; + expect(entry.notes).toBe('go early'); + expect(entry.place).toEqual(expect.objectContaining({ + description: 'Big museum', address: 'Rue de Rivoli', notes: 'Skip the pyramid queue', + duration_minutes: 180, website: 'https://louvre.fr', phone: '+33 1', + })); + const pool = (data.places as any[])[0]; + expect(pool.notes).toBe('Skip the pyramid queue'); + expect(pool).not.toHaveProperty('reservation_notes'); + expect(pool).not.toHaveProperty('google_place_id'); + expect(JSON.stringify(data)).not.toContain('Booked under Ada'); + expect(JSON.stringify(data)).not.toContain('ChIJ123'); + }); + + it('SHARE-SVC-037: a stay carries its desk times and note, not its confirmation', () => { + const { trip, token } = seedSharedTrip(); + const place = createPlace(testDb, trip.id, { name: 'Hotel' }); + const day = createDay(testDb, trip.id, { date: '2026-09-01' }); + testDb.prepare(`INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes) + VALUES (?, ?, ?, ?, '15:00', '11:00', 'HOTEL-777', 'Ask for a quiet room')`).run(trip.id, place.id, day.id, day.id); + + const data = svc.getSharedTripData(token)!; + const [stay] = data.accommodations as any[]; + expect(stay).toEqual(expect.objectContaining({ check_in: '15:00', check_out: '11:00', notes: 'Ask for a quiet room', place_name: 'Hotel' })); + expect(stay).not.toHaveProperty('confirmation'); + expect(JSON.stringify(data)).not.toContain('HOTEL-777'); + }); + + it('SHARE-SVC-038: metadata that is not an object comes back as nothing', () => { + expect(publicReservationMetadata(null)).toBeNull(); + expect(publicReservationMetadata('not json')).toBeNull(); + expect(publicReservationMetadata('[1,2]')).toBeNull(); + expect(publicReservationMetadata('"str"')).toBeNull(); + expect(publicReservationMetadata({ airline: 'OS', legs: [null, 'x', { from: 'VIE', seat: '1A' }] })) + .toBe(JSON.stringify({ airline: 'OS', legs: [{ from: 'VIE' }] })); + }); +}); + // ── getSharedPlacePhotoKey ─────────────────────────────────────────────────── describe('getSharedPlacePhotoKey', () => {