feat: Apple Wallet passes for bookings

"Add to Apple Wallet" on a booking in the iOS app turns it into a TREK pass:
a boarding pass from and to for flights, trains, buses and boats, check-in and
check-out for a stay, pickup and drop-off for a car, the time for anything
else, with the confirmation code as a QR code. Wallet brings it up on the lock
screen around its time and place: times carry the offset of the endpoint's zone
(looked up from the coordinates where the endpoint has none), stays and
restaurants their place's coordinates. The labels come from the booking forms'
own strings, so nothing new to translate on the pass.

Only liketrek holds the Pass Type ID certificate, so the server sends the pass's
texts to the TREK services' signer (TREK_WALLET_PASS_URL, push.liketrek.com)
once per tap; it stores nothing, and TREK_SERVICES=off turns it off with the
other services. Texts are cleaned of links and shortened here, the answer must
be a .pkpass within 512 KB, and a user gets 20 passes a minute.

- New wallet-pass domain: GET /api/trips/:tripId/reservations/:id/wallet-pass
  behind the trip guard, the booking checked to belong to the trip; trip title
  and place come from the reservations domain (findPassContext)
- MWalletButton in the transport and booking sheets, only where the app binary
  has the wallet feature; undated bookings and failures are told apart
- TREK_WALLET_PASS_URL in .env.example, compose, Helm and the wiki
This commit is contained in:
Maurice
2026-11-07 10:40:41 +01:00
parent 794da3be72
commit 37a4e84360
56 changed files with 1873 additions and 113 deletions
+3
View File
@@ -127,6 +127,9 @@ data:
{{- if .Values.env.TREK_VALHALLA_URL }}
TREK_VALHALLA_URL: {{ .Values.env.TREK_VALHALLA_URL | quote }}
{{- end }}
{{- if .Values.env.TREK_WALLET_PASS_URL }}
TREK_WALLET_PASS_URL: {{ .Values.env.TREK_WALLET_PASS_URL | quote }}
{{- end }}
{{- if .Values.env.GOOGLE_LIST_DATA_BASE }}
GOOGLE_LIST_DATA_BASE: {{ .Values.env.GOOGLE_LIST_DATA_BASE | quote }}
{{- end }}
+4
View File
@@ -159,6 +159,10 @@ env:
# TREK_VALHALLA_URL: ""
# The Valhalla of the TREK services, which this server relays routing to unless the admin picks another
# routing service. The browser never talks to it. Defaults to https://valhalla.liketrek.com.
# TREK_WALLET_PASS_URL: ""
# Signs the Apple Wallet pass of a booking when someone taps "Add to Apple Wallet" in the iOS app.
# Only the pass's texts go there, once per tap. Never asked with TREK_SERVICES=off.
# Defaults to https://push.liketrek.com/v1/wallet/pass.
# AMAP_API_BASE: ""
# Send the Amap (高德地图) calls through an egress proxy, a cache or a gateway that speaks the same API instead of
# restapi.amap.com directly. The key itself is a credential and goes under secretEnv.AMAP_API_KEY, or into
@@ -54,6 +54,7 @@
"components/shared/DialogShell.tsx": 2,
"components/shared/PasswordChecklist.tsx": 2,
"mobile/components/MSheet.tsx": 1,
"mobile/components/MWalletButton.tsx": 1,
"mobile/screens/atlas/MAtlas.tsx": 1,
"mobile/screens/atlas/MAtlasCountryPopup.tsx": 2,
"mobile/screens/atlas/MAtlasSearch.tsx": 1,
@@ -0,0 +1,59 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
import MWalletButton from './MWalletButton';
const wallet = vi.hoisted(() => ({
can: true,
add: vi.fn(),
}));
vi.mock('../../native/wallet', () => ({
canAddToWallet: () => wallet.can,
addReservationToWallet: wallet.add,
}));
vi.mock('../../native/capabilities', () => ({ useNativeCapabilities: () => ({ caps: {}, updateRequired: false }) }));
beforeEach(() => {
wallet.can = true;
wallet.add.mockReset();
});
describe('MWalletButton', () => {
it('is not there for a booking that is not saved yet', () => {
render(<MWalletButton tripId={42} reservationId={undefined} onError={vi.fn()} />);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
it('sits in a wrapper when the sheet places it', () => {
const { container } = render(<MWalletButton tripId={42} reservationId={7} onError={vi.fn()} className="mt-2" />);
expect(container.querySelector('div.mt-2 > button')).not.toBeNull();
});
it('is not there where the app cannot add a pass', () => {
wallet.can = false;
render(<MWalletButton tripId={42} reservationId={7} onError={vi.fn()} />);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
it('adds the booking and says nothing when Wallet took it', async () => {
wallet.add.mockResolvedValue('added');
const onError = vi.fn();
render(<MWalletButton tripId={42} reservationId={7} onError={onError} />);
fireEvent.click(screen.getByRole('button'));
await waitFor(() => expect(wallet.add).toHaveBeenCalledWith(42, 7, expect.any(String)));
await waitFor(() => expect(screen.getByRole('button')).not.toBeDisabled());
expect(onError).not.toHaveBeenCalled();
});
it('explains an undated booking and a failure apart', async () => {
const onError = vi.fn();
render(<MWalletButton tripId={42} reservationId={7} onError={onError} />);
wallet.add.mockResolvedValueOnce('undated');
fireEvent.click(screen.getByRole('button'));
await waitFor(() => expect(onError).toHaveBeenCalledWith('reservations.wallet.undated'));
await waitFor(() => expect(screen.getByRole('button')).not.toBeDisabled());
wallet.add.mockResolvedValueOnce('failed');
fireEvent.click(screen.getByRole('button'));
await waitFor(() => expect(onError).toHaveBeenLastCalledWith('reservations.wallet.failed'));
});
});
@@ -0,0 +1,55 @@
import { Loader2, Wallet } from 'lucide-react';
import { useState } from 'react';
import { useTranslation } from '../../i18n';
import { useNativeCapabilities } from '../../native/capabilities';
import { addReservationToWallet, canAddToWallet } from '../../native/wallet';
interface MWalletButtonProps {
tripId: number;
/** None yet (a booking being created): no button. */
reservationId: number | undefined;
/** Placement in the hosting sheet, on a wrapper around the button. */
className?: string;
/** Shown when the pass could not be made (a toast in the sheet that hosts the button). */
onError: (message: string) => void;
}
/**
* "Add to Apple Wallet" on a booking, in the iOS app only (feature wallet): the
* booking becomes a TREK pass that shows up on the lock screen around its time
* and place. Black like Apple's own badge, so it reads as Wallet at a glance.
*/
export default function MWalletButton({ tripId, reservationId, onError, className }: MWalletButtonProps) {
const { t, language } = useTranslation();
// Re-renders once the app binary has said what it can do.
useNativeCapabilities();
const [busy, setBusy] = useState(false);
if (reservationId === undefined || !canAddToWallet()) return null;
const id = reservationId;
const add = async () => {
if (busy) return;
setBusy(true);
try {
const outcome = await addReservationToWallet(tripId, id, language);
if (outcome === 'undated') onError(t('reservations.wallet.undated'));
else if (outcome === 'failed') onError(t('reservations.wallet.failed'));
} finally {
setBusy(false);
}
};
const button = (
<button
type="button"
onClick={() => void add()}
disabled={busy}
aria-busy={busy}
className="flex items-center gap-[6px] rounded-full bg-black px-3 py-[7px] text-[length:calc(12px*var(--fs-scale-body,1))] font-semibold text-white transition-transform active:scale-[.97] disabled:opacity-70" // theme-lint-disable: Apple Wallet's badge is black in either theme
>
{busy ? <Loader2 size={13} className="animate-spin" /> : <Wallet size={13} strokeWidth={2} />}
{t('reservations.wallet.add')}
</button>
);
return className ? <div className={className}>{button}</div> : button;
}
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react'
import { Link2, Ticket } from 'lucide-react'
import MSheet from '../../../components/MSheet'
import MWalletButton from '../../../components/MWalletButton'
import { MBookingCodeStatus, MBookingNotes, MBookingTravelers } from './MBookingFields'
import { useAddonStore } from '../../../../store/addonStore'
import { useTranslation } from '../../../../i18n'
@@ -36,6 +37,9 @@ const EMPTY = {
hotel_address: '',
}
/** A booking that is saved already has an id; one being created has none. */
const savedId = (res: { id: number } | null | undefined): number | undefined => res?.id
/**
* Add/edit booking sheet — the mobile counterpart of the desktop ReservationModal,
* driven by the planner's own editor flags (showReservationModal / editingReservation /
@@ -187,6 +191,9 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati
/>
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[6px] pt-[2px]">
{/* A saved booking goes into Apple Wallet (iOS app only). */}
<MWalletButton tripId={tripId} reservationId={savedId(res)} onError={toast.error} className="mb-1 mt-2 flex" />
{/* BOOKING TYPE */}
<Eyebrow className="mb-[6px] mt-2 uppercase">{t('reservations.bookingType')}</Eyebrow>
<div className="flex flex-wrap gap-[6px]">
@@ -398,7 +405,7 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati
{/* FILES + COSTS */}
<MBookingFilesCosts
planner={planner}
reservationId={res?.id}
reservationId={savedId(res)}
pendingFiles={pendingFiles}
setPendingFiles={setPendingFiles}
canUploadFiles={canUploadFiles}
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import { ArrowRight, Footprints, Paperclip, Pencil, Route as RouteIcon, Trash2 } from 'lucide-react'
import MSheet from '../../../components/MSheet'
import MWalletButton from '../../../components/MWalletButton'
import type { MTripSheetsProps } from '../MTripShell'
import { useTranslation } from '../../../../i18n'
import { useSettingsStore } from '../../../../store/settingsStore'
@@ -348,6 +349,7 @@ export default function MTransportSheet({ planner, shell }: MTripSheetsProps) {
<RouteIcon size={13} strokeWidth={2} />
{t('mobileTrip.onMap')}
</button>
<MWalletButton tripId={planner.tripId} reservationId={payload.reservationId} onError={planner.toast.error} />
{resFiles.map(f => (
<button
key={f.id}
+35 -1
View File
@@ -9,7 +9,7 @@ vi.mock('./capabilities', () => ({ hasNativeFeature: (name: string) => name ===
vi.mock('./trekShell', () => ({ TrekShell: shell }));
vi.mock('./reportFailure', () => ({ reportNativeFailure: report }));
import { addToWallet, canAddToWallet, isWalletPassName } from './wallet';
import { addReservationToWallet, addToWallet, canAddToWallet, isWalletPassName } from './wallet';
const pass = new Blob(['PK\u0003\u0004pass'], { type: 'application/vnd.apple.pkpass' });
@@ -62,3 +62,37 @@ describe('Apple Wallet', () => {
expect(shell.addToWallet).not.toHaveBeenCalled();
});
});
describe('a booking into Wallet', () => {
const answer = (status: number) =>
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(status === 200 ? pass : '{}', { status }));
it('fetches the booking pass in the user language and hands it to Wallet', async () => {
const fetchSpy = answer(200);
shell.addToWallet.mockResolvedValue({ result: 'added' });
expect(await addReservationToWallet(42, 7, 'de')).toBe('added');
expect(fetchSpy).toHaveBeenCalledWith('/api/trips/42/reservations/7/wallet-pass?lang=de', {
credentials: 'include',
});
fetchSpy.mockRestore();
});
it('tells an undated booking from a failure', async () => {
let fetchSpy = answer(422);
expect(await addReservationToWallet(42, 7, 'en')).toBe('undated');
fetchSpy.mockRestore();
fetchSpy = answer(502);
expect(await addReservationToWallet(42, 7, 'en')).toBe('failed');
fetchSpy.mockRestore();
fetchSpy = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('offline'));
expect(await addReservationToWallet(42, 7, 'en')).toBe('failed');
fetchSpy.mockRestore();
});
it('fails when Wallet does not take the pass', async () => {
const fetchSpy = answer(200);
shell.addToWallet.mockRejectedValue({ code: 'INVALID' });
expect(await addReservationToWallet(42, 7, 'en')).toBe('failed');
fetchSpy.mockRestore();
});
});
+27
View File
@@ -32,3 +32,30 @@ export async function addToWallet(blob: Blob): Promise<boolean> {
return false;
}
}
/** What a tap on "Add to Apple Wallet" came to. */
export type ReservationWalletOutcome = 'added' | 'undated' | 'failed';
/**
* A booking's pass, made by this server (GET .../reservations/:id/wallet-pass,
* signed by the TREK services) and handed to Apple Wallet's sheet. `undated`:
* the booking has no date yet, which Wallet needs to bring the pass up.
*/
export async function addReservationToWallet(
tripId: number,
reservationId: number,
language: string
): Promise<ReservationWalletOutcome> {
let response: Response;
try {
response = await fetch(
`/api/trips/${tripId}/reservations/${reservationId}/wallet-pass?lang=${encodeURIComponent(language)}`,
{ credentials: 'include' }
);
} catch {
return 'failed';
}
if (response.status === 422) return 'undated';
if (!response.ok) return 'failed';
return (await addToWallet(await response.blob())) ? 'added' : 'failed';
}
+1
View File
@@ -66,6 +66,7 @@ services:
# - TREK_SERVICES_KEY= # Optional. A key issued for this installation; it wins over the stored one and turns self-registration off.
# - TREK_PHOTON_URL=https://photon.liketrek.com # Optional. The address search of the TREK services. With a key, search and reverse lookups ask it before Nominatim and fall back to Nominatim when it does not answer; never asked when NOMINATIM_URL is set. (default: https://photon.liketrek.com)
# - TREK_VALHALLA_URL=https://valhalla.liketrek.com # Optional. The Valhalla of the TREK services, which this server relays routing to unless the admin picks another routing service; the browser never talks to it. (default: https://valhalla.liketrek.com)
# - TREK_WALLET_PASS_URL=https://push.liketrek.com/v1/wallet/pass # Optional. Signs the Apple Wallet pass of a booking when someone taps "Add to Apple Wallet" in the iOS app; only the pass's texts go there, once per tap, and are not stored. Never asked with TREK_SERVICES=off. (default: https://push.liketrek.com/v1/wallet/pass)
# - PLACES_API_KEY= # Optional. Google Maps API key (Places API (New) enabled) for place search, photos, details and Google transit. Overrides the key set in Admin > Settings > API Keys, whose field then shows this variable read-only.
# - GOOGLE_LIST_DATA_BASE= # Optional. HTTP(S) base URL of a list-data service. Pasted URLs beginning with this base are imported directly; unset keeps the Google flow.
# - AMAP_API_KEY= # Optional. Amap (高德地图) Web 服务 key for place search and geocoding inside mainland China. Overrides the key set in Admin > Settings > API Keys. A Web 端 (JS API) key is a different credential and is rejected with INVALID_USER_KEY.
+1
View File
@@ -76,6 +76,7 @@ DEMO_MODE=false # Demo mode: resets data hourly
# TREK_SERVICES_HOSTS=places.liketrek.com,photon.liketrek.com,valhalla.liketrek.com # Comma-separated hosts that receive the installation key over https. No other host ever sees it. (default: places.liketrek.com,photon.liketrek.com,valhalla.liketrek.com)
# TREK_PHOTON_URL=https://photon.liketrek.com # The address search (Photon) of the TREK services. Once the installation holds a key, search and reverse lookups ask it before Nominatim and fall back to Nominatim whenever it does not answer. Never asked with TREK_SERVICES=off, without a key, or when NOMINATIM_URL is set. (default: https://photon.liketrek.com)
# TREK_VALHALLA_URL=https://valhalla.liketrek.com # The Valhalla of the TREK services. Routes and elevation are asked of it unless Admin > Default user settings picks another routing service; the browser never talks to it, this server relays its questions with the installation key. (default: https://valhalla.liketrek.com)
# TREK_WALLET_PASS_URL=https://push.liketrek.com/v1/wallet/pass # Signs the Apple Wallet pass of a booking when someone taps "Add to Apple Wallet" in the iOS app. Only liketrek holds the pass certificate, so the pass's texts (the booking's name, times, places, seat or room and confirmation code, nothing else) go there once per tap and are not stored. Never asked with TREK_SERVICES=off. (default: https://push.liketrek.com/v1/wallet/pass)
# PLACES_API_KEY= # Google Maps API key (Places API (New) enabled) for place search, photos, details and Google transit. Overrides the key set in Admin > Settings > API Keys, whose field then shows this variable read-only. The value never reaches the browser.
# GOOGLE_LIST_DATA_BASE= # Optional HTTP(S) base URL of a list-data service, e.g. https://maps.example.com. Paste a URL beginning with this base to fetch and parse it directly. Unset keeps the original Google list import flow.
+3
View File
@@ -69,6 +69,8 @@ const DEFAULT_SERVICE_HOSTS = ['places.liketrek.com', 'photon.liketrek.com', 'va
const DEFAULT_PHOTON_URL = 'https://photon.liketrek.com';
/** The TREK services' Valhalla, asked through this server's routing relay. */
const DEFAULT_VALHALLA_URL = 'https://valhalla.liketrek.com';
/** Signs the Apple Wallet passes of bookings; only liketrek holds the pass certificate. */
const DEFAULT_WALLET_PASS_URL = 'https://push.liketrek.com/v1/wallet/pass';
/**
* How this installation identifies itself to the liketrek services. Unset is
@@ -84,6 +86,7 @@ export function deriveServices(raw: RawEnv) {
hosts: hosts?.length ? hosts : DEFAULT_SERVICE_HOSTS,
photonUrl: stripTrailingSlashes(raw.TREK_PHOTON_URL?.trim() || DEFAULT_PHOTON_URL),
valhallaUrl: stripTrailingSlashes(raw.TREK_VALHALLA_URL?.trim() || DEFAULT_VALHALLA_URL),
walletPassUrl: raw.TREK_WALLET_PASS_URL?.trim() || DEFAULT_WALLET_PASS_URL,
};
}
+1
View File
@@ -191,6 +191,7 @@ export const envSchema = z.object({
TREK_SERVICES_HOSTS: anyString,
TREK_PHOTON_URL: url,
TREK_VALHALLA_URL: url,
TREK_WALLET_PASS_URL: url,
PLACES_API_KEY: anyString,
AMAP_API_BASE: url,
AMAP_API_KEY: anyString,
@@ -1,13 +1,16 @@
import type { Reservations } from '../entities/Reservations.entity';
import { castIntegerKysely, concatKysely, startsWithIsoDateKysely, substringKysely } from '../dialect/kysely-functions';
import { coalesceOverride, columnRef, dayDistance } from '../dialect/sql-functions';
import { DayAssignments } from '../entities/DayAssignments.entity';
import { Days } from '../entities/Days.entity';
import { coalesceOverride, columnRef, dayDistance } from '../dialect/sql-functions';
import { castIntegerKysely, concatKysely, startsWithIsoDateKysely, substringKysely } from '../dialect/kysely-functions';
import { publicReservationExpr, publicStayExists, type ReservationVisibilityKyselyDB } from './_shared/reservation-visibility';
import type { DayAssignmentRow } from './day-assignment-rows';
import { TrekRepository } from './_shared/trek-repository';
import type { Reservations } from '../entities/Reservations.entity';
import type { DB } from '../kysely/db';
import { ROADTRIP_RESERVATION_TYPES } from '@trek/shared';
import {
publicReservationExpr,
publicStayExists,
type ReservationVisibilityKyselyDB,
} from './_shared/reservation-visibility';
import { TrekRepository } from './_shared/trek-repository';
import type { DayAssignmentRow } from './day-assignment-rows';
import type {
PublicApiAccommodationKyselyDB,
PublicApiAccommodationRow,
@@ -17,6 +20,7 @@ import type {
PublicApiUnplannedPlaceRow,
PublicApiUnscheduledReservationRow,
} from './reservation-public-api-rows';
import { ROADTRIP_RESERVATION_TYPES } from '@trek/shared';
// The public API row types moved to their own module; re-exported so callers keep one import path.
export type {
@@ -64,7 +68,9 @@ type ReservationJoinKyselyDB = Pick<DB, 'reservations' | 'days' | 'places' | 'da
* TEXT column, widened to `string | number | null` so the plain `number`
* their statements bind against it type-checks (R2's ruling).
*/
type ReservationsNumericAccommodationBind = Omit<DB['reservations'], 'accommodation_id'> & { accommodation_id: string | number | null };
type ReservationsNumericAccommodationBind = Omit<DB['reservations'], 'accommodation_id'> & {
accommodation_id: string | number | null;
};
/**
* Kysely typing for `restampLinkedReservation` (DY23). `accommodation_id:
@@ -218,7 +224,8 @@ export interface CalendarStayRow {
reservation_title: string | null;
}
type CalendarStayKyselyDB = ReservationVisibilityKyselyDB & Pick<DB, 'day_accommodations' | 'reservations' | 'days' | 'places'>;
type CalendarStayKyselyDB = ReservationVisibilityKyselyDB &
Pick<DB, 'day_accommodations' | 'reservations' | 'days' | 'places'>;
/**
* RS20 (`ReservationsService.listUpcoming`) — the CTE + `UNION ALL`
@@ -268,7 +275,10 @@ interface EntriesRow extends UpcomingReservationRow {
at_time: string | null;
}
type UpcomingReservationsKyselyDB = Pick<DB, 'trips' | 'trip_members' | 'reservations' | 'days' | 'places' | 'day_accommodations'>;
type UpcomingReservationsKyselyDB = Pick<
DB,
'trips' | 'trip_members' | 'reservations' | 'days' | 'places' | 'day_accommodations'
>;
/**
* `share.service.ts:251` (`publicEndpointsByReservation`) — `SELECT
@@ -378,7 +388,42 @@ type SharePublicAccommodationKyselyDB = ReservationVisibilityKyselyDB & Pick<DB,
*/
type FlightsMissingEndpointsKyselyDB = Pick<DB, 'reservations' | 'reservation_endpoints'>;
/** `findPassContext`: the booking's trip title and the coordinates of its place or stay. */
type PassContextKyselyDB = Pick<DB, 'reservations' | 'trips' | 'places' | 'day_accommodations'>;
/** What a booking's Wallet pass needs beyond the booking: where it is, and the trip's name. */
export interface ReservationPassContext {
trip_title: string | null;
place_lat: number | null;
place_lng: number | null;
place_name: string | null;
}
export class ReservationsRepository extends TrekRepository<Reservations> {
/**
* The trip's title and the place a booking is at (its own place, else its
* stay's), for its Apple Wallet pass (WalletPassService). Scoped to the trip,
* so a booking of another trip reads as none.
*/
async findPassContext(id: number, trip_id: number): Promise<ReservationPassContext | undefined> {
const row = await this.kysely<PassContextKyselyDB>()
.selectFrom('reservations as r')
.innerJoin('trips as t', 't.id', 'r.trip_id')
.leftJoin('places as p', 'p.id', 'r.place_id')
.leftJoin('day_accommodations as ap', 'ap.id', 'r.accommodation_id')
.leftJoin('places as acc_p', 'acc_p.id', 'ap.place_id')
.where('r.id', '=', id)
.where('r.trip_id', '=', trip_id)
.select([
't.title as trip_title',
(eb) => eb.fn.coalesce('p.lat', 'acc_p.lat').as('place_lat'),
(eb) => eb.fn.coalesce('p.lng', 'acc_p.lng').as('place_lng'),
(eb) => eb.fn.coalesce('p.name', 'acc_p.name').as('place_name'),
])
.executeTakeFirst();
return row as ReservationPassContext | undefined;
}
private joinedQuery() {
return this.kysely<ReservationJoinKyselyDB>()
.selectFrom('reservations as r')
@@ -415,7 +460,9 @@ export class ReservationsRepository extends TrekRepository<Reservations> {
* projection for one reservation, no `ORDER BY` (a single row).
*/
async findWithJoins(id: number | string): Promise<ReservationJoinRow | undefined> {
const row = await this.joinedQuery().where('r.id', '=', id as number).executeTakeFirst();
const row = await this.joinedQuery()
.where('r.id', '=', id as number)
.executeTakeFirst();
return row as ReservationJoinRow | undefined;
}
@@ -435,11 +482,17 @@ export class ReservationsRepository extends TrekRepository<Reservations> {
}
/** RS45 (`ReservationsService.remove`'s pre-image read) — `SELECT id, title, type, accommodation_id FROM reservations WHERE id = ? AND trip_id = ?`. */
async findHeaderInTrip(id: number, trip_id: number): Promise<{ id: number; title: string; type: string | null; accommodation_id: string | null } | undefined> {
async findHeaderInTrip(
id: number,
trip_id: number,
): Promise<{ id: number; title: string; type: string | null; accommodation_id: string | null } | undefined> {
return this.qb('r')
.select(['r.id', 'r.title', 'r.type', 'r.accommodation_id'])
.where({ id, trip: trip_id })
.execute<{ id: number; title: string; type: string | null; accommodation_id: string | null } | undefined>('get', false);
.execute<{ id: number; title: string; type: string | null; accommodation_id: string | null } | undefined>(
'get',
false,
);
}
/**
@@ -448,19 +501,38 @@ export class ReservationsRepository extends TrekRepository<Reservations> {
* reservations WHERE trip_id = ? AND (type != 'hotel' OR accommodation_id
* IS NULL) AND reservation_time IS NOT NULL`.
*/
async listResyncCandidates(trip_id: number): Promise<{
id: number; reservation_time: string | null; reservation_end_time: string | null;
day_id: number | null; end_day_id: number | null;
}[]> {
async listResyncCandidates(trip_id: number): Promise<
{
id: number;
reservation_time: string | null;
reservation_end_time: string | null;
day_id: number | null;
end_day_id: number | null;
}[]
> {
const platform = this.getEntityManager().getPlatform();
return this.qb('r')
.select(['r.id', 'r.reservation_time', 'r.reservation_end_time', columnRef(platform, 'r.day_id').as('day_id'), columnRef(platform, 'r.end_day_id').as('end_day_id')])
.select([
'r.id',
'r.reservation_time',
'r.reservation_end_time',
columnRef(platform, 'r.day_id').as('day_id'),
columnRef(platform, 'r.end_day_id').as('end_day_id'),
])
.where({
trip: trip_id,
$or: [{ type: { $ne: 'hotel' } }, { accommodation_id: null }],
reservation_time: { $ne: null },
})
.execute<{ id: number; reservation_time: string | null; reservation_end_time: string | null; day_id: number | null; end_day_id: number | null }[]>('all', false);
.execute<
{
id: number;
reservation_time: string | null;
reservation_end_time: string | null;
day_id: number | null;
end_day_id: number | null;
}[]
>('all', false);
}
/** RS13 (`resyncReservationDays`'s per-row write) — `UPDATE reservations SET day_id = ?, end_day_id = ? WHERE id = ?`. */
@@ -530,42 +602,48 @@ export class ReservationsRepository extends TrekRepository<Reservations> {
* is handed — no SQL-side COALESCE. `id: number` (rule 21 — the SAME
* `toRowId`-parsed value `findInTrip`'s gate read used).
*/
async updateReservation(id: number, write: {
title: string;
reservation_time: string | null;
reservation_end_time: string | null;
location: string | null;
confirmation_number: string | null;
notes: string | null;
url: string | null;
day_id: number | null;
end_day_id: number | null;
place_id: number | null;
assignment_id: number | null;
status: string;
type: string;
accommodation_id: string | null;
metadata: string | null;
needs_review: number;
}): Promise<void> {
await this.nativeUpdate({ id }, {
title: write.title,
reservation_time: write.reservation_time,
reservation_end_time: write.reservation_end_time,
location: write.location,
confirmation_number: write.confirmation_number,
notes: write.notes,
url: write.url,
day: write.day_id,
endDay: write.end_day_id,
place: write.place_id,
assignment: write.assignment_id,
status: write.status,
type: write.type,
accommodation_id: write.accommodation_id,
metadata: write.metadata,
needs_review: write.needs_review,
});
async updateReservation(
id: number,
write: {
title: string;
reservation_time: string | null;
reservation_end_time: string | null;
location: string | null;
confirmation_number: string | null;
notes: string | null;
url: string | null;
day_id: number | null;
end_day_id: number | null;
place_id: number | null;
assignment_id: number | null;
status: string;
type: string;
accommodation_id: string | null;
metadata: string | null;
needs_review: number;
},
): Promise<void> {
await this.nativeUpdate(
{ id },
{
title: write.title,
reservation_time: write.reservation_time,
reservation_end_time: write.reservation_end_time,
location: write.location,
confirmation_number: write.confirmation_number,
notes: write.notes,
url: write.url,
day: write.day_id,
endDay: write.end_day_id,
place: write.place_id,
assignment: write.assignment_id,
status: write.status,
type: write.type,
accommodation_id: write.accommodation_id,
metadata: write.metadata,
needs_review: write.needs_review,
},
);
}
/**
@@ -585,15 +663,34 @@ export class ReservationsRepository extends TrekRepository<Reservations> {
}
/** DY14 (`DaysService.restampReservationDates`) — `SELECT id, day_id, end_day_id, reservation_time, reservation_end_time FROM reservations WHERE trip_id = ?`. */
async listForRestamp(trip_id: number): Promise<{
id: number; day_id: number | null; end_day_id: number | null;
reservation_time: string | null; reservation_end_time: string | null;
}[]> {
async listForRestamp(trip_id: number): Promise<
{
id: number;
day_id: number | null;
end_day_id: number | null;
reservation_time: string | null;
reservation_end_time: string | null;
}[]
> {
const platform = this.getEntityManager().getPlatform();
return this.qb('r')
.select(['r.id', columnRef(platform, 'r.day_id').as('day_id'), columnRef(platform, 'r.end_day_id').as('end_day_id'), 'r.reservation_time', 'r.reservation_end_time'])
.select([
'r.id',
columnRef(platform, 'r.day_id').as('day_id'),
columnRef(platform, 'r.end_day_id').as('end_day_id'),
'r.reservation_time',
'r.reservation_end_time',
])
.where({ trip: trip_id })
.execute<{ id: number; day_id: number | null; end_day_id: number | null; reservation_time: string | null; reservation_end_time: string | null }[]>('all', false);
.execute<
{
id: number;
day_id: number | null;
end_day_id: number | null;
reservation_time: string | null;
reservation_end_time: string | null;
}[]
>('all', false);
}
/** DY15 — `UPDATE reservations SET reservation_time = ? WHERE id = ?`. */
@@ -632,7 +729,14 @@ export class ReservationsRepository extends TrekRepository<Reservations> {
.case()
.when('reservation_time', 'is', null)
.then(eb.val(date))
.else(concatKysely(platform, eb, { value: date }, { expression: substringKysely(platform, eb, 'reservation_time', 11) }))
.else(
concatKysely(
platform,
eb,
{ value: date },
{ expression: substringKysely(platform, eb, 'reservation_time', 11) },
),
)
.end(),
}))
.where('accommodation_id', '=', accommodation_id)
@@ -980,7 +1084,12 @@ export class ReservationsRepository extends TrekRepository<Reservations> {
}
/** RS20 — see {@link UpcomingReservationRow}'s docstring. */
async listUpcomingForUser(user_id: number, today: string, now_hhmm: string, limit: number): Promise<UpcomingReservationRow[]> {
async listUpcomingForUser(
user_id: number,
today: string,
now_hhmm: string,
limit: number,
): Promise<UpcomingReservationRow[]> {
const platform = this.getEntityManager().getPlatform();
// The `COALESCE(place name, nameless-stay's-linked-booking title, trip
@@ -995,10 +1104,15 @@ export class ReservationsRepository extends TrekRepository<Reservations> {
.with('visible_trips', (qb) =>
qb
.selectFrom('trips as t')
.leftJoin('trip_members as tm', (join) => join.onRef('tm.trip_id', '=', 't.id').on('tm.user_id', '=', user_id))
.leftJoin('trip_members as tm', (join) =>
join.onRef('tm.trip_id', '=', 't.id').on('tm.user_id', '=', user_id),
)
.select(['t.id', 't.title', 't.cover_image'])
.where((eb) =>
eb.and([eb.or([eb('t.user_id', '=', user_id), eb('tm.user_id', 'is not', null)]), eb('t.is_archived', '=', 0)]),
eb.and([
eb.or([eb('t.user_id', '=', user_id), eb('tm.user_id', 'is not', null)]),
eb('t.is_archived', '=', 0),
]),
),
)
.with('entries', (qb) => {
@@ -1048,8 +1162,8 @@ export class ReservationsRepository extends TrekRepository<Reservations> {
.select((eb) => [
'a.id as id',
'a.trip_id as trip_id',
eb
.fn.coalesce(
eb.fn
.coalesce(
eb.ref('p.name'),
eb
.selectFrom('reservations as res')
@@ -1089,8 +1203,8 @@ export class ReservationsRepository extends TrekRepository<Reservations> {
.select((eb) => [
'a.id as id',
'a.trip_id as trip_id',
eb
.fn.coalesce(
eb.fn
.coalesce(
eb.ref('p.name'),
eb
.selectFrom('reservations as res')
@@ -1125,7 +1239,21 @@ export class ReservationsRepository extends TrekRepository<Reservations> {
return bookings.unionAll(checkins).unionAll(checkouts);
})
.selectFrom('entries')
.select(['id', 'trip_id', 'title', 'type', 'status', 'location', 'reservation_time', 'confirmation_number', 'trip_title', 'trip_cover', 'day_date', 'place_name', 'place_image'])
.select([
'id',
'trip_id',
'title',
'type',
'status',
'location',
'reservation_time',
'confirmation_number',
'trip_title',
'trip_cover',
'day_date',
'place_name',
'place_image',
])
.where('at_date', 'is not', null)
.where((eb) =>
eb.or([
@@ -1146,7 +1274,18 @@ export class ReservationsRepository extends TrekRepository<Reservations> {
const rows = await this.kysely<ShareEndpointsKyselyDB>()
.selectFrom('reservation_endpoints as e')
.innerJoin('reservations as r', 'r.id', 'e.reservation_id')
.select(['e.reservation_id', 'e.role', 'e.sequence', 'e.name', 'e.code', 'e.lat', 'e.lng', 'e.timezone', 'e.local_date', 'e.local_time'])
.select([
'e.reservation_id',
'e.role',
'e.sequence',
'e.name',
'e.code',
'e.lat',
'e.lng',
'e.timezone',
'e.local_date',
'e.local_time',
])
.where('r.trip_id', '=', trip_id as number)
.orderBy('e.reservation_id', 'asc')
.orderBy('e.sequence', 'asc')
@@ -1170,9 +1309,22 @@ export class ReservationsRepository extends TrekRepository<Reservations> {
const rows = await this.kysely<SharePublicReservationKyselyDB>()
.selectFrom('reservations as r')
.select([
'r.id', 'r.trip_id', 'r.day_id', 'r.end_day_id', 'r.place_id', 'r.accommodation_id',
'r.title', 'r.type', 'r.status', 'r.location', 'r.reservation_time', 'r.reservation_end_time',
'r.notes', 'r.url', 'r.metadata', 'r.created_at',
'r.id',
'r.trip_id',
'r.day_id',
'r.end_day_id',
'r.place_id',
'r.accommodation_id',
'r.title',
'r.type',
'r.status',
'r.location',
'r.reservation_time',
'r.reservation_end_time',
'r.notes',
'r.url',
'r.metadata',
'r.created_at',
])
.where('r.trip_id', '=', trip_id as number)
.where((eb) => publicReservationExpr(eb, 'r.ingest_state'))
@@ -1189,9 +1341,19 @@ export class ReservationsRepository extends TrekRepository<Reservations> {
.where((eb) => publicStayExists(eb))
.innerJoin('places as p', 'p.id', 'a.place_id')
.select([
'a.id', 'a.trip_id', 'a.place_id', 'a.start_day_id', 'a.end_day_id',
'a.check_in', 'a.check_in_end', 'a.check_out', 'a.notes',
'p.name as place_name', 'p.address as place_address', 'p.lat as place_lat', 'p.lng as place_lng',
'a.id',
'a.trip_id',
'a.place_id',
'a.start_day_id',
'a.end_day_id',
'a.check_in',
'a.check_in_end',
'a.check_out',
'a.notes',
'p.name as place_name',
'p.address as place_address',
'p.lat as place_lat',
'p.lng as place_lng',
])
.execute();
return rows as SharePublicAccommodationRow[];
@@ -1247,7 +1409,17 @@ export class ReservationsRepository extends TrekRepository<Reservations> {
.leftJoin('places as p', 'p.id', 'a.place_id')
.leftJoin('days as ds', 'ds.id', 'a.start_day_id')
.leftJoin('days as de', 'de.id', 'a.end_day_id')
.select(['p.name', 'p.address', 'p.lat', 'p.lng', 'ds.date as start_date', 'de.date as end_date', 'a.check_in', 'a.check_out', 'a.notes'])
.select([
'p.name',
'p.address',
'p.lat',
'p.lng',
'ds.date as start_date',
'de.date as end_date',
'a.check_in',
'a.check_out',
'a.notes',
])
.where('a.trip_id', '=', trip_id)
.orderBy('ds.date', 'asc')
.execute();
@@ -1260,12 +1432,24 @@ export class ReservationsRepository extends TrekRepository<Reservations> {
.selectFrom('places as p')
.leftJoin('categories as c', 'c.id', 'p.category_id')
.select((eb) => [
'p.name', 'p.address', 'p.lat', 'p.lng', 'p.place_time', 'p.end_time',
'p.duration_minutes', 'p.notes', 'p.transport_mode', 'c.name as category',
'p.name',
'p.address',
'p.lat',
'p.lng',
'p.place_time',
'p.end_time',
'p.duration_minutes',
'p.notes',
'p.transport_mode',
'c.name as category',
])
.where('p.trip_id', '=', trip_id)
.where((eb) => eb.not(eb.exists(eb.selectFrom('day_assignments as da').select('da.id').whereRef('da.place_id', '=', 'p.id'))))
.where((eb) => eb.not(eb.exists(eb.selectFrom('day_accommodations as a').select('a.id').whereRef('a.place_id', '=', 'p.id'))))
.where((eb) =>
eb.not(eb.exists(eb.selectFrom('day_assignments as da').select('da.id').whereRef('da.place_id', '=', 'p.id'))),
)
.where((eb) =>
eb.not(eb.exists(eb.selectFrom('day_accommodations as a').select('a.id').whereRef('a.place_id', '=', 'p.id'))),
)
.orderBy('p.created_at', 'asc')
.orderBy('p.id', 'asc')
.execute();
@@ -1305,7 +1489,10 @@ export class ReservationsRepository extends TrekRepository<Reservations> {
// ---------------------------------------------------------------------------
/** BG54/BG85 — `SELECT id, metadata FROM reservations WHERE id = ? AND trip_id = ?`. */
async getIdAndMetadata(id: number | string, trip_id: number | string): Promise<{ id: number; metadata: string | null } | undefined> {
async getIdAndMetadata(
id: number | string,
trip_id: number | string,
): Promise<{ id: number; metadata: string | null } | undefined> {
return await this.kysely<Pick<DB, 'reservations'>>()
.selectFrom('reservations')
.select(['id', 'metadata'])
@@ -1352,7 +1539,9 @@ export class ReservationsRepository extends TrekRepository<Reservations> {
}
/** ATI2 (`airtrail-import.service.ts#importAirtrailFlights`, existing-flight signature dedup) — `SELECT r.id, r.reservation_time, r.metadata FROM reservations r WHERE r.trip_id = ? AND r.type = 'flight'`. */
async listFlightReservationsForTrip(trip_id: number): Promise<{ id: number; reservation_time: string | null; metadata: string | null }[]> {
async listFlightReservationsForTrip(
trip_id: number,
): Promise<{ id: number; reservation_time: string | null; metadata: string | null }[]> {
return this.qb('r')
.select(['r.id', 'r.reservation_time', 'r.metadata'])
.where({ trip: trip_id, type: 'flight' })
@@ -1370,8 +1559,16 @@ export class ReservationsRepository extends TrekRepository<Reservations> {
* untracked reservation. Preserved exactly, not "fixed" with an added
* `uow.transactional` wrap.
*/
async linkAirtrailMultiLeg(id: number, external_id: string, external_owner_user_id: number, external_synced_at: string): Promise<void> {
await this.nativeUpdate({ id }, { external_source: 'airtrail', external_id, external_owner_user_id, sync_enabled: 0, external_synced_at });
async linkAirtrailMultiLeg(
id: number,
external_id: string,
external_owner_user_id: number,
external_synced_at: string,
): Promise<void> {
await this.nativeUpdate(
{ id },
{ external_source: 'airtrail', external_id, external_owner_user_id, sync_enabled: 0, external_synced_at },
);
}
/**
@@ -1381,8 +1578,24 @@ export class ReservationsRepository extends TrekRepository<Reservations> {
* external_hash = ?, external_synced_at = ? WHERE id = ?`. Same
* pre-existing tx gap as {@link linkAirtrailMultiLeg} — report don't fix.
*/
async linkAirtrailSingleFlight(id: number, external_id: string, external_owner_user_id: number, external_hash: string, external_synced_at: string): Promise<void> {
await this.nativeUpdate({ id }, { external_source: 'airtrail', external_id, external_owner_user_id, sync_enabled: 1, external_hash, external_synced_at });
async linkAirtrailSingleFlight(
id: number,
external_id: string,
external_owner_user_id: number,
external_hash: string,
external_synced_at: string,
): Promise<void> {
await this.nativeUpdate(
{ id },
{
external_source: 'airtrail',
external_id,
external_owner_user_id,
sync_enabled: 1,
external_hash,
external_synced_at,
},
);
}
/** ATL2 (`airtrail-link.service.ts#detach`) — `UPDATE reservations SET sync_enabled = 0 WHERE id = ?`. */
@@ -1397,12 +1610,36 @@ export class ReservationsRepository extends TrekRepository<Reservations> {
* via `columnRef` (the `listResyncCandidates` precedent above) — the
* mirror column, not a bare select.
*/
async findAirtrailLinked(id: number): Promise<{ id: number; trip_id: number; external_id: string | null; external_owner_user_id: number | null; sync_enabled: number | null } | undefined> {
async findAirtrailLinked(id: number): Promise<
| {
id: number;
trip_id: number;
external_id: string | null;
external_owner_user_id: number | null;
sync_enabled: number | null;
}
| undefined
> {
const platform = this.getEntityManager().getPlatform();
return this.qb('r')
.select(['r.id', columnRef(platform, 'r.trip_id').as('trip_id'), 'r.external_id', 'r.external_owner_user_id', 'r.sync_enabled'])
.select([
'r.id',
columnRef(platform, 'r.trip_id').as('trip_id'),
'r.external_id',
'r.external_owner_user_id',
'r.sync_enabled',
])
.where({ id, external_source: 'airtrail' })
.execute<{ id: number; trip_id: number; external_id: string | null; external_owner_user_id: number | null; sync_enabled: number | null } | undefined>('get', false);
.execute<
| {
id: number;
trip_id: number;
external_id: string | null;
external_owner_user_id: number | null;
sync_enabled: number | null;
}
| undefined
>('get', false);
}
/**
@@ -1420,12 +1657,17 @@ export class ReservationsRepository extends TrekRepository<Reservations> {
* external_id, external_hash FROM reservations WHERE external_source =
* 'airtrail' AND sync_enabled = 1 AND external_owner_user_id = ?`.
*/
async listAirtrailSyncCandidatesForOwner(owner_user_id: number): Promise<{ id: number; trip_id: number; external_id: string | null; external_hash: string | null }[]> {
async listAirtrailSyncCandidatesForOwner(
owner_user_id: number,
): Promise<{ id: number; trip_id: number; external_id: string | null; external_hash: string | null }[]> {
const platform = this.getEntityManager().getPlatform();
return this.qb('r')
.select(['r.id', columnRef(platform, 'r.trip_id').as('trip_id'), 'r.external_id', 'r.external_hash'])
.where({ external_source: 'airtrail', sync_enabled: 1, external_owner_user_id: owner_user_id })
.execute<{ id: number; trip_id: number; external_id: string | null; external_hash: string | null }[]>('all', false);
.execute<{ id: number; trip_id: number; external_id: string | null; external_hash: string | null }[]>(
'all',
false,
);
}
/**
@@ -1452,12 +1694,20 @@ export class ReservationsRepository extends TrekRepository<Reservations> {
* EXISTS guard, same shape as `Reservations.repository.ts`'s own
* `listUnplannedPlacesForPublicApi` NOT EXISTS precedent (`eb.not(eb.exists(...))`).
*/
async listFlightsMissingEndpoints(): Promise<{ id: number; metadata: string | null; reservation_time: string | null; reservation_end_time: string | null }[]> {
async listFlightsMissingEndpoints(): Promise<
{ id: number; metadata: string | null; reservation_time: string | null; reservation_end_time: string | null }[]
> {
const rows = await this.kysely<FlightsMissingEndpointsKyselyDB>()
.selectFrom('reservations as r')
.select(['r.id', 'r.metadata', 'r.reservation_time', 'r.reservation_end_time'])
.where('r.type', '=', 'flight')
.where((eb) => eb.not(eb.exists(eb.selectFrom('reservation_endpoints as e').select('e.id').whereRef('e.reservation_id', '=', 'r.id'))))
.where((eb) =>
eb.not(
eb.exists(
eb.selectFrom('reservation_endpoints as e').select('e.id').whereRef('e.reservation_id', '=', 'r.id'),
),
),
)
.execute();
return rows;
}
@@ -1470,18 +1720,17 @@ export class ReservationsRepository extends TrekRepository<Reservations> {
/** RPL4/RPL5's shared select list, the columns of `RoadtripCarrierRow`. */
private roadtripCarrierQuery() {
const platform = this.getEntityManager().getPlatform();
return this.qb('r')
.select([
'r.id',
'r.type',
'r.title',
columnRef(platform, 'r.day_id').as('day_id'),
columnRef(platform, 'r.end_day_id').as('end_day_id'),
'r.reservation_time',
'r.reservation_end_time',
'r.metadata',
'r.day_plan_position',
]);
return this.qb('r').select([
'r.id',
'r.type',
'r.title',
columnRef(platform, 'r.day_id').as('day_id'),
columnRef(platform, 'r.end_day_id').as('end_day_id'),
'r.reservation_time',
'r.reservation_end_time',
'r.metadata',
'r.day_plan_position',
]);
}
/**
@@ -1504,9 +1753,7 @@ export class ReservationsRepository extends TrekRepository<Reservations> {
* NULL`. Which of them count as rides is `undatedRides` (shared) alone.
*/
async listRoadtripUndated(trip_id: number): Promise<RoadtripCarrierRow[]> {
return this.roadtripCarrierQuery()
.where({ trip: trip_id, day: null })
.execute<RoadtripCarrierRow[]>('all', false);
return this.roadtripCarrierQuery().where({ trip: trip_id, day: null }).execute<RoadtripCarrierRow[]>('all', false);
}
}
+2
View File
@@ -103,6 +103,7 @@ import { TransitModule } from './transit/transit.module';
import { TripInviteModule } from './trip-invite/trip-invite.module';
import { TripsModule } from './trips/trips.module';
import { VacayModule } from './vacay/vacay.module';
import { WalletPassModule } from './wallet-pass/wallet-pass.module';
import { WandererModule } from './wanderer/wanderer.module';
import { WeatherModule } from './weather/weather.module';
import { MikroOrmModule } from '@mikro-orm/nestjs';
@@ -189,6 +190,7 @@ import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
SettingsModule,
ServiceIdentityModule,
RoutingRelayModule,
WalletPassModule,
StorageModule,
BackupModule,
AuthModule,
@@ -5,7 +5,7 @@ import { Reservations } from '../../db/entities/Reservations.entity';
import type { ReservationDayPositionsRepository } from '../../db/repositories/ReservationDayPositions.repository';
import type { ReservationEndpointsRepository } from '../../db/repositories/ReservationEndpoints.repository';
import type { ReservationTravelersRepository } from '../../db/repositories/ReservationTravelers.repository';
import type { ReservationsRepository } from '../../db/repositories/Reservations.repository';
import type { ReservationPassContext, ReservationsRepository } from '../../db/repositories/Reservations.repository';
import { avatarUrl } from '../common/avatarUrl';
import type { ReservationRow, ReservationEndpoint, ReservationTraveler } from './reservations.service';
import { InjectRepository } from '@mikro-orm/nestjs';
@@ -72,6 +72,11 @@ export class ReservationsReadService {
return result;
}
/** The trip title and place coordinates a booking's Wallet pass shows (wallet-pass). */
passContext(id: number, tripId: number): Promise<ReservationPassContext | undefined> {
return this.reservationsRepo.findPassContext(id, tripId);
}
async loadEndpoints(reservationId: number): Promise<ReservationEndpoint[]> {
// RR2 — no ORDER BY difference: `ReservationEndpointsRepository
// .listForReservation` already orders by `sequence` (RR2's own shape).
@@ -0,0 +1,302 @@
import { createHash } from 'node:crypto';
import tzLookup from 'tz-lookup';
/**
* A booking as an Apple Wallet pass: what this server sends the TREK services'
* pass signer (push.liketrek.com/v1/wallet/pass), which builds and signs the
* .pkpass. Pure: the service hands the booking, its endpoints, the trip's title
* and the words in the user's language in; nothing here reads or fetches.
*
* The signer takes plain text of bounded length and refuses a spelled-out link,
* so every text is cleaned and shortened here rather than refused there.
*/
export type WalletPassKind =
'flight' | 'train' | 'bus' | 'boat' | 'car' | 'hotel' | 'restaurant' | 'event' | 'tour' | 'other';
export interface WalletPassField {
label: string;
value: string;
}
/** The signer's request body (control-plane/src/wallet-pass.ts in trek-hosted). */
export interface WalletPassRequest {
kind: WalletPassKind;
serial: string;
title: string;
trip?: string;
start: string;
end?: string;
from?: { code?: string; name: string };
to?: { code?: string; name: string };
location?: { latitude: number; longitude: number; name?: string };
details?: WalletPassField[];
back?: WalletPassField[];
confirmation?: WalletPassField;
locale?: string;
}
/** The labels a pass shows, in the user's language (wallet-pass.words.ts). */
export interface WalletWords {
locale: string;
departure: string;
arrival: string;
pickup: string;
dropOff: string;
checkIn: string;
checkOut: string;
time: string;
seat: string;
platform: string;
flightNumber: string;
trainNumber: string;
airline: string;
confirmation: string;
location: string;
}
export interface WalletPassEndpoint {
role: 'from' | 'to' | 'stop';
name: string;
code: string | null;
lat: number;
lng: number;
timezone: string | null;
local_time: string | null;
local_date: string | null;
}
export interface WalletPassSource {
reservation: {
id: number;
trip_id: number;
title: string;
type: string | null;
reservation_time?: string | null;
reservation_end_time?: string | null;
confirmation_number?: string | null;
location?: string | null;
created_at?: string | null;
metadata: Record<string, unknown>;
};
endpoints: WalletPassEndpoint[];
tripTitle: string | null;
/** The booking's place (the hotel, the restaurant), when it has coordinates. */
place: { lat: number; lng: number; name: string | null } | null;
}
const KIND_OF: Record<string, WalletPassKind> = {
flight: 'flight',
train: 'train',
bus: 'bus',
transit: 'bus',
cable_car: 'bus',
cruise: 'boat',
ferry: 'boat',
car: 'car',
taxi: 'car',
bicycle: 'car',
parking: 'car',
hotel: 'hotel',
restaurant: 'restaurant',
event: 'event',
tour: 'tour',
};
export function kindOf(type: string | null | undefined): WalletPassKind {
return (type && KIND_OF[type]) || 'other';
}
/** A text for the pass: no spelled-out links, single-spaced, at most `max` characters. */
export function clean(value: unknown, max: number): string | undefined {
if (value === null || value === undefined) return undefined;
const text = String(value)
.replace(/https?:\/\/\S+/gi, '')
.replace(/www\.\S+/gi, '')
.replace(/\s+/g, ' ')
.trim();
if (!text) return undefined;
return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text;
}
/** "2027-04-02T15:00" and "2027-04-02 15:00" as their date and time parts; a bare date gets no time. */
function splitLocal(value: string | null | undefined): { date: string; time: string | null } | null {
if (!value) return null;
const match = /^(\d{4}-\d{2}-\d{2})(?:[T ](\d{2}:\d{2}))?/.exec(value.trim());
if (!match) return null;
return { date: match[1] as string, time: match[2] ?? null };
}
/** The UTC offset of a zone at a wall-clock time, in minutes ("+09:00" is 540). */
function offsetMinutes(timeZone: string, wallClockAsUtc: number): number {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone,
hourCycle: 'h23',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).formatToParts(new Date(wallClockAsUtc));
const get = (type: string) => Number(parts.find((p) => p.type === type)?.value ?? 0);
const shown = Date.UTC(get('year'), get('month') - 1, get('day'), get('hour'), get('minute'));
return Math.round((shown - wallClockAsUtc) / 60_000);
}
/**
* A local date and time with the offset its zone has then: what Wallet needs to
* bring the pass up at the right moment. Without a zone the time is taken as UTC,
* which only shifts when the pass surfaces, never what it shows.
*/
export function withOffset(date: string, time: string | null, timeZone: string | null): string {
const clock = time ?? '00:00';
const asUtc = Date.parse(`${date}T${clock}:00Z`);
let offset = 0;
if (timeZone) {
try {
// Twice, so a wall-clock time right after a DST change takes the offset it really has.
offset = offsetMinutes(timeZone, asUtc - offsetMinutes(timeZone, asUtc) * 60_000);
} catch {
offset = 0;
}
}
const sign = offset < 0 ? '-' : '+';
const abs = Math.abs(offset);
const hh = String(Math.floor(abs / 60)).padStart(2, '0');
const mm = String(abs % 60).padStart(2, '0');
return `${date}T${clock}:00${sign}${hh}:${mm}`;
}
function zoneAt(lat: number | undefined, lng: number | undefined): string | null {
if (typeof lat !== 'number' || typeof lng !== 'number' || !Number.isFinite(lat) || !Number.isFinite(lng)) return null;
try {
return tzLookup(lat, lng);
} catch {
return null;
}
}
/** "Fri 2 Apr, 15:00" in the user's language, as the wall clock says it (no zone shift). */
export function displayTime(locale: string, date: string, time: string | null): string {
const asUtc = new Date(`${date}T${time ?? '00:00'}:00Z`);
const options: Intl.DateTimeFormatOptions = { timeZone: 'UTC', weekday: 'short', day: 'numeric', month: 'short' };
if (time) Object.assign(options, { hour: '2-digit', minute: '2-digit' });
try {
return new Intl.DateTimeFormat(locale, options).format(asUtc);
} catch {
return new Intl.DateTimeFormat('en', options).format(asUtc);
}
}
function text(meta: Record<string, unknown>, key: string, max = 64): string | undefined {
const value = meta[key];
return typeof value === 'string' || typeof value === 'number' ? clean(value, max) : undefined;
}
/** A stable serial for the booking, so adding its pass again replaces the one in Wallet. */
export function serialFor(reservation: WalletPassSource['reservation']): string {
const digest = createHash('sha256')
.update(`${reservation.trip_id}:${reservation.id}:${reservation.created_at ?? ''}`)
.digest('hex')
.slice(0, 16);
return `trek-${reservation.id}-${digest}`;
}
const CONFIRMATION = /^[A-Za-z0-9 ._/-]{1,40}$/;
/**
* The request for a booking's pass, or null when it has no date to bring the
* pass up on (a booking without a time is not something Wallet can surface).
*/
export function buildWalletPassRequest(src: WalletPassSource, words: WalletWords): WalletPassRequest | null {
const { reservation, endpoints } = src;
const meta = reservation.metadata;
const kind = kindOf(reservation.type);
const fromEnd = endpoints.find((e) => e.role === 'from');
const toEnd = [...endpoints].reverse().find((e) => e.role === 'to');
const journey = kind === 'flight' || kind === 'train' || kind === 'bus' || kind === 'boat';
// When it starts: the departure as the endpoint knows it, else the booking's own time.
const startLocal =
(journey && fromEnd?.local_date ? { date: fromEnd.local_date, time: fromEnd.local_time } : null) ??
splitLocal(reservation.reservation_time);
if (!startLocal) return null;
const endLocal =
(journey && toEnd?.local_date ? { date: toEnd.local_date, time: toEnd.local_time } : null) ??
splitLocal(reservation.reservation_end_time);
const place = src.place ?? (fromEnd && !journey ? { lat: fromEnd.lat, lng: fromEnd.lng, name: fromEnd.name } : null);
const startZone =
fromEnd?.timezone ?? zoneAt(journey ? fromEnd?.lat : place?.lat, journey ? fromEnd?.lng : place?.lng);
const endZone = toEnd?.timezone ?? zoneAt(toEnd?.lat ?? place?.lat, toEnd?.lng ?? place?.lng) ?? startZone;
const title = clean(reservation.title, 60) ?? clean(words.location, 60) ?? 'TREK';
const request: WalletPassRequest = {
kind,
serial: serialFor(reservation),
title,
start: withOffset(startLocal.date, startLocal.time, startZone),
locale: words.locale,
};
const trip = clean(src.tripTitle, 60);
if (trip) request.trip = trip;
if (endLocal) request.end = withOffset(endLocal.date, endLocal.time, endZone);
const details: WalletPassField[] = [];
const add = (label: string, value: string | undefined) => {
const v = clean(value, 64);
if (v && details.length < 4) details.push({ label: clean(label, 24) ?? label.slice(0, 24), value: v });
};
const when = (date: string, time: string | null) => displayTime(words.locale, date, time);
if (journey && fromEnd && toEnd) {
request.from = { name: clean(fromEnd.name, 40) ?? '?', ...(fromEnd.code ? { code: clean(fromEnd.code, 8) } : {}) };
request.to = { name: clean(toEnd.name, 40) ?? '?', ...(toEnd.code ? { code: clean(toEnd.code, 8) } : {}) };
add(words.departure, when(startLocal.date, startLocal.time));
if (kind === 'flight') {
const number = [text(meta, 'airline', 30), text(meta, 'flight_number', 12)].filter(Boolean).join(' ');
add(words.flightNumber, number || undefined);
}
if (kind === 'train') add(words.trainNumber, text(meta, 'train_number', 20));
add(words.seat, text(meta, 'seat', 16));
add(words.platform, text(meta, 'platform', 16));
if (endLocal) add(words.arrival, when(endLocal.date, endLocal.time));
} else if (kind === 'hotel') {
add(words.checkIn, when(startLocal.date, text(meta, 'check_in_time', 5) ?? startLocal.time));
if (endLocal) add(words.checkOut, when(endLocal.date, text(meta, 'check_out_time', 5) ?? endLocal.time));
} else if (kind === 'car') {
add(words.pickup, when(startLocal.date, startLocal.time));
if (endLocal) add(words.dropOff, when(endLocal.date, endLocal.time));
} else {
add(words.time, when(startLocal.date, startLocal.time));
}
// A journey without both ends is shown as a plain booking, which the signer accepts.
if (journey && !(request.from && request.to)) request.kind = 'other';
if (place && Number.isFinite(place.lat) && Number.isFinite(place.lng) && !journey) {
const name = clean(place.name ?? reservation.location, 60);
request.location = { latitude: place.lat, longitude: place.lng, ...(name ? { name } : {}) };
} else if (journey && fromEnd) {
request.location = {
latitude: fromEnd.lat,
longitude: fromEnd.lng,
...(fromEnd.name ? { name: clean(fromEnd.name, 60) } : {}),
};
}
if (details.length) request.details = details;
const back: WalletPassField[] = [];
const where = clean(reservation.location, 64);
if (where) back.push({ label: clean(words.location, 24) ?? 'Location', value: where });
const code = reservation.confirmation_number?.trim();
if (code && CONFIRMATION.test(code)) {
request.confirmation = { label: clean(words.confirmation, 24) ?? 'Confirmation', value: code };
} else if (code) {
const shown = clean(code, 64);
if (shown) back.push({ label: clean(words.confirmation, 24) ?? 'Confirmation', value: shown });
}
if (back.length) request.back = back;
return request;
}
@@ -0,0 +1,35 @@
import type { User } from '../../types';
import { CurrentUser } from '../auth-core/current-user.decorator';
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
import { TripAccessGuard } from '../permissions/trip-access.guard';
import { WalletPassService } from './wallet-pass.service';
import { walletLanguage } from './wallet-pass.words';
import { Controller, Get, Param, ParseIntPipe, Query, Res, UseGuards } from '@nestjs/common';
import type { Response } from 'express';
/**
* GET /api/trips/:tripId/reservations/:id/wallet-pass: the booking's Apple
* Wallet pass, which the iOS app hands to Wallet's own sheet (native/wallet.ts).
*/
@Controller('api/trips/:tripId/reservations')
// TripAccessGuard resolves :tripId and 404s a trip the user cannot reach.
@UseGuards(JwtAuthGuard, TripAccessGuard)
export class WalletPassController {
constructor(private readonly passes: WalletPassService) {}
// response-contract-exempt: answers the signed .pkpass bytes through @Res(), not JSON.
@Get(':id/wallet-pass')
async pass(
@CurrentUser() user: User,
@Param('tripId', ParseIntPipe) tripId: number,
@Param('id', ParseIntPipe) id: number,
@Query('lang') lang: string | undefined,
@Res() res: Response,
): Promise<void> {
const pass = await this.passes.passFor(user.id, tripId, id, walletLanguage(lang));
res.set('Cache-Control', 'no-store');
res.set('Content-Disposition', `attachment; filename="${pass.filename}"`);
res.status(200).type('application/vnd.apple.pkpass').send(pass.bytes);
}
}
@@ -0,0 +1,15 @@
import { AppConfigModule } from '../app-config/app-config.module';
import { RateLimitModule } from '../common/rate-limit.module';
import { PermissionsModule } from '../permissions/permissions.module';
import { ReservationsReadModule } from '../reservations/reservations-read.module';
import { WalletPassController } from './wallet-pass.controller';
import { WalletPassService } from './wallet-pass.service';
import { Module } from '@nestjs/common';
/** Apple Wallet passes for bookings, signed by the TREK services (wallet-pass.service.ts). */
@Module({
imports: [AppConfigModule, RateLimitModule, PermissionsModule, ReservationsReadModule],
controllers: [WalletPassController],
providers: [WalletPassService],
})
export class WalletPassModule {}
@@ -0,0 +1,153 @@
import { readCapped } from '../../utils/cappedFetch';
import { safeFetchAdminConfigured } from '../../utils/ssrfGuard';
import { servicesConfig } from '../app-config/tokens';
import { logWarn } from '../audit/audit-log.logger';
import { DomainError } from '../common/domain-error';
import { RateLimitService } from '../common/rate-limit.service';
import { ReservationsReadService } from '../reservations/reservations-read.service';
import { buildWalletPassRequest, type WalletPassEndpoint } from './wallet-pass.builder';
import { loadWalletWords } from './wallet-pass.words';
import { Inject, Injectable } from '@nestjs/common';
import type { ConfigType } from '@nestjs/config';
/** Per user and minute: a pass is one tap, this only stops a loop. */
const PASSES_PER_MINUTE = 20;
const RATE_WINDOW_MS = 60_000;
const TIMEOUT_MS = 10_000;
/** A pass is its texts plus TREK's icon and wordmark: some 25 KB. */
const MAX_PASS_BYTES = 512 * 1024;
const PKPASS = 'application/vnd.apple.pkpass';
/** Every ZIP, and so every .pkpass, starts with a local file header. */
const ZIP_LOCAL_HEADER = 0x04034b50;
export interface WalletPass {
bytes: Buffer;
filename: string;
}
/**
* A booking's Apple Wallet pass. Only liketrek holds the Pass Type ID
* certificate, so the pass's texts go to the TREK services' signer
* (TREK_WALLET_PASS_URL) once per tap and come back as a signed .pkpass; the
* signer stores nothing. Off with the other TREK services (TREK_SERVICES=off).
*/
@Injectable()
export class WalletPassService {
constructor(
@Inject(servicesConfig.KEY) private readonly config: ConfigType<typeof servicesConfig>,
private readonly reservations: ReservationsReadService,
private readonly rateLimit: RateLimitService,
) {}
/** Whether this server makes Wallet passes at all. */
enabled(): boolean {
return this.config.mode === 'auto';
}
async passFor(userId: number, tripId: number, reservationId: number, language: string): Promise<WalletPass> {
if (!this.enabled()) throw new DomainError(404, 'Wallet passes are off on this server');
const allowed = await this.rateLimit.check(
'wallet-pass',
String(userId),
PASSES_PER_MINUTE,
RATE_WINDOW_MS,
Date.now(),
);
if (!allowed) throw new DomainError(429, 'Too many Wallet passes, try again in a minute');
const row = await this.reservations.getReservationWithJoins(reservationId);
// The trip guard checked the trip; the booking must belong to it as well.
if (!row || Number(row.trip_id) !== tripId) throw new DomainError(404, 'Reservation not found');
// The trip's title and where the booking is, read by the reservations domain itself.
const context = await this.reservations.passContext(reservationId, tripId);
const request = buildWalletPassRequest(
{
reservation: {
id: row.id,
trip_id: row.trip_id,
title: row.title,
type: row.type,
reservation_time: stringOrNull(row.reservation_time),
reservation_end_time: stringOrNull(row.reservation_end_time),
confirmation_number: stringOrNull(row.confirmation_number),
location: stringOrNull(row.location),
created_at: stringOrNull(row.created_at),
metadata: parseMetadata(row.metadata),
},
endpoints: (row.endpoints ?? []) as WalletPassEndpoint[],
tripTitle: context?.trip_title ?? null,
place:
typeof context?.place_lat === 'number' && typeof context.place_lng === 'number'
? { lat: context.place_lat, lng: context.place_lng, name: context.place_name ?? null }
: null,
},
await loadWalletWords(language),
);
if (!request) throw new DomainError(422, 'A booking needs a date for a Wallet pass');
return { bytes: await this.sign(request), filename: `${filenameOf(row.title)}.pkpass` };
}
private async sign(request: object): Promise<Buffer> {
let response: Response;
try {
response = await safeFetchAdminConfigured(
this.config.walletPassUrl,
{
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: PKPASS },
body: JSON.stringify(request),
signal: AbortSignal.timeout(TIMEOUT_MS),
},
0,
undefined,
MAX_PASS_BYTES,
);
} catch (err) {
logWarn(`[Wallet pass] signer unreachable: ${err instanceof Error ? err.message : String(err)}`);
throw new DomainError(502, 'The Wallet pass service did not answer');
}
if (response.status === 429) throw new DomainError(429, 'Too many Wallet passes, try again in a minute');
if (response.status !== 200 || !(response.headers.get('content-type') ?? '').startsWith(PKPASS)) {
const reason = await readCapped(response, 2048).then(
({ bytes }) => bytes.toString('utf8').slice(0, 300),
() => '',
);
logWarn(`[Wallet pass] signer answered ${response.status}: ${reason}`);
throw new DomainError(502, 'The Wallet pass service could not make this pass');
}
const { bytes, truncated } = await readCapped(response, MAX_PASS_BYTES);
// A .pkpass is a ZIP: anything else is not handed to the app as one.
if (truncated || bytes.length < 4 || bytes.readUInt32LE(0) !== ZIP_LOCAL_HEADER) {
throw new DomainError(502, 'The Wallet pass service sent something that is not a pass');
}
return bytes;
}
}
function stringOrNull(value: unknown): string | null {
return typeof value === 'string' ? value : null;
}
export function parseMetadata(raw: unknown): Record<string, unknown> {
if (raw && typeof raw === 'object' && !Array.isArray(raw)) return raw as Record<string, unknown>;
if (typeof raw !== 'string' || !raw) return {};
try {
const parsed: unknown = JSON.parse(raw);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {};
} catch {
return {};
}
}
/** A file name from the booking's title: letters, digits and dashes. */
export function filenameOf(title: string): string {
const slug = title
.normalize('NFKD')
.replace(/\p{M}/gu, '')
.replace(/[^\p{L}\p{N}]+/gu, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 60);
return slug || 'booking';
}
@@ -0,0 +1,56 @@
import type { WalletWords } from './wallet-pass.builder';
import { getLocaleForLanguage, SUPPORTED_LANGUAGE_CODES } from '@trek/shared/i18n';
/**
* The labels on a booking's Wallet pass, in the user's language, from the same
* locale tables the booking forms read (`reservations.ts` of each locale), so a
* pass says "Sitz" where the app says "Sitz". Nothing new to translate.
*/
const KEYS: Readonly<Record<Exclude<keyof WalletWords, 'locale'>, string>> = {
departure: 'reservations.span.departure',
arrival: 'reservations.span.arrival',
pickup: 'reservations.span.pickup',
dropOff: 'reservations.span.dropOff',
checkIn: 'reservations.meta.checkIn',
checkOut: 'reservations.meta.checkOut',
time: 'reservations.time',
seat: 'reservations.meta.seat',
platform: 'reservations.meta.platform',
flightNumber: 'reservations.meta.flightNumber',
trainNumber: 'reservations.meta.trainNumber',
airline: 'reservations.meta.airline',
confirmation: 'reservations.confirmationCode',
location: 'reservations.locationAddress',
};
/** A language TREK has a table for; anything else is English. */
export function walletLanguage(value: unknown): string {
return typeof value === 'string' && SUPPORTED_LANGUAGE_CODES.includes(value) ? value : 'en';
}
async function tableOf(language: string): Promise<Record<string, unknown>> {
try {
// A supported code only (walletLanguage), so the path is one of the package's own locale entries.
const loaded: unknown = await import(`@trek/shared/i18n/${language}`);
const table = (loaded as { default?: unknown } | null)?.default;
return table !== null && typeof table === 'object' ? (table as Record<string, unknown>) : {};
} catch {
return {};
}
}
/** The words in `language`, English where its table lacks one. */
export async function loadWalletWords(language: string): Promise<WalletWords> {
const code = walletLanguage(language);
const own = await tableOf(code);
const english = code === 'en' ? own : await tableOf('en');
const word = (key: string) => {
const value = own[key] ?? english[key];
return typeof value === 'string' ? value : '';
};
const words = { locale: getLocaleForLanguage(code) } as WalletWords;
for (const [field, key] of Object.entries(KEYS) as [Exclude<keyof WalletWords, 'locale'>, string][]) {
words[field] = word(key);
}
return words;
}
+168
View File
@@ -0,0 +1,168 @@
/**
* GET /api/trips/:tripId/reservations/:id/wallet-pass end to end: the session,
* the trip guard, the booking read through the reservations module and the
* request the TREK services' pass signer receives. The signer itself is
* stubbed at the fetch seam.
*/
import { servicesConfig } from '../../src/nest/app-config/tokens';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
import { WalletPassModule } from '../../src/nest/wallet-pass/wallet-pass.module';
import { createSnapshotTestDb } from '../helpers/db-mock';
import { makeReservation, makeReservationEndpoint } from '../helpers/factories/reservations';
import { makeTrip } from '../helpers/factories/trips';
import { makeUser } from '../helpers/factories/users';
import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { sessionCookie } from './harness';
import { Test } from '@nestjs/testing';
import cookieParser from 'cookie-parser';
import request from 'supertest';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
const { safeFetchAdminConfigured } = vi.hoisted(() => ({ safeFetchAdminConfigured: vi.fn() }));
vi.mock('../../src/utils/ssrfGuard', async (importOriginal) => ({
...(await importOriginal<typeof import('../../src/utils/ssrfGuard')>()),
safeFetchAdminConfigured,
}));
const db = createSnapshotTestDb();
const OWNER = 1;
const STRANGER = 2;
const PASS = Buffer.concat([Buffer.from([0x50, 0x4b, 0x03, 0x04]), Buffer.from('signed pass')]);
let orm: TestOrm;
let app: Awaited<ReturnType<typeof build>>;
let tripId: number;
let flightId: number;
async function build() {
const moduleRef = await Test.createTestingModule({
imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), WalletPassModule],
})
.overrideProvider(servicesConfig.KEY)
.useValue({ mode: 'auto', key: null, hosts: [], walletPassUrl: 'https://push.liketrek.com/v1/wallet/pass' })
.compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
nest.useGlobalPipes(new ZodValidationPipe());
await nest.init();
return nest;
}
const as = (userId: number) => ({ Cookie: sessionCookie(userId) });
const path = (trip: number, id: number, lang = 'de') =>
`/api/trips/${trip}/reservations/${id}/wallet-pass?lang=${lang}`;
beforeAll(async () => {
orm = await createTestOrm(db);
await makeUser(orm, { id: OWNER, username: 'owner', email: 'owner@example.test' });
await makeUser(orm, { id: STRANGER, username: 'stranger', email: 'stranger@example.test' });
const trip = await makeTrip(orm, OWNER, { title: 'Japan 2027' });
tripId = trip.id;
const flight = await makeReservation(orm, tripId, {
title: 'LH 716',
type: 'flight',
reservation_time: '2027-04-01T13:25',
confirmation_number: 'X7K2QP',
metadata: JSON.stringify({ airline: 'Lufthansa', flight_number: 'LH716', seat: '34K' }),
});
flightId = flight.id;
await makeReservationEndpoint(orm, flightId, {
role: 'from',
name: 'Frankfurt',
code: 'FRA',
lat: 50.03,
lng: 8.57,
timezone: 'Europe/Berlin',
local_date: '2027-04-01',
local_time: '13:25',
});
await makeReservationEndpoint(orm, flightId, {
role: 'to',
sequence: 1,
name: 'Tokyo Haneda',
code: 'HND',
lat: 35.55,
lng: 139.78,
timezone: 'Asia/Tokyo',
local_date: '2027-04-02',
local_time: '08:50',
});
app = await build();
});
beforeEach(() => {
safeFetchAdminConfigured.mockReset();
safeFetchAdminConfigured.mockResolvedValue(
new Response(PASS, { status: 200, headers: { 'content-type': 'application/vnd.apple.pkpass' } }),
);
});
afterAll(async () => {
await app.close();
await orm.close();
});
describe('GET /api/trips/:tripId/reservations/:id/wallet-pass', () => {
it('WALLET-E2E-001: answers 401 without a session and asks nobody', async () => {
const res = await request(app.getHttpServer()).get(path(tripId, flightId));
expect(res.status).toBe(401);
expect(safeFetchAdminConfigured).not.toHaveBeenCalled();
});
it('WALLET-E2E-002: someone outside the trip gets a 404, not a pass', async () => {
const res = await request(app.getHttpServer()).get(path(tripId, flightId)).set(as(STRANGER));
expect(res.status).toBe(404);
expect(safeFetchAdminConfigured).not.toHaveBeenCalled();
});
it('WALLET-E2E-003: the owner gets the signed pass, built from the booking in their language', async () => {
const res = await request(app.getHttpServer())
.get(path(tripId, flightId))
.set(as(OWNER))
.buffer(true)
.parse((r, done) => {
const chunks: Buffer[] = [];
r.on('data', (c: Buffer) => chunks.push(c));
r.on('end', () => done(null, Buffer.concat(chunks)));
});
expect(res.status).toBe(200);
expect(res.headers['content-type']).toContain('application/vnd.apple.pkpass');
expect(res.headers['content-disposition']).toBe('attachment; filename="LH-716.pkpass"');
expect(Buffer.compare(res.body as Buffer, PASS)).toBe(0);
const [url, init] = safeFetchAdminConfigured.mock.calls[0] as [string, RequestInit];
expect(url).toBe('https://push.liketrek.com/v1/wallet/pass');
const body = JSON.parse(String(init.body));
expect(body).toMatchObject({
kind: 'flight',
title: 'LH 716',
trip: 'Japan 2027',
start: '2027-04-01T13:25:00+02:00',
end: '2027-04-02T08:50:00+09:00',
from: { code: 'FRA', name: 'Frankfurt' },
to: { code: 'HND', name: 'Tokyo Haneda' },
confirmation: { value: 'X7K2QP' },
locale: 'de-DE',
});
expect(body.details.map((d: { label: string }) => d.label)).toContain('Sitzplatz');
});
it('WALLET-E2E-004: a booking of another trip, or none, is a 404', async () => {
const other = await makeTrip(orm, OWNER, { title: 'Elsewhere' });
expect((await request(app.getHttpServer()).get(path(other.id, flightId)).set(as(OWNER))).status).toBe(404);
expect((await request(app.getHttpServer()).get(path(tripId, 999_999)).set(as(OWNER))).status).toBe(404);
expect(safeFetchAdminConfigured).not.toHaveBeenCalled();
});
it('WALLET-E2E-005: a booking without a date gets a 422, and the signer failing a 502', async () => {
const undated = await makeReservation(orm, tripId, { title: 'Someday', type: 'event', reservation_time: null });
expect((await request(app.getHttpServer()).get(path(tripId, undated.id)).set(as(OWNER))).status).toBe(422);
safeFetchAdminConfigured.mockRejectedValueOnce(new Error('ECONNREFUSED'));
expect((await request(app.getHttpServer()).get(path(tripId, flightId)).set(as(OWNER))).status).toBe(502);
});
});
@@ -1032,3 +1032,40 @@ describe('ReservationsRepository — confirmIfPending', () => {
expect((await findRow(await orm(), Reservations, { id: cancelled.id }))?.status).toBe('cancelled');
});
});
describe('ReservationsRepository — findPassContext', () => {
it('RES-PASS-001: reads the trip title and the place of the booking, else of its stay, within the trip only', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Japan 2027', start_date: '2027-04-01', end_date: '2027-04-06' });
const day1 = createDay(testDb, trip.id, { date: '2027-04-01' });
const day2 = createDay(testDb, trip.id, { date: '2027-04-02' });
const restaurant = createPlace(testDb, trip.id, { name: 'Gion Karyo', lat: 35.0, lng: 135.77 });
const hotel = createPlace(testDb, trip.id, { name: 'Hotel Granvia Kyoto', lat: 34.985, lng: 135.758 });
const stay = createDayAccommodation(testDb, trip.id, hotel.id, day1.id, day2.id);
const dinner = createReservation(testDb, trip.id, { title: 'Dinner', type: 'restaurant' });
await setReservation(dinner.id, { place: restaurant.id });
const room = createReservation(testDb, trip.id, { title: 'Room', type: 'hotel' });
await setReservation(room.id, { accommodation_id: String(stay.id) });
const bare = createReservation(testDb, trip.id, { title: 'Museum', type: 'event' });
expect(await reservationsRepo.findPassContext(dinner.id, trip.id)).toEqual({
trip_title: 'Japan 2027',
place_lat: 35.0,
place_lng: 135.77,
place_name: 'Gion Karyo',
});
expect(await reservationsRepo.findPassContext(room.id, trip.id)).toMatchObject({
place_lat: 34.985,
place_name: 'Hotel Granvia Kyoto',
});
expect(await reservationsRepo.findPassContext(bare.id, trip.id)).toEqual({
trip_title: 'Japan 2027',
place_lat: null,
place_lng: null,
place_name: null,
});
const other = createTrip(testDb, user.id, { title: 'Elsewhere' });
expect(await reservationsRepo.findPassContext(dinner.id, other.id)).toBeUndefined();
});
});
@@ -0,0 +1,240 @@
import {
buildWalletPassRequest,
clean,
displayTime,
kindOf,
serialFor,
withOffset,
type WalletPassSource,
type WalletWords,
} from '../../../../src/nest/wallet-pass/wallet-pass.builder';
import { describe, expect, it } from 'vitest';
const WORDS: WalletWords = {
locale: 'en-GB',
departure: 'Departure',
arrival: 'Arrival',
pickup: 'Pickup',
dropOff: 'Drop-off',
checkIn: 'Check-in',
checkOut: 'Check-out',
time: 'Time',
seat: 'Seat',
platform: 'Platform',
flightNumber: 'Flight No.',
trainNumber: 'Train No.',
airline: 'Airline',
confirmation: 'Booking Code',
location: 'Location / Address',
};
function source(
over: Partial<WalletPassSource['reservation']> = {},
rest: Partial<WalletPassSource> = {},
): WalletPassSource {
return {
reservation: {
id: 7,
trip_id: 42,
title: 'Hotel Granvia Kyoto',
type: 'hotel',
reservation_time: '2027-04-02T15:00',
reservation_end_time: '2027-04-06T11:00',
confirmation_number: 'HG-48213',
location: 'Karasuma-dori, Kyoto',
created_at: '2026-11-01 10:00:00',
metadata: { check_in_time: '15:00', check_out_time: '11:00' },
...over,
},
endpoints: [],
tripTitle: 'Japan 2027',
place: { lat: 34.985, lng: 135.758, name: 'Hotel Granvia Kyoto' },
...rest,
};
}
describe('wallet pass builder', () => {
it('WALLET-B-001: maps every booking type to a pass kind', () => {
expect(kindOf('flight')).toBe('flight');
expect(kindOf('cruise')).toBe('boat');
expect(kindOf('ferry')).toBe('boat');
expect(kindOf('transit')).toBe('bus');
expect(kindOf('taxi')).toBe('car');
expect(kindOf('hotel')).toBe('hotel');
expect(kindOf('tour')).toBe('tour');
expect(kindOf('transport_other')).toBe('other');
expect(kindOf(null)).toBe('other');
});
it('WALLET-B-002: cleans texts of links and shortens them', () => {
expect(clean('Pay at https://evil.example/now please', 60)).toBe('Pay at please');
expect(clean('see www.example.com', 60)).toBe('see');
expect(clean(' a b ', 10)).toBe('a b');
expect(clean('x'.repeat(30), 10)).toBe(`${'x'.repeat(9)}…`);
expect(clean('https://only.link', 10)).toBeUndefined();
expect(clean(null, 10)).toBeUndefined();
});
it('WALLET-B-003: gives a wall-clock time the offset its zone has then', () => {
expect(withOffset('2027-04-02', '15:00', 'Asia/Tokyo')).toBe('2027-04-02T15:00:00+09:00');
expect(withOffset('2027-07-01', '08:30', 'Europe/Berlin')).toBe('2027-07-01T08:30:00+02:00');
expect(withOffset('2027-01-15', '08:30', 'Europe/Berlin')).toBe('2027-01-15T08:30:00+01:00');
expect(withOffset('2027-01-15', '08:30', 'America/St_Johns')).toBe('2027-01-15T08:30:00-03:30');
expect(withOffset('2027-01-15', null, null)).toBe('2027-01-15T00:00:00+00:00');
expect(withOffset('2027-01-15', '10:00', 'Not/AZone')).toBe('2027-01-15T10:00:00+00:00');
});
it('WALLET-B-004: shows a time as the wall clock says it, in the user language', () => {
expect(displayTime('en-GB', '2027-04-02', '15:00')).toMatch(/Fri.*2 Apr.*15:00/);
expect(displayTime('de-DE', '2027-04-02', null)).toMatch(/Fr.*2\. Apr/);
expect(displayTime('no-such-LOCALE-x', '2027-04-02', null)).toMatch(/Apr/);
});
it('WALLET-B-005: a hotel becomes a pass with check-in, check-out, place and QR code', () => {
const request = buildWalletPassRequest(source(), WORDS);
expect(request).toMatchObject({
kind: 'hotel',
title: 'Hotel Granvia Kyoto',
trip: 'Japan 2027',
start: '2027-04-02T15:00:00+09:00',
end: '2027-04-06T11:00:00+09:00',
location: { latitude: 34.985, longitude: 135.758, name: 'Hotel Granvia Kyoto' },
confirmation: { label: 'Booking Code', value: 'HG-48213' },
locale: 'en-GB',
});
expect(request?.details?.map((d) => d.label)).toEqual(['Check-in', 'Check-out']);
expect(request?.back).toEqual([{ label: 'Location / Address', value: 'Karasuma-dori, Kyoto' }]);
expect(request?.serial).toBe(serialFor(source().reservation));
expect(request?.serial).toMatch(/^trek-7-[0-9a-f]{16}$/);
});
it('WALLET-B-006: a flight becomes a boarding pass from and to, in the departure zone', () => {
const request = buildWalletPassRequest(
source(
{
title: 'LH 716',
type: 'flight',
reservation_time: '2027-04-01T13:25',
reservation_end_time: null,
metadata: { airline: 'Lufthansa', flight_number: 'LH716', seat: '34K' },
},
{
place: null,
endpoints: [
{
role: 'from',
name: 'Frankfurt',
code: 'FRA',
lat: 50.03,
lng: 8.57,
timezone: 'Europe/Berlin',
local_date: '2027-04-01',
local_time: '13:25',
},
{
role: 'to',
name: 'Tokyo Haneda',
code: 'HND',
lat: 35.55,
lng: 139.78,
timezone: 'Asia/Tokyo',
local_date: '2027-04-02',
local_time: '08:50',
},
],
},
),
WORDS,
);
expect(request).toMatchObject({
kind: 'flight',
start: '2027-04-01T13:25:00+02:00',
end: '2027-04-02T08:50:00+09:00',
from: { code: 'FRA', name: 'Frankfurt' },
to: { code: 'HND', name: 'Tokyo Haneda' },
location: { latitude: 50.03, longitude: 8.57, name: 'Frankfurt' },
});
expect(request?.details?.map((d) => d.label)).toEqual(['Departure', 'Flight No.', 'Seat', 'Arrival']);
expect(request?.details?.[1]?.value).toBe('Lufthansa LH716');
});
it('WALLET-B-007: a journey without both ends is a plain booking; a train shows its number and platform', () => {
const half = buildWalletPassRequest(source({ type: 'train', metadata: {} }, { place: null, endpoints: [] }), WORDS);
expect(half?.kind).toBe('other');
expect(half?.from).toBeUndefined();
const train = buildWalletPassRequest(
source(
{ type: 'train', metadata: { train_number: 'ICE 597', platform: '7', seat: '61' } },
{
place: null,
endpoints: [
{
role: 'from',
name: 'Berlin Hbf',
code: null,
lat: 52.52,
lng: 13.37,
timezone: null,
local_date: null,
local_time: null,
},
{
role: 'to',
name: 'München Hbf',
code: null,
lat: 48.14,
lng: 11.56,
timezone: null,
local_date: null,
local_time: null,
},
],
},
),
WORDS,
);
expect(train?.kind).toBe('train');
// No zone on the endpoint: looked up from its coordinates.
expect(train?.start).toBe('2027-04-02T15:00:00+02:00');
// At most four lines under the title; the arrival is the one that gives way.
expect(train?.details?.map((d) => d.label)).toEqual(['Departure', 'Train No.', 'Seat', 'Platform']);
expect(train?.from).toEqual({ name: 'Berlin Hbf' });
});
it('WALLET-B-008: a car shows pickup and drop-off, a restaurant its time', () => {
const car = buildWalletPassRequest(source({ type: 'car', title: 'Sixt' }), WORDS);
expect(car?.details?.map((d) => d.label)).toEqual(['Pickup', 'Drop-off']);
const dinner = buildWalletPassRequest(
source({ type: 'restaurant', title: 'Gion Karyo', reservation_end_time: null }),
WORDS,
);
expect(dinner?.kind).toBe('restaurant');
expect(dinner?.details).toEqual([{ label: 'Time', value: expect.stringMatching(/15:00/) }]);
expect(dinner?.end).toBeUndefined();
});
it('WALLET-B-009: a confirmation that is no code goes on the back; no date means no pass', () => {
const odd = buildWalletPassRequest(source({ confirmation_number: 'Ref: 12#34 (see mail)' }), WORDS);
expect(odd?.confirmation).toBeUndefined();
expect(odd?.back).toContainEqual({ label: 'Booking Code', value: 'Ref: 12#34 (see mail)' });
expect(buildWalletPassRequest(source({ reservation_time: null }), WORDS)).toBeNull();
expect(buildWalletPassRequest(source({ reservation_time: 'soon' }), WORDS)).toBeNull();
});
it('WALLET-B-010: a booking without place or trip still makes a pass, its title cleaned', () => {
const request = buildWalletPassRequest(
source(
{ title: 'Museum https://tickets.example', type: 'event', location: null, confirmation_number: null },
{ place: null, tripTitle: null },
),
WORDS,
);
expect(request).toMatchObject({ kind: 'event', title: 'Museum' });
expect(request?.trip).toBeUndefined();
expect(request?.location).toBeUndefined();
expect(request?.back).toBeUndefined();
expect(request?.start).toBe('2027-04-02T15:00:00+00:00');
});
});
@@ -0,0 +1,56 @@
import { DomainError } from '../../../../src/nest/common/domain-error';
import { WalletPassController } from '../../../../src/nest/wallet-pass/wallet-pass.controller';
import type { User } from '../../../../src/types';
import { describe, expect, it, vi } from 'vitest';
function response() {
const res = {
headers: {} as Record<string, string>,
statusCode: 0,
contentType: '',
body: undefined as unknown,
set: vi.fn((name: string, value: string) => {
res.headers[name] = value;
return res;
}),
status: vi.fn((code: number) => {
res.statusCode = code;
return res;
}),
type: vi.fn((t: string) => {
res.contentType = t;
return res;
}),
send: vi.fn((body: unknown) => {
res.body = body;
return res;
}),
};
return res;
}
const user = { id: 5 } as User;
describe('WalletPassController', () => {
it('WALLET-C-001: answers the pass as a download that is never cached, in the asked language', async () => {
const passFor = vi.fn(async () => ({ bytes: Buffer.from('PK\u0003\u0004'), filename: 'LH-716.pkpass' }));
const controller = new WalletPassController({ passFor } as never);
const res = response();
await controller.pass(user, 42, 7, 'ja', res as never);
expect(passFor).toHaveBeenCalledWith(5, 42, 7, 'ja');
expect(res.statusCode).toBe(200);
expect(res.contentType).toBe('application/vnd.apple.pkpass');
expect(res.headers['Content-Disposition']).toBe('attachment; filename="LH-716.pkpass"');
expect(res.headers['Cache-Control']).toBe('no-store');
});
it('WALLET-C-002: an unknown language falls back to English; a refusal is passed on', async () => {
const passFor = vi.fn(async () => {
throw new DomainError(422, 'A booking needs a date for a Wallet pass');
});
const controller = new WalletPassController({ passFor } as never);
await expect(controller.pass(user, 42, 7, 'klingon', response() as never)).rejects.toBeInstanceOf(DomainError);
expect(passFor).toHaveBeenCalledWith(5, 42, 7, 'en');
});
});
@@ -0,0 +1,155 @@
import { DomainError } from '../../../../src/nest/common/domain-error';
import { filenameOf, parseMetadata, WalletPassService } from '../../../../src/nest/wallet-pass/wallet-pass.service';
import { loadWalletWords, walletLanguage } from '../../../../src/nest/wallet-pass/wallet-pass.words';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { safeFetchAdminConfigured } = vi.hoisted(() => ({ safeFetchAdminConfigured: vi.fn() }));
vi.mock('../../../../src/utils/ssrfGuard', async (importOriginal) => ({
...(await importOriginal<typeof import('../../../../src/utils/ssrfGuard')>()),
safeFetchAdminConfigured,
}));
const PASS = Buffer.concat([Buffer.from([0x50, 0x4b, 0x03, 0x04]), Buffer.from('rest of the zip')]);
const ROW = {
id: 7,
trip_id: 42,
title: 'Hotel Granvia Kyoto',
type: 'hotel',
status: 'confirmed',
reservation_time: '2027-04-02T15:00',
reservation_end_time: '2027-04-06T11:00',
confirmation_number: 'HG-48213',
location: null,
created_at: '2026-11-01 10:00:00',
metadata: '{"check_in_time":"15:00"}',
place_id: 3,
endpoints: [],
};
function make(mode: 'auto' | 'off' = 'auto') {
const reservations = {
getReservationWithJoins: vi.fn(async (): Promise<Record<string, unknown> | undefined> => ({ ...ROW })),
passContext: vi.fn(async (): Promise<Record<string, unknown> | undefined> => ({
trip_title: 'Japan 2027',
place_lat: 34.985,
place_lng: 135.758,
place_name: 'Hotel Granvia Kyoto',
})),
};
const rateLimit = { check: vi.fn(async () => true) };
const service = new WalletPassService(
{ mode, walletPassUrl: 'https://push.liketrek.com/v1/wallet/pass' } as never,
reservations as never,
rateLimit as never,
);
return { service, reservations, rateLimit };
}
const signerAnswers = (status: number, body: BodyInit, type = 'application/vnd.apple.pkpass') =>
safeFetchAdminConfigured.mockResolvedValue(new Response(body, { status, headers: { 'content-type': type } }));
async function refusal(promise: Promise<unknown>): Promise<number> {
try {
await promise;
} catch (err) {
expect(err).toBeInstanceOf(DomainError);
return (err as DomainError).getStatus();
}
throw new Error('expected a refusal');
}
beforeEach(() => safeFetchAdminConfigured.mockReset());
describe('WalletPassService', () => {
it('WALLET-S-001: sends the booking to the signer and hands back the signed pass', async () => {
const { service, reservations } = make();
signerAnswers(200, PASS);
const pass = await service.passFor(1, 42, 7, 'de');
expect(pass.bytes.equals(PASS)).toBe(true);
expect(pass.filename).toBe('Hotel-Granvia-Kyoto.pkpass');
expect(reservations.passContext).toHaveBeenCalledWith(7, 42);
const [url, init] = safeFetchAdminConfigured.mock.calls[0] as [string, RequestInit];
expect(url).toBe('https://push.liketrek.com/v1/wallet/pass');
const body = JSON.parse(String(init.body));
expect(body).toMatchObject({
kind: 'hotel',
trip: 'Japan 2027',
locale: 'de-DE',
start: '2027-04-02T15:00:00+09:00',
});
expect(body.details[0].label).toBe('Check-in');
expect(init.signal).toBeInstanceOf(AbortSignal);
});
it('WALLET-S-002: is off with the TREK services, and asks nobody', async () => {
const { service } = make('off');
expect(service.enabled()).toBe(false);
expect(await refusal(service.passFor(1, 42, 7, 'en'))).toBe(404);
expect(safeFetchAdminConfigured).not.toHaveBeenCalled();
});
it('WALLET-S-003: a booking of another trip, a missing one or one without a date is refused', async () => {
const { service, reservations } = make();
expect(await refusal(service.passFor(1, 99, 7, 'en'))).toBe(404);
reservations.getReservationWithJoins.mockResolvedValueOnce(undefined);
expect(await refusal(service.passFor(1, 42, 7, 'en'))).toBe(404);
reservations.getReservationWithJoins.mockResolvedValueOnce({ ...ROW, reservation_time: null, place_id: null });
expect(await refusal(service.passFor(1, 42, 7, 'en'))).toBe(422);
expect(safeFetchAdminConfigured).not.toHaveBeenCalled();
});
it('WALLET-S-004: a user asking too often is slowed down before anything is read', async () => {
const { service, rateLimit, reservations } = make();
rateLimit.check.mockResolvedValueOnce(false);
expect(await refusal(service.passFor(1, 42, 7, 'en'))).toBe(429);
expect(reservations.getReservationWithJoins).not.toHaveBeenCalled();
});
it('WALLET-S-005: the signer down, refusing, busy or answering something else is a clear error', async () => {
const { service } = make();
safeFetchAdminConfigured.mockRejectedValueOnce(new Error('ECONNREFUSED'));
expect(await refusal(service.passFor(1, 42, 7, 'en'))).toBe(502);
signerAnswers(400, '{"error":"title: required"}', 'application/json');
expect(await refusal(service.passFor(1, 42, 7, 'en'))).toBe(502);
signerAnswers(429, '');
expect(await refusal(service.passFor(1, 42, 7, 'en'))).toBe(429);
signerAnswers(200, '<html>captive portal</html>', 'application/vnd.apple.pkpass');
expect(await refusal(service.passFor(1, 42, 7, 'en'))).toBe(502);
signerAnswers(200, PASS, 'text/html');
expect(await refusal(service.passFor(1, 42, 7, 'en'))).toBe(502);
});
it('WALLET-S-006: a place without coordinates is no location; metadata reads in any shape', async () => {
const { service, reservations } = make();
reservations.passContext.mockResolvedValueOnce({
trip_title: null,
place_lat: null,
place_lng: null,
place_name: null,
});
signerAnswers(200, PASS);
await service.passFor(1, 42, 7, 'en');
const body = JSON.parse(String((safeFetchAdminConfigured.mock.calls[0] as [string, RequestInit])[1].body));
expect(body.location).toBeUndefined();
expect(parseMetadata('{"seat":"1A"}')).toEqual({ seat: '1A' });
expect(parseMetadata({ seat: '1A' })).toEqual({ seat: '1A' });
expect(parseMetadata('[1]')).toEqual({});
expect(parseMetadata('not json')).toEqual({});
expect(parseMetadata(null)).toEqual({});
});
it('WALLET-S-007: file names and languages stay safe', async () => {
expect(filenameOf('Hôtel "Le Nid" / Paris')).toBe('Hotel-Le-Nid-Paris');
expect(filenameOf('***')).toBe('booking');
expect(walletLanguage('de')).toBe('de');
expect(walletLanguage('../../etc')).toBe('en');
expect(walletLanguage(undefined)).toBe('en');
const words = await loadWalletWords('de');
expect(words.locale).toBe('de-DE');
expect(words.seat).toBe('Sitzplatz');
expect((await loadWalletWords('xx')).seat).toBe('Seat');
});
});
+3
View File
@@ -224,6 +224,9 @@ export default defineConfig({
'src/nest/trips/**/*.ts': { statements: 95, branches: 86, functions: 95, lines: 96 },
'src/nest/unsplash/**/*.ts': { statements: 99, branches: 88, functions: 99, lines: 99 },
'src/nest/vacay/**/*.ts': { statements: 82, branches: 66, functions: 90, lines: 86 },
// New domain in this change (Apple Wallet passes), unmeasured: pinned at the 80 floor
// until the first full run regenerates it.
'src/nest/wallet-pass/**/*.ts': { statements: 80, branches: 80, functions: 80, lines: 80 },
'src/nest/wanderer/**/*.ts': { statements: 80, branches: 80, functions: 80, lines: 80 },
'src/nest/weather/**/*.ts': { statements: 93, branches: 78, functions: 91, lines: 97 },
'src/nest/**/*.ts': { statements: 80, branches: 80, functions: 80, lines: 80 },
+3
View File
@@ -299,5 +299,8 @@ const reservations: TranslationStrings = {
'يحتاج رفع الملفات إلى اتصال. أزل الملفات الجديدة لحفظ الحجز دون اتصال، أو احفظ عندما تعود متصلاً.',
'reservations.needsConnection': 'يتطلب هذا اتصالاً. حاول مرة أخرى عندما يعود TREK للاتصال.',
'reservations.exportCsvFiltered': 'تصدير الصفوف المعروضة ({shown} من {total}) (CSV)',
'reservations.wallet.add': 'إضافة إلى Apple Wallet',
'reservations.wallet.failed': 'تعذّر إنشاء بطاقة Wallet. حاول مرة أخرى.',
'reservations.wallet.undated': 'حدّد تاريخًا للحجز أولًا: يعرض Wallet البطاقة قرب موعده.',
};
export default reservations;
+3
View File
@@ -307,6 +307,9 @@ const reservations: TranslationStrings = {
'Faylların yüklənməsi üçün bağlantı lazımdır. Rezervasiyanı oflayn saxlamaq üçün yeni faylları silin və ya yenidən onlayn olduqda saxlayın.',
'reservations.needsConnection': 'Bunun üçün bağlantı lazımdır. TREK yenidən onlayn olduqda bir daha cəhd edin.',
'reservations.exportCsvFiltered': 'Göstərilən {shown}/{total} sətri ixrac et (CSV)',
'reservations.wallet.add': 'Apple Wallet-ə əlavə et',
'reservations.wallet.failed': 'Wallet kartını yaratmaq alınmadı. Yenidən cəhd edin.',
'reservations.wallet.undated': 'Əvvəlcə rezervasiyaya tarix verin: Wallet kartı həmin vaxta yaxın göstərir.',
};
export default reservations;
+3
View File
@@ -273,5 +273,8 @@ const reservations: TranslationStrings = {
'Os arquivos precisam de conexão para serem enviados. Remova os novos para salvar a reserva offline, ou salve quando estiver online de novo.',
'reservations.needsConnection': 'Isso precisa de conexão. Tente de novo quando o TREK voltar a ficar online.',
'reservations.exportCsvFiltered': 'Ezporzhiañ ar {shown} diwar {total} renk skwer (CSV)',
'reservations.wallet.add': 'Adicionar à Apple Wallet',
'reservations.wallet.failed': 'Não foi possível criar o passe da Wallet. Tente novamente.',
'reservations.wallet.undated': 'Primeiro dê uma data à reserva: a Wallet mostra o passe perto desse horário.',
};
export default reservations;
+4
View File
@@ -276,5 +276,9 @@ const reservations: TranslationStrings = {
'Els fitxers necessiten connexió per pujar-se. Treu els nous per desar la reserva sense connexió, o desa quan tornis a estar en línia.',
'reservations.needsConnection': 'Això necessita connexió. Torna-ho a provar quan TREK torni a estar en línia.',
'reservations.exportCsvFiltered': 'Exporta les {shown} de {total} files mostrades (CSV)',
'reservations.wallet.add': 'Afegeix a Apple Wallet',
'reservations.wallet.failed': 'No s’ha pogut crear el passi de Wallet. Torna-ho a provar.',
'reservations.wallet.undated':
'Primer posa una data a la reserva: Wallet mostra el passi al voltant d’aquell moment.',
};
export default reservations;
+3
View File
@@ -280,5 +280,8 @@ const reservations: TranslationStrings = {
'Soubory potřebují k nahrání připojení. Odeber nové, abys rezervaci uložil offline, nebo ulož, až budeš znovu online.',
'reservations.needsConnection': 'Na tohle je potřeba připojení. Zkus to znovu, až bude TREK zase online.',
'reservations.exportCsvFiltered': 'Exportovat zobrazené řádky, {shown} z {total} (CSV)',
'reservations.wallet.add': 'Přidat do Apple Wallet',
'reservations.wallet.failed': 'Kartu do Wallet se nepodařilo vytvořit. Zkuste to znovu.',
'reservations.wallet.undated': 'Nejdřív rezervaci zadejte datum: Wallet kartu zobrazí kolem toho času.',
};
export default reservations;
+3
View File
@@ -278,5 +278,8 @@ const reservations: TranslationStrings = {
'reservations.needsConnection':
'Dafür braucht es eine Verbindung. Versuch es noch einmal, sobald TREK wieder online ist.',
'reservations.exportCsvFiltered': 'Die {shown} von {total} angezeigten Zeilen exportieren (CSV)',
'reservations.wallet.add': 'Zu Apple Wallet hinzufügen',
'reservations.wallet.failed': 'Der Wallet-Pass konnte nicht erstellt werden. Bitte versuch es erneut.',
'reservations.wallet.undated': 'Gib der Buchung zuerst ein Datum: Wallet zeigt den Pass rund um diesen Zeitpunkt an.',
};
export default reservations;
+3
View File
@@ -275,5 +275,8 @@ const reservations: TranslationStrings = {
'Files need a connection to upload. Remove the new ones to save the booking offline, or save once you are back online.',
'reservations.needsConnection': 'This needs a connection. Try again once TREK is back online.',
'reservations.exportCsvFiltered': 'Export the {shown} of {total} shown rows (CSV)',
'reservations.wallet.add': 'Add to Apple Wallet',
'reservations.wallet.failed': 'The Wallet pass could not be made. Please try again.',
'reservations.wallet.undated': 'Give the booking a date first: Wallet brings the pass up around its time.',
};
export default reservations;
+3
View File
@@ -276,5 +276,8 @@ const reservations: TranslationStrings = {
'Los archivos necesitan conexión para subirse. Quita los nuevos para guardar la reserva sin conexión, o guarda cuando vuelvas a estar en línea.',
'reservations.needsConnection': 'Esto necesita conexión. Vuelve a intentarlo cuando TREK esté de nuevo en línea.',
'reservations.exportCsvFiltered': 'Exportar las {shown} de {total} filas mostradas (CSV)',
'reservations.wallet.add': 'Añadir a Apple Wallet',
'reservations.wallet.failed': 'No se pudo crear el pase de Wallet. Inténtalo de nuevo.',
'reservations.wallet.undated': 'Primero ponle una fecha a la reserva: Wallet muestra el pase en torno a ese momento.',
};
export default reservations;
+3
View File
@@ -275,5 +275,8 @@ const reservations: TranslationStrings = {
'Failide üleslaadimiseks on vaja ühendust. Eemalda uued, et broneering võrguühenduseta salvestada, või salvesta, kui oled taas võrgus.',
'reservations.needsConnection': 'Selleks on vaja ühendust. Proovi uuesti, kui TREK on taas võrgus.',
'reservations.exportCsvFiltered': 'Ekspordi näidatud read, {shown} / {total} (CSV)',
'reservations.wallet.add': 'Lisa Apple Walletisse',
'reservations.wallet.failed': 'Walleti passi ei õnnestunud luua. Proovi uuesti.',
'reservations.wallet.undated': 'Anna broneeringule kõigepealt kuupäev: Wallet näitab passi selle aja paiku.',
};
export default reservations;
+4
View File
@@ -279,5 +279,9 @@ const reservations: TranslationStrings = {
"Les fichiers ont besoin d'une connexion pour être envoyés. Retire les nouveaux pour enregistrer la réservation hors ligne, ou enregistre une fois de retour en ligne.",
'reservations.needsConnection': 'Cela nécessite une connexion. Réessaie dès que TREK sera de nouveau en ligne.',
'reservations.exportCsvFiltered': 'Exporter les {shown} lignes affichées sur {total} (CSV)',
'reservations.wallet.add': 'Ajouter à Apple Wallet',
'reservations.wallet.failed': 'Impossible de créer la carte Wallet. Réessayez.',
'reservations.wallet.undated':
'Donnez d’abord une date à la réservation : Wallet affiche la carte autour de ce moment.',
};
export default reservations;
+4
View File
@@ -278,5 +278,9 @@ const reservations: TranslationStrings = {
'Τα αρχεία χρειάζονται σύνδεση για να ανέβουν. Αφαίρεσε τα νέα για να αποθηκεύσεις την κράτηση εκτός σύνδεσης ή αποθήκευσε όταν είσαι ξανά online.',
'reservations.needsConnection': 'Αυτό χρειάζεται σύνδεση. Δοκίμασε ξανά μόλις το TREK συνδεθεί ξανά.',
'reservations.exportCsvFiltered': 'Εξαγωγή των {shown} από {total} εμφανιζόμενων γραμμών (CSV)',
'reservations.wallet.add': 'Προσθήκη στο Apple Wallet',
'reservations.wallet.failed': 'Δεν ήταν δυνατή η δημιουργία της κάρτας Wallet. Δοκιμάστε ξανά.',
'reservations.wallet.undated':
'Δώστε πρώτα ημερομηνία στην κράτηση: το Wallet εμφανίζει την κάρτα γύρω από εκείνη την ώρα.',
};
export default reservations;
+3
View File
@@ -277,6 +277,9 @@ const reservations: TranslationStrings = {
'העלאת קבצים דורשת חיבור. הסירו את הקבצים החדשים כדי לשמור את ההזמנה במצב לא מקוון, או שמרו כשתחזרו לרשת.',
'reservations.needsConnection': 'הפעולה הזו דורשת חיבור. נסו שוב כש-TREK יחזור לרשת.',
'reservations.exportCsvFiltered': 'ייצוא {shown} מתוך {total} השורות המוצגות (CSV)',
'reservations.wallet.add': 'הוספה ל-Apple Wallet',
'reservations.wallet.failed': 'לא ניתן היה ליצור את כרטיס ה-Wallet. אנא נסו שוב.',
'reservations.wallet.undated': 'קודם תנו להזמנה תאריך: Wallet מציג את הכרטיס סביב המועד שלה.',
};
export default reservations;
+3
View File
@@ -277,5 +277,8 @@ const reservations: TranslationStrings = {
'A fájlok feltöltéséhez kapcsolat kell. Távolítsd el az újakat, hogy offline mentsd a foglalást, vagy ments, amikor újra online vagy.',
'reservations.needsConnection': 'Ehhez kapcsolat kell. Próbáld újra, amint a TREK újra online lesz.',
'reservations.exportCsvFiltered': 'A megjelenített {shown} sor exportálása a(z) {total} közül (CSV)',
'reservations.wallet.add': 'Hozzáadás az Apple Wallethez',
'reservations.wallet.failed': 'Nem sikerült létrehozni a Wallet-kártyát. Próbáld újra.',
'reservations.wallet.undated': 'Előbb adj dátumot a foglalásnak: a Wallet az időpont körül jeleníti meg a kártyát.',
};
export default reservations;
+3
View File
@@ -266,5 +266,8 @@ const reservations: TranslationStrings = {
'File perlu koneksi untuk diunggah. Hapus file baru untuk menyimpan pemesanan secara offline, atau simpan saat kamu kembali online.',
'reservations.needsConnection': 'Ini memerlukan koneksi. Coba lagi begitu TREK kembali online.',
'reservations.exportCsvFiltered': 'Ekspor {shown} dari {total} baris yang ditampilkan (CSV)',
'reservations.wallet.add': 'Tambahkan ke Apple Wallet',
'reservations.wallet.failed': 'Pass Wallet tidak dapat dibuat. Silakan coba lagi.',
'reservations.wallet.undated': 'Beri tanggal pada pemesanan dulu: Wallet menampilkan pass di sekitar waktunya.',
};
export default reservations;
+3
View File
@@ -274,5 +274,8 @@ const reservations: TranslationStrings = {
'I file hanno bisogno di una connessione per essere caricati. Rimuovi quelli nuovi per salvare la prenotazione offline, oppure salva quando sei di nuovo online.',
'reservations.needsConnection': 'Serve una connessione. Riprova quando TREK tornerà online.',
'reservations.exportCsvFiltered': 'Esporta le {shown} righe mostrate su {total} (CSV)',
'reservations.wallet.add': 'Aggiungi ad Apple Wallet',
'reservations.wallet.failed': 'Impossibile creare il pass di Wallet. Riprova.',
'reservations.wallet.undated': 'Prima dai una data alla prenotazione: Wallet mostra il pass intorno a quel momento.',
};
export default reservations;
+3
View File
@@ -266,5 +266,8 @@ const reservations: TranslationStrings = {
'ファイルのアップロードには接続が必要です。オフラインで予約を保存するには新しいファイルを外すか、オンラインに戻ってから保存してください。',
'reservations.needsConnection': 'この操作には接続が必要です。TREK がオンラインに戻ってから、もう一度お試しください。',
'reservations.exportCsvFiltered': '表示中の {shown}/{total} 行をエクスポート (CSV)',
'reservations.wallet.add': 'Apple Walletに追加',
'reservations.wallet.failed': 'Walletパスを作成できませんでした。もう一度お試しください。',
'reservations.wallet.undated': '先に予約に日付を入れてください。Walletはその時刻の前後にパスを表示します。',
};
export default reservations;
+3
View File
@@ -266,5 +266,8 @@ const reservations: TranslationStrings = {
'파일을 업로드하려면 연결이 필요합니다. 오프라인으로 예약을 저장하려면 새 파일을 제거하거나, 다시 온라인이 되면 저장하세요.',
'reservations.needsConnection': '연결이 필요한 작업입니다. TREK가 다시 온라인이 되면 다시 시도하세요.',
'reservations.exportCsvFiltered': '표시된 {total}행 중 {shown}행 내보내기 (CSV)',
'reservations.wallet.add': 'Apple Wallet에 추가',
'reservations.wallet.failed': 'Wallet 패스를 만들지 못했습니다. 다시 시도하세요.',
'reservations.wallet.undated': '먼저 예약에 날짜를 지정하세요. Wallet은 그 시간 즈음에 패스를 보여 줍니다.',
};
export default reservations;
+3
View File
@@ -275,5 +275,8 @@ const reservations: TranslationStrings = {
'Bestanden hebben een verbinding nodig om te uploaden. Verwijder de nieuwe om de boeking offline op te slaan, of sla op zodra je weer online bent.',
'reservations.needsConnection': 'Hiervoor is een verbinding nodig. Probeer het opnieuw zodra TREK weer online is.',
'reservations.exportCsvFiltered': 'De {shown} van {total} getoonde rijen exporteren (CSV)',
'reservations.wallet.add': 'Voeg toe aan Apple Wallet',
'reservations.wallet.failed': 'De Wallet-pas kon niet worden gemaakt. Probeer het opnieuw.',
'reservations.wallet.undated': 'Geef de boeking eerst een datum: Wallet toont de pas rond dat moment.',
};
export default reservations;
+3
View File
@@ -289,5 +289,8 @@ const reservations: TranslationStrings = {
'Pliki wymagają połączenia do przesłania. Usuń nowe, aby zapisać rezerwację offline, albo zapisz po powrocie online.',
'reservations.needsConnection': 'To wymaga połączenia. Spróbuj ponownie, gdy TREK znów będzie online.',
'reservations.exportCsvFiltered': 'Eksportuj wyświetlone wiersze: {shown} z {total} (CSV)',
'reservations.wallet.add': 'Dodaj do Apple Wallet',
'reservations.wallet.failed': 'Nie udało się utworzyć karty Wallet. Spróbuj ponownie.',
'reservations.wallet.undated': 'Najpierw nadaj rezerwacji datę: Wallet pokazuje kartę w okolicach tego terminu.',
};
export default reservations;
+3
View File
@@ -287,5 +287,8 @@ const reservations: TranslationStrings = {
'Для загрузки файлов нужно подключение. Удалите новые, чтобы сохранить бронирование офлайн, или сохраните, когда снова будете онлайн.',
'reservations.needsConnection': 'Для этого нужно подключение. Попробуйте ещё раз, когда TREK снова будет онлайн.',
'reservations.exportCsvFiltered': 'Экспортировать показанные строки: {shown} из {total} (CSV)',
'reservations.wallet.add': 'Добавить в Apple Wallet',
'reservations.wallet.failed': 'Не удалось создать карту Wallet. Повторите попытку.',
'reservations.wallet.undated': 'Сначала укажите дату брони: Wallet показывает карту ближе к этому времени.',
};
export default reservations;
+3
View File
@@ -283,5 +283,8 @@ const reservations: TranslationStrings = {
'Súbory potrebujú na nahratie pripojenie. Odstráň nové, aby si rezerváciu uložil offline, alebo ulož, keď budeš znova online.',
'reservations.needsConnection': 'Na toto je potrebné pripojenie. Skús to znova, keď bude TREK opäť online.',
'reservations.exportCsvFiltered': 'Exportovať zobrazené riadky, {shown} z {total} (CSV)',
'reservations.wallet.add': 'Pridať do Apple Wallet',
'reservations.wallet.failed': 'Kartu do Wallet sa nepodarilo vytvoriť. Skúste to znova.',
'reservations.wallet.undated': 'Najprv rezervácii zadajte dátum: Wallet kartu zobrazí okolo toho času.',
};
export default reservations;
+3
View File
@@ -274,5 +274,8 @@ const reservations: TranslationStrings = {
'Filer behöver en anslutning för att laddas upp. Ta bort de nya för att spara bokningen offline, eller spara när du är online igen.',
'reservations.needsConnection': 'Det här kräver en anslutning. Försök igen när TREK är online igen.',
'reservations.exportCsvFiltered': 'Exportera de {shown} av {total} visade raderna (CSV)',
'reservations.wallet.add': 'Lägg till i Apple Wallet',
'reservations.wallet.failed': 'Wallet-kortet kunde inte skapas. Försök igen.',
'reservations.wallet.undated': 'Ge bokningen ett datum först: Wallet visar kortet runt den tiden.',
};
export default reservations;
+3
View File
@@ -268,5 +268,8 @@ const reservations: TranslationStrings = {
'การอัปโหลดไฟล์ต้องใช้การเชื่อมต่อ ลบไฟล์ใหม่ออกเพื่อบันทึกการจองแบบออฟไลน์ หรือบันทึกเมื่อกลับมาออนไลน์',
'reservations.needsConnection': 'การทำงานนี้ต้องใช้การเชื่อมต่อ ลองอีกครั้งเมื่อ TREK กลับมาออนไลน์',
'reservations.exportCsvFiltered': 'ส่งออก {shown} จาก {total} แถวที่แสดง (CSV)',
'reservations.wallet.add': 'เพิ่มลงใน Apple Wallet',
'reservations.wallet.failed': 'สร้างบัตร Wallet ไม่สำเร็จ โปรดลองอีกครั้ง',
'reservations.wallet.undated': 'กำหนดวันที่ให้การจองก่อน Wallet จะแสดงบัตรในช่วงเวลานั้น',
};
export default reservations;
+3
View File
@@ -275,5 +275,8 @@ const reservations: TranslationStrings = {
'Dosyaların yüklenmesi için bağlantı gerekir. Rezervasyonu çevrimdışı kaydetmek için yenilerini kaldır ya da tekrar çevrimiçi olduğunda kaydet.',
'reservations.needsConnection': 'Bunun için bağlantı gerekiyor. TREK yeniden çevrimiçi olduğunda tekrar dene.',
'reservations.exportCsvFiltered': 'Gösterilen {total} satırdan {shown} tanesini dışa aktar (CSV)',
'reservations.wallet.add': 'Apple Cüzdan’a Ekle',
'reservations.wallet.failed': 'Cüzdan kartı oluşturulamadı. Lütfen tekrar deneyin.',
'reservations.wallet.undated': 'Önce rezervasyona bir tarih verin: Cüzdan kartı o zamana yakın gösterir.',
};
export default reservations;
+3
View File
@@ -287,5 +287,8 @@ const reservations: TranslationStrings = {
"Для завантаження файлів потрібне з'єднання. Видаліть нові, щоб зберегти бронювання офлайн, або збережіть, коли знову будете онлайн.",
'reservations.needsConnection': "Для цього потрібне з'єднання. Спробуйте ще раз, коли TREK знову буде онлайн.",
'reservations.exportCsvFiltered': 'Експортувати показані рядки: {shown} із {total} (CSV)',
'reservations.wallet.add': 'Додати до Apple Wallet',
'reservations.wallet.failed': 'Не вдалося створити картку Wallet. Повторіть спробу.',
'reservations.wallet.undated': 'Спершу вкажіть дату бронювання: Wallet показує картку ближче до цього часу.',
};
export default reservations;
+3
View File
@@ -269,5 +269,8 @@ const reservations: TranslationStrings = {
'Cần có kết nối để tải tệp lên. Gỡ các tệp mới để lưu đặt chỗ khi ngoại tuyến, hoặc lưu khi bạn trực tuyến trở lại.',
'reservations.needsConnection': 'Thao tác này cần kết nối. Hãy thử lại khi TREK trực tuyến trở lại.',
'reservations.exportCsvFiltered': 'Xuất {shown} trong {total} dòng đang hiển thị (CSV)',
'reservations.wallet.add': 'Thêm vào Apple Wallet',
'reservations.wallet.failed': 'Không thể tạo thẻ Wallet. Vui lòng thử lại.',
'reservations.wallet.undated': 'Hãy đặt ngày cho đặt chỗ trước: Wallet sẽ hiện thẻ vào khoảng thời gian đó.',
};
export default reservations;
+3
View File
@@ -261,5 +261,8 @@ const reservations: TranslationStrings = {
'reservations.filesNeedConnection': '上傳檔案需要網路連線。移除新檔案即可離線儲存這筆預訂,或在恢復連線後再儲存。',
'reservations.needsConnection': '此操作需要網路連線。請在 TREK 恢復連線後再試一次。',
'reservations.exportCsvFiltered': '匯出顯示的 {shown}/{total} 列 (CSV)',
'reservations.wallet.add': '加入 Apple 錢包',
'reservations.wallet.failed': '無法產生錢包票卡。請重試。',
'reservations.wallet.undated': '請先為訂位設定日期:錢包會在那個時間前後顯示票卡。',
};
export default reservations;
+3
View File
@@ -261,5 +261,8 @@ const reservations: TranslationStrings = {
'reservations.filesNeedConnection': '上传文件需要网络连接。移除新文件即可离线保存这条预订,或在恢复联网后再保存。',
'reservations.needsConnection': '此操作需要网络连接。请在 TREK 恢复联网后重试。',
'reservations.exportCsvFiltered': '导出显示的 {shown}/{total} 行 (CSV)',
'reservations.wallet.add': '添加到 Apple 钱包',
'reservations.wallet.failed': '无法生成钱包卡券。请重试。',
'reservations.wallet.undated': '请先为预订设置日期:钱包会在那个时间前后显示卡券。',
};
export default reservations;
+1
View File
@@ -398,6 +398,7 @@ TREK's own place index, the [TREK Places API](TREK-Places-API), answers the sugg
| `TREK_SERVICES_HOSTS` | Comma-separated hosts that receive the installation key over https. | `places.liketrek.com,photon.liketrek.com,valhalla.liketrek.com` |
| `TREK_PHOTON_URL` | The address search of the TREK services, a [Photon](https://github.com/komoot/photon) instance. Once the installation holds a key it is the geocoder in place of Nominatim: the OpenStreetMap half of search and suggestions, import geocoding, the right-click on the map and pasted map links, matching a Google place to its OpenStreetMap record and naming an opened OpenStreetMap place. An empty answer is the answer, and so is a miss (an error, a timeout, a refused key or a spent allowance): Nominatim is not asked in its place. It is never asked with `TREK_SERVICES=off`, without a key, or when `NOMINATIM_URL` is set. See [Address search](TREK-Places-API#address-search). | `https://photon.liketrek.com` |
| `TREK_VALHALLA_URL` | The Valhalla of the TREK services. Routes, elevation and the description of a line go there unless the admin picks another routing service under Admin, Default user settings; this server relays them with the installation key, so the browser never talks to it. A trailing slash is stripped, and a value that is not a full URL aborts startup. | `https://valhalla.liketrek.com` |
| `TREK_WALLET_PASS_URL` | Signs a booking's Apple Wallet pass when someone taps **Add to Apple Wallet** in the iOS app. Only liketrek holds the pass certificate, so the pass's texts (the booking's name, times, places, seat or room and confirmation code, nothing else) go there once per tap and are not stored. Never asked with `TREK_SERVICES=off`; a value that is not a full URL aborts startup. | `https://push.liketrek.com/v1/wallet/pass` |
On Helm these go under `env:` in `values.yaml`, except `TREK_SERVICES_KEY`, which goes under `secretEnv:`. The chart passes `TREK_PLACES_ENABLED` through whenever it is set at all, so an unquoted `false` or `--set env.TREK_PLACES_ENABLED=false` reaches the container as well.