mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
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:
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user