feat(server): name the event in a push and offer a booking as a Google Wallet pass

The push payload carries the event it reports, so the Android app can file a
notification under a channel of its own (chat, trip updates, invitations).
A browser and the iOS extension ignore the field.

GET /api/trips/:tripId/reservations/:id/wallet-pass/google sends the same
request the Apple Wallet signer takes to the TREK services' Google route and
answers with the signed save-to-wallet link the Android app opens. Off with the
other TREK services, and a clear 404 while liketrek has no Wallet issuer yet.
This commit is contained in:
Maurice
2026-11-07 14:11:22 +01:00
parent 41c713fd9a
commit d41a4f0827
9 changed files with 151 additions and 2 deletions
@@ -85,6 +85,7 @@ function payloadBytes(payload: PushPayload): number {
* chat messages carry a tag, `collab_message:<trip path>`, so a burst of them in
* one trip shows as one notification that keeps updating instead of a pile of
* them. Everything else has no tag and never replaces an earlier notification.
* The event goes along so the Android app can sort it into a channel.
*
* The body is cut, at a character boundary and with an ellipsis, until the
* JSON fits MAX_PUSH_PAYLOAD_BYTES. The title and the path are capped first, so
@@ -99,6 +100,8 @@ export function buildPushPayload(
body: '',
...(url ? { url } : {}),
...(url && msg.event === COLLAPSING_EVENT ? { tag: `${COLLAPSING_EVENT}:${url}` } : {}),
// The Android app files it under a channel by event; a browser ignores it.
...(msg.event ? { event: msg.event } : {}),
};
const full = { ...base, body: msg.body };
if (payloadBytes(full) <= MAX_PUSH_PAYLOAD_BYTES) return full;
@@ -1,16 +1,20 @@
import type { User } from '../../types';
import { CurrentUser } from '../auth-core/current-user.decorator';
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
import { ResponseContract } from '../common/response-contract';
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 { googleWalletSaveResponseSchema, type GoogleWalletSaveResponse } from '@trek/shared';
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).
* GET .../wallet-pass/google: the same booking as a Google Wallet save link,
* which the Android app opens.
*/
@Controller('api/trips/:tripId/reservations')
// TripAccessGuard resolves :tripId and 404s a trip the user cannot reach.
@@ -32,4 +36,15 @@ export class WalletPassController {
res.set('Content-Disposition', `attachment; filename="${pass.filename}"`);
res.status(200).type('application/vnd.apple.pkpass').send(pass.bytes);
}
@Get(':id/wallet-pass/google')
@ResponseContract(googleWalletSaveResponseSchema)
async google(
@CurrentUser() user: User,
@Param('tripId', ParseIntPipe) tripId: number,
@Param('id', ParseIntPipe) id: number,
@Query('lang') lang: string | undefined,
): Promise<GoogleWalletSaveResponse> {
return { saveUrl: await this.passes.googleSaveUrlFor(user.id, tripId, id, walletLanguage(lang)) };
}
}
@@ -14,6 +14,9 @@ import type { ConfigType } from '@nestjs/config';
const PASSES_PER_MINUTE = 20;
const RATE_WINDOW_MS = 60_000;
const TIMEOUT_MS = 10_000;
/** The signer's answer for Google Wallet: a JSON object with one signed link. */
const MAX_GOOGLE_ANSWER_BYTES = 16 * 1024;
const GOOGLE_SAVE_PREFIX = 'https://pay.google.com/gp/v/save/';
/** 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';
@@ -45,6 +48,27 @@ export class WalletPassService {
}
async passFor(userId: number, tripId: number, reservationId: number, language: string): Promise<WalletPass> {
const { request, title } = await this.requestFor(userId, tripId, reservationId, language);
return { bytes: await this.sign(request), filename: `${filenameOf(title)}.pkpass` };
}
/**
* The booking as a Google Wallet pass: the same request the Apple signer takes,
* sent to the signer's Google route (�/v1/wallet/google beside �/v1/wallet/pass),
* which answers with a save link signed for liketrek's Wallet issuer.
*/
async googleSaveUrlFor(userId: number, tripId: number, reservationId: number, language: string): Promise<string> {
const { request } = await this.requestFor(userId, tripId, reservationId, language);
return this.signGoogle(request);
}
/** The signer's request for a booking the user can reach, after the switch and the rate limit. */
private async requestFor(
userId: number,
tripId: number,
reservationId: number,
language: string,
): Promise<{ request: object; title: string }> {
if (!this.enabled()) throw new DomainError(404, 'Wallet passes are off on this server');
const allowed = await this.rateLimit.check(
'wallet-pass',
@@ -85,8 +109,51 @@ export class WalletPassService {
await loadWalletWords(language),
);
if (!request) throw new DomainError(422, 'A booking needs a date for a Wallet pass');
return { request, title: row.title };
}
return { bytes: await this.sign(request), filename: `${filenameOf(row.title)}.pkpass` };
private async signGoogle(request: object): Promise<string> {
let response: Response;
try {
response = await safeFetchAdminConfigured(
googleSignerUrl(this.config.walletPassUrl),
{
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(request),
signal: AbortSignal.timeout(TIMEOUT_MS),
},
0,
undefined,
MAX_GOOGLE_ANSWER_BYTES,
);
} catch (err) {
logWarn(`[Wallet pass] Google 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');
// The TREK services answer 404 while liketrek has no Google Wallet issuer yet.
if (response.status === 404) throw new DomainError(404, 'Google Wallet passes are not available yet');
const { bytes, truncated } = await readCapped(response, MAX_GOOGLE_ANSWER_BYTES).catch(() => ({
bytes: Buffer.alloc(0),
truncated: true,
}));
let saveUrl: unknown;
try {
saveUrl = (JSON.parse(bytes.toString('utf8')) as { saveUrl?: unknown }).saveUrl;
} catch {
saveUrl = undefined;
}
if (
response.status !== 200 ||
truncated ||
typeof saveUrl !== 'string' ||
!saveUrl.startsWith(GOOGLE_SAVE_PREFIX)
) {
logWarn(`[Wallet pass] Google signer answered ${response.status}`);
throw new DomainError(502, 'The Wallet pass service could not make this pass');
}
return saveUrl;
}
private async sign(request: object): Promise<Buffer> {
@@ -126,6 +193,11 @@ export class WalletPassService {
}
}
/** The signer's Google route beside its Apple one: �/v1/wallet/pass becomes �/v1/wallet/google. */
function googleSignerUrl(passUrl: string): string {
return passUrl.replace(/\/pass\/?$/, '/google');
}
function stringOrNull(value: unknown): string | null {
return typeof value === 'string' ? value : null;
}
@@ -188,6 +188,7 @@ describe('WebPushService delivery', () => {
body: MSG.body,
url: '/trips/12',
tag: 'collab_message:/trips/12',
event: 'collab_message',
});
const [row] = await rows(user.id);
@@ -477,11 +478,13 @@ describe('buildPushPayload', () => {
body: MSG.body,
url: '/trips/12',
tag: 'collab_message:/trips/12',
event: 'collab_message',
});
// Without a trip to collapse on there is no tag at all, never one for every chat everywhere.
expect(buildPushPayload({ ...MSG, navigateTarget: undefined })).toEqual({
title: MSG.title,
body: MSG.body,
event: 'collab_message',
});
});
@@ -490,7 +493,7 @@ describe('buildPushPayload', () => {
expect(others.length).toBeGreaterThan(5);
for (const event of others) {
const payload = buildPushPayload({ ...MSG, event });
expect(payload, event).toEqual({ title: MSG.title, body: MSG.body, url: '/trips/12' });
expect(payload, event).toEqual({ title: MSG.title, body: MSG.body, url: '/trips/12', event });
expect(payload, event).not.toHaveProperty('tag');
}
});
@@ -53,4 +53,13 @@ describe('WalletPassController', () => {
await expect(controller.pass(user, 42, 7, 'klingon', response() as never)).rejects.toBeInstanceOf(DomainError);
expect(passFor).toHaveBeenCalledWith(5, 42, 7, 'en');
});
it('WALLET-C-003: Google Wallet answers the save link as JSON, in the asked language', async () => {
const googleSaveUrlFor = vi.fn(async () => 'https://pay.google.com/gp/v/save/abc');
const controller = new WalletPassController({ googleSaveUrlFor } as never);
await expect(controller.google(user, 42, 7, 'fr')).resolves.toEqual({
saveUrl: 'https://pay.google.com/gp/v/save/abc',
});
expect(googleSaveUrlFor).toHaveBeenCalledWith(5, 42, 7, 'fr');
});
});
@@ -189,4 +189,34 @@ describe('WalletPassService', () => {
expect(words.seat).toBe('Sitzplatz');
expect((await loadWalletWords('xx')).seat).toBe('Seat');
});
it('WALLET-S-008: Google Wallet gets the same request at the Google route of the signer and answers its save link', async () => {
make();
const saveUrl = 'https://pay.google.com/gp/v/save/eyJhbGciOi.payload.sig';
signerAnswers(200, JSON.stringify({ saveUrl }), 'application/json');
await expect(service.googleSaveUrlFor(1, 42, 7, 'de')).resolves.toBe(saveUrl);
const [url, init] = safeFetchAdminConfigured.mock.calls[0] as [string, RequestInit];
expect(url).toBe('https://push.liketrek.com/v1/wallet/google');
const body = JSON.parse(String(init.body));
expect(body.kind).toBe('hotel');
expect(body.title).toBe('Hotel Granvia Kyoto');
});
it('WALLET-S-009: Google Wallet refusals: off, not set up yet, busy, or a link that is not from Google', async () => {
make('off');
expect(await refusal(service.googleSaveUrlFor(1, 42, 7, 'en'))).toBe(404);
make();
signerAnswers(404, 'not found', 'text/plain');
expect(await refusal(service.googleSaveUrlFor(1, 42, 7, 'en'))).toBe(404);
signerAnswers(429, 'slow down', 'text/plain');
expect(await refusal(service.googleSaveUrlFor(1, 42, 7, 'en'))).toBe(429);
signerAnswers(200, JSON.stringify({ saveUrl: 'https://evil.example/save' }), 'application/json');
expect(await refusal(service.googleSaveUrlFor(1, 42, 7, 'en'))).toBe(502);
signerAnswers(200, 'not json', 'application/json');
expect(await refusal(service.googleSaveUrlFor(1, 42, 7, 'en'))).toBe(502);
safeFetchAdminConfigured.mockRejectedValueOnce(new Error('ECONNREFUSED'));
expect(await refusal(service.googleSaveUrlFor(1, 42, 7, 'en'))).toBe(502);
reservations.getReservationWithJoins.mockResolvedValueOnce({ ...ROW, trip_id: 99 });
expect(await refusal(service.googleSaveUrlFor(1, 42, 7, 'en'))).toBe(404);
});
});
@@ -199,5 +199,7 @@ describe('pushPayloadSchema', () => {
expect(pushPayloadSchema.safeParse({ title: 'T' }).success).toBe(false);
expect(pushPayloadSchema.safeParse({ body: 'B' }).success).toBe(false);
expect(pushPayloadSchema.safeParse({ title: 'T', body: 'B', tag: 5 }).success).toBe(false);
expect(pushPayloadSchema.safeParse({ title: 'T', body: 'B', event: 'trip_invite' }).success).toBe(true);
expect(pushPayloadSchema.safeParse({ title: 'T', body: 'B', event: 1 }).success).toBe(false);
});
});
@@ -125,5 +125,10 @@ export const pushPayloadSchema = z.object({
body: z.string(),
url: z.string().optional(),
tag: z.string().optional(),
/**
* The event it reports (trip_invite, collab_message, ...). The Android app
* sorts notifications into its channels by it; a browser ignores it.
*/
event: z.string().optional(),
});
export type PushPayload = z.infer<typeof pushPayloadSchema>;
@@ -402,3 +402,13 @@ export const bookingImportConfirmResponseSchema = z.object({
created: z.array(reservationSchema),
});
export type BookingImportConfirmResponse = z.infer<typeof bookingImportConfirmResponseSchema>;
/**
* GET /api/trips/:tripId/reservations/:id/wallet-pass/google: the booking as a
* Google Wallet pass, by the save link the TREK services signed. The Android app
* opens it, and Google Wallet adds the pass (native/wallet.ts).
*/
export const googleWalletSaveResponseSchema = z.object({
saveUrl: z.string().url().startsWith('https://pay.google.com/gp/v/save/'),
});
export type GoogleWalletSaveResponse = z.infer<typeof googleWalletSaveResponseSchema>;