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', () => {