diff --git a/apps/backend/src/domain/services/notification-target.spec.ts b/apps/backend/src/domain/services/notification-target.spec.ts new file mode 100644 index 0000000..ab7c688 --- /dev/null +++ b/apps/backend/src/domain/services/notification-target.spec.ts @@ -0,0 +1,95 @@ +import { existsSync } from 'fs'; +import { join } from 'path'; +import { NotificationType } from '../entities/notification.entity'; +import { notificationTarget } from './notification-target'; + +const bookingId = 'b1e20067-db15-4028-a2c0-d8ef7f54e91b'; + +describe('notificationTarget', () => { + it('sends every booking notification to the booking itself', () => { + const bookingTypes = [ + NotificationType.BOOKING_CREATED, + NotificationType.BOOKING_UPDATED, + NotificationType.BOOKING_CONFIRMED, + NotificationType.BOOKING_CANCELLED, + NotificationType.CSV_BOOKING_ACCEPTED, + NotificationType.CSV_BOOKING_REJECTED, + NotificationType.CSV_BOOKING_REQUEST_SENT, + NotificationType.DOCUMENT_UPLOADED, + ]; + + for (const type of bookingTypes) { + expect(notificationTarget(type, { bookingId })).toBe(`/dashboard/bookings/${bookingId}`); + } + }); + + it('falls back to the list when the booking is unknown', () => { + // Mieux vaut la liste que rien : la personne retrouve son dossier. + expect(notificationTarget(NotificationType.CSV_BOOKING_ACCEPTED, {})).toBe( + '/dashboard/bookings' + ); + expect(notificationTarget(NotificationType.CSV_BOOKING_ACCEPTED, undefined)).toBe( + '/dashboard/bookings' + ); + }); + + it('leaves an announcement without a destination', () => { + // Une ligne sans cible ne doit pas se presenter comme cliquable. + expect(notificationTarget(NotificationType.SYSTEM_ANNOUNCEMENT, {})).toBeNull(); + }); + + it.each([ + [NotificationType.RATE_QUOTE_EXPIRING, '/dashboard/search-advanced'], + [NotificationType.USER_INVITED, '/dashboard/settings/users'], + [NotificationType.ORGANIZATION_UPDATE, '/dashboard/settings/organization'], + ])('routes %s to %s', (type, expected) => { + expect(notificationTarget(type, {})).toBe(expected); + }); + + it.each([ + ['../../../admin/users', 'une remontee de chemin'], + ['b1/../../etc', 'un segment compose'], + ['id?next=/admin', 'une chaine de requete'], + ['', 'une chaine vide'], + [42, 'un nombre'], + [{ id: 'x' }, 'un objet'], + ])('refuses %p as a booking id (%s)', (value, _why) => { + // Les metadonnees sont du JSON libre : un identifiant douteux renvoie vers + // la liste, jamais vers une URL fabriquee. + expect(notificationTarget(NotificationType.CSV_BOOKING_ACCEPTED, { bookingId: value })).toBe( + '/dashboard/bookings' + ); + }); + + /** + * Le garde-fou qui compte : chaque destination doit correspondre a une page + * qui existe. Les liens precedents — `/bookings/{id}` et + * `/dashboard/admin/organizations` — visaient des routes disparues, et rien ne + * le signalait. + */ + it('points every destination at a page that exists', () => { + const appDir = join(__dirname, '../../../../frontend/app/[locale]'); + if (!existsSync(appDir)) { + // Depuis l'image backend seule, le frontend n'est pas la : on ne peut pas + // verifier, mais on ne fait pas echouer pour autant. + return; + } + + const destinations = Object.values(NotificationType) + .map(type => notificationTarget(type, { bookingId })) + .filter((target): target is string => target !== null); + + expect(destinations.length).toBeGreaterThan(0); + + for (const destination of new Set(destinations)) { + // `/dashboard/bookings/` correspond au segment dynamique `[id]`. + const segments = destination + .replace(/^\//, '') + .split('/') + .map(segment => (segment === bookingId ? '[id]' : segment)); + + const page = join(appDir, ...segments, 'page.tsx'); + expect(existsSync(page)).toBe(true); + } + }); +}); diff --git a/apps/backend/src/domain/services/notification-target.ts b/apps/backend/src/domain/services/notification-target.ts new file mode 100644 index 0000000..7a2f9c6 --- /dev/null +++ b/apps/backend/src/domain/services/notification-target.ts @@ -0,0 +1,59 @@ +import { NotificationType } from '../entities/notification.entity'; + +/** + * Ou mene une notification. + * + * Une notification n'est pas un message : c'est un pointeur vers quelque chose + * qui a change. Le lien est donc derive du type et des metadonnees, ici et nulle + * part ailleurs — l'interface se contente de suivre. + * + * Les liens etaient jusqu'ici ecrits a la main a chaque appel, et deux d'entre + * eux visaient des routes qui n'existent pas : `/bookings/{id}` (la vraie route + * est `/dashboard/bookings/{id}`) et `/dashboard/admin/organizations` (l'espace + * d'administration a depuis son propre segment `/admin`). Les regrouper permet + * de les eprouver contre les routes reelles, en une seule fois. + * + * Les liens sont **relatifs et sans prefixe de langue** : le frontend est + * localise (`/fr`, `/en`) et ajoute le sien. + */ +export function notificationTarget( + type: NotificationType, + metadata: Record | undefined +): string | null { + const bookingId = asId(metadata?.bookingId); + + switch (type) { + // Toutes les notifications de reservation menent au dossier concerne. + case NotificationType.BOOKING_CREATED: + case NotificationType.BOOKING_UPDATED: + case NotificationType.BOOKING_CONFIRMED: + case NotificationType.BOOKING_CANCELLED: + case NotificationType.CSV_BOOKING_ACCEPTED: + case NotificationType.CSV_BOOKING_REJECTED: + case NotificationType.CSV_BOOKING_REQUEST_SENT: + case NotificationType.DOCUMENT_UPLOADED: + return bookingId ? `/dashboard/bookings/${bookingId}` : '/dashboard/bookings'; + + case NotificationType.RATE_QUOTE_EXPIRING: + return '/dashboard/search-advanced'; + + case NotificationType.USER_INVITED: + return '/dashboard/settings/users'; + + case NotificationType.ORGANIZATION_UPDATE: + return '/dashboard/settings/organization'; + + // Une annonce ne pointe vers rien : la ligne ne doit pas se presenter comme + // cliquable pour n'aboutir nulle part. + case NotificationType.SYSTEM_ANNOUNCEMENT: + return null; + } +} + +/** Un identifiant utilisable dans une URL, ou rien. */ +function asId(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + // Les metadonnees sont du JSON libre : refuser ce qui sortirait du segment. + return trimmed && /^[A-Za-z0-9_-]{1,64}$/.test(trimmed) ? trimmed : null; +}