From d41a4f0827c21eec67bc622b63d85f0beacee24d Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 7 Nov 2026 14:11:22 +0100 Subject: [PATCH] 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. --- .../transports/web-push.service.ts | 3 + .../wallet-pass/wallet-pass.controller.ts | 15 ++++ .../nest/wallet-pass/wallet-pass.service.ts | 74 ++++++++++++++++++- .../notifications/web-push.service.test.ts | 5 +- .../wallet-pass.controller.test.ts | 9 +++ .../wallet-pass/wallet-pass.service.test.ts | 30 ++++++++ .../notification/notification.schema.spec.ts | 2 + .../src/notification/notification.schema.ts | 5 ++ shared/src/reservation/reservation.schema.ts | 10 +++ 9 files changed, 151 insertions(+), 2 deletions(-) diff --git a/server/src/nest/notifications/transports/web-push.service.ts b/server/src/nest/notifications/transports/web-push.service.ts index a0cb4d39a..cc39fc7c2 100644 --- a/server/src/nest/notifications/transports/web-push.service.ts +++ b/server/src/nest/notifications/transports/web-push.service.ts @@ -85,6 +85,7 @@ function payloadBytes(payload: PushPayload): number { * chat messages carry a tag, `collab_message:`, 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; diff --git a/server/src/nest/wallet-pass/wallet-pass.controller.ts b/server/src/nest/wallet-pass/wallet-pass.controller.ts index f95ed9b79..1a8242adf 100644 --- a/server/src/nest/wallet-pass/wallet-pass.controller.ts +++ b/server/src/nest/wallet-pass/wallet-pass.controller.ts @@ -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 { + return { saveUrl: await this.passes.googleSaveUrlFor(user.id, tripId, id, walletLanguage(lang)) }; + } } diff --git a/server/src/nest/wallet-pass/wallet-pass.service.ts b/server/src/nest/wallet-pass/wallet-pass.service.ts index e920bf487..d1959f0d1 100644 --- a/server/src/nest/wallet-pass/wallet-pass.service.ts +++ b/server/src/nest/wallet-pass/wallet-pass.service.ts @@ -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 { + 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 { + 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 { + 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 { @@ -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; } diff --git a/server/tests/unit/nest/notifications/web-push.service.test.ts b/server/tests/unit/nest/notifications/web-push.service.test.ts index 35a792659..3547c751a 100644 --- a/server/tests/unit/nest/notifications/web-push.service.test.ts +++ b/server/tests/unit/nest/notifications/web-push.service.test.ts @@ -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'); } }); diff --git a/server/tests/unit/nest/wallet-pass/wallet-pass.controller.test.ts b/server/tests/unit/nest/wallet-pass/wallet-pass.controller.test.ts index 769c3f29b..403720242 100644 --- a/server/tests/unit/nest/wallet-pass/wallet-pass.controller.test.ts +++ b/server/tests/unit/nest/wallet-pass/wallet-pass.controller.test.ts @@ -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'); + }); }); diff --git a/server/tests/unit/nest/wallet-pass/wallet-pass.service.test.ts b/server/tests/unit/nest/wallet-pass/wallet-pass.service.test.ts index 3b4fcb3a8..7dbb12968 100644 --- a/server/tests/unit/nest/wallet-pass/wallet-pass.service.test.ts +++ b/server/tests/unit/nest/wallet-pass/wallet-pass.service.test.ts @@ -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); + }); }); diff --git a/shared/src/notification/notification.schema.spec.ts b/shared/src/notification/notification.schema.spec.ts index b88bc4c75..5ed532a7b 100644 --- a/shared/src/notification/notification.schema.spec.ts +++ b/shared/src/notification/notification.schema.spec.ts @@ -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); }); }); diff --git a/shared/src/notification/notification.schema.ts b/shared/src/notification/notification.schema.ts index 029d7d0f5..c3e680fd4 100644 --- a/shared/src/notification/notification.schema.ts +++ b/shared/src/notification/notification.schema.ts @@ -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; diff --git a/shared/src/reservation/reservation.schema.ts b/shared/src/reservation/reservation.schema.ts index 56c64bb8c..d3efb7ef5 100644 --- a/shared/src/reservation/reservation.schema.ts +++ b/shared/src/reservation/reservation.schema.ts @@ -402,3 +402,13 @@ export const bookingImportConfirmResponseSchema = z.object({ created: z.array(reservationSchema), }); export type BookingImportConfirmResponse = z.infer; + +/** + * 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;