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