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:
parent
672abea1be
commit
33d70a16dd
95
apps/backend/src/domain/services/notification-target.spec.ts
Normal file
95
apps/backend/src/domain/services/notification-target.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
59
apps/backend/src/domain/services/notification-target.ts
Normal file
59
apps/backend/src/domain/services/notification-target.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user