feat(domain): deriver la destination d une notification de son type

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018BAUeCFpDkRD6tU5wGsc1C
This commit is contained in:
David 2026-09-07 21:40:55 +02:00
parent 672abea1be
commit 33d70a16dd
2 changed files with 154 additions and 0 deletions

View File

@ -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/<uuid>` 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);
}
});
});

View File

@ -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<string, unknown> | 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;
}