feat(r1.1): socle backend du référentiel — modèle, contrat, API, seed, tests

- migration r1_referentiel : Category (EQUIPMENT/COMPONENT_TYPE), Location
  (site → zone, lat/lng + colonne PostGIS générée geography(Point,4326)
  + index GIST), Asset (statut d'équipement), AssetComponent (organe sans
  emplacement PAR CONSTRUCTION), Team, invitation sur User ; migration
  autosuffisante (CREATE EXTENSION IF NOT EXISTS postgis)
- contrat : 21 nouvelles opérations (26 total), générateur OpenAPI étendu
  aux paramètres de chemin ; spec + client web régénérés dans ce commit
- API : modules categories/locations/assets/teams + gestion des personnes
  (liste, rôles, invitation lien 7 j à usage unique, activation publique
  qui connecte directement, mise à jour rôle/équipes) — tout sous
  @RequirePermission ; invariants en service (profondeur 2, kinds,
  catégorie jamais supprimée)
- seed : parc de la maquette validée (5 sites + 8 zones, 8 appareils,
  organes A1/B2, 9 catégories, 2 équipes) — idempotent
- 36 tests verts (couverture 96 % stmts / 85 % branches) : recette
  site→zone→appareil→organes, matrice vivante, invitation→activation ;
  smoke test sur build de prod
- CI : postgres → postgis/postgis:18-3.6 (la migration R1 l'exige)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pr-daaif
2026-07-16 12:27:48 +01:00
parent 6d9aafdab4
commit 266ffaaf1b
36 changed files with 6016 additions and 13 deletions

View File

@@ -7,6 +7,36 @@ import {
} from './schemas/auth';
import { MeResponseSchema } from './schemas/users';
import { HealthResponseSchema } from './schemas/health';
import {
AssetCreateSchema,
AssetComponentCreateSchema,
AssetComponentSchema,
AssetDetailSchema,
AssetsResponseSchema,
AssetUpdateSchema,
CategoriesResponseSchema,
CategoryCreateSchema,
CategorySchema,
CategoryUpdateSchema,
LocationCreateSchema,
LocationSchema,
LocationsResponseSchema,
LocationUpdateSchema,
TeamCreateSchema,
TeamSchema,
TeamsResponseSchema,
TeamUpdateSchema,
} from './schemas/referentiel';
import {
ActivateRequestSchema,
ActivateResponseSchema,
InvitationCreateSchema,
InvitationResponseSchema,
RolesResponseSchema,
UserAdminSchema,
UsersResponseSchema,
UserUpdateSchema,
} from './schemas/users-admin';
/**
* Contrat d'API R0 — source unique de vérité (règle d'or ADR-001).
@@ -23,6 +53,8 @@ export interface ApiOperation {
isPublic?: boolean;
/** ADR-002 : la route N'EXISTE PAS (404) si DEMO_MODE n'est pas actif. */
demoOnly?: boolean;
/** Paramètres de chemin (`{id}` dans path) — tous UUID en R1. */
pathParams?: string[];
request?: { name: string; schema: z.ZodType };
responses: Record<
number,
@@ -86,6 +118,255 @@ export const API_CONTRACT: ApiOperation[] = [
401: { description: 'Non authentifié' },
},
},
{
operationId: 'activateAccount',
method: 'post',
path: '/auth/activate',
summary: 'Activer un compte invité (lien 7 jours) — connecte directement',
tags: ['auth'],
isPublic: true,
request: { name: 'ActivateRequest', schema: ActivateRequestSchema },
responses: {
200: { description: 'Compte activé et connecté', name: 'AuthResponse', schema: ActivateResponseSchema },
400: { description: 'Lien invalide ou expiré' },
},
},
// ————— R1 · Référentiel —————
{
operationId: 'listCategories',
method: 'get',
path: '/categories',
summary: 'Référentiels administrables (catégories déquipement, types dorganes)',
tags: ['categories'],
responses: {
200: { description: 'Liste', name: 'CategoriesResponse', schema: CategoriesResponseSchema },
},
},
{
operationId: 'createCategory',
method: 'post',
path: '/categories',
summary: 'Ajouter une catégorie',
tags: ['categories'],
request: { name: 'CategoryCreate', schema: CategoryCreateSchema },
responses: {
201: { description: 'Créée', name: 'Category', schema: CategorySchema },
409: { description: 'Nom déjà utilisé pour ce type' },
},
},
{
operationId: 'updateCategory',
method: 'patch',
path: '/categories/{id}',
summary: 'Renommer ou (dés)activer — jamais de suppression si utilisée',
tags: ['categories'],
pathParams: ['id'],
request: { name: 'CategoryUpdate', schema: CategoryUpdateSchema },
responses: {
200: { description: 'Mise à jour', name: 'Category', schema: CategorySchema },
404: { description: 'Inconnue' },
},
},
{
operationId: 'listLocations',
method: 'get',
path: '/locations',
summary: 'Sites et zones (liste plate, le client construit larbre)',
tags: ['locations'],
responses: {
200: { description: 'Liste', name: 'LocationsResponse', schema: LocationsResponseSchema },
},
},
{
operationId: 'createLocation',
method: 'post',
path: '/locations',
summary: 'Créer un site (sans parent) ou une zone (profondeur max 2)',
tags: ['locations'],
request: { name: 'LocationCreate', schema: LocationCreateSchema },
responses: {
201: { description: 'Créé', name: 'Location', schema: LocationSchema },
400: { description: 'Hiérarchie trop profonde' },
},
},
{
operationId: 'updateLocation',
method: 'patch',
path: '/locations/{id}',
summary: 'Modifier un emplacement',
tags: ['locations'],
pathParams: ['id'],
request: { name: 'LocationUpdate', schema: LocationUpdateSchema },
responses: {
200: { description: 'Mis à jour', name: 'Location', schema: LocationSchema },
404: { description: 'Inconnu' },
},
},
{
operationId: 'listAssets',
method: 'get',
path: '/assets',
summary: 'Inventaire des appareils',
tags: ['assets'],
responses: {
200: { description: 'Liste', name: 'AssetsResponse', schema: AssetsResponseSchema },
},
},
{
operationId: 'getAsset',
method: 'get',
path: '/assets/{id}',
summary: 'Fiche appareil (identité + organes)',
tags: ['assets'],
pathParams: ['id'],
responses: {
200: { description: 'Fiche', name: 'AssetDetail', schema: AssetDetailSchema },
404: { description: 'Inconnu' },
},
},
{
operationId: 'createAsset',
method: 'post',
path: '/assets',
summary: 'Créer un appareil (avec ses organes) — le QR découle de la référence',
tags: ['assets'],
request: { name: 'AssetCreate', schema: AssetCreateSchema },
responses: {
201: { description: 'Créé', name: 'AssetDetail', schema: AssetDetailSchema },
409: { description: 'Référence déjà utilisée' },
},
},
{
operationId: 'updateAsset',
method: 'patch',
path: '/assets/{id}',
summary: 'Modifier un appareil (dont son statut déquipement)',
tags: ['assets'],
pathParams: ['id'],
request: { name: 'AssetUpdate', schema: AssetUpdateSchema },
responses: {
200: { description: 'Mis à jour', name: 'AssetDetail', schema: AssetDetailSchema },
404: { description: 'Inconnu' },
},
},
{
operationId: 'addAssetComponent',
method: 'post',
path: '/assets/{id}/components',
summary: 'Ajouter un organe (jamais demplacement propre)',
tags: ['assets'],
pathParams: ['id'],
request: { name: 'AssetComponentCreate', schema: AssetComponentCreateSchema },
responses: {
201: { description: 'Ajouté', name: 'AssetComponent', schema: AssetComponentSchema },
400: { description: 'Le type choisi nest pas un type dorgane' },
},
},
{
operationId: 'removeAssetComponent',
method: 'delete',
path: '/assets/{id}/components/{componentId}',
summary: 'Retirer un organe',
tags: ['assets'],
pathParams: ['id', 'componentId'],
responses: {
204: { description: 'Retiré' },
404: { description: 'Inconnu' },
},
},
{
operationId: 'listTeams',
method: 'get',
path: '/teams',
summary: 'Équipes et leurs membres',
tags: ['teams'],
responses: {
200: { description: 'Liste', name: 'TeamsResponse', schema: TeamsResponseSchema },
},
},
{
operationId: 'createTeam',
method: 'post',
path: '/teams',
summary: 'Créer une équipe',
tags: ['teams'],
request: { name: 'TeamCreate', schema: TeamCreateSchema },
responses: {
201: { description: 'Créée', name: 'Team', schema: TeamSchema },
409: { description: 'Nom déjà utilisé' },
},
},
{
operationId: 'updateTeam',
method: 'patch',
path: '/teams/{id}',
summary: 'Modifier une équipe (nom, description, membres)',
tags: ['teams'],
pathParams: ['id'],
request: { name: 'TeamUpdate', schema: TeamUpdateSchema },
responses: {
200: { description: 'Mise à jour', name: 'Team', schema: TeamSchema },
404: { description: 'Inconnue' },
},
},
{
operationId: 'listUsers',
method: 'get',
path: '/users',
summary: 'Personnes (statut dérivé : actif / invité / désactivé)',
tags: ['users'],
responses: {
200: { description: 'Liste', name: 'UsersResponse', schema: UsersResponseSchema },
},
},
{
operationId: 'listRoles',
method: 'get',
path: '/roles',
summary: 'Les 7 rôles (pour linvitation)',
tags: ['users'],
responses: {
200: { description: 'Liste', name: 'RolesResponse', schema: RolesResponseSchema },
},
},
{
operationId: 'inviteUser',
method: 'post',
path: '/users/invitations',
summary: 'Inviter — crée le compte inactif et émet le lien dactivation (7 j)',
tags: ['users'],
request: { name: 'InvitationCreate', schema: InvitationCreateSchema },
responses: {
201: { description: 'Invitation émise', name: 'InvitationResponse', schema: InvitationResponseSchema },
409: { description: 'Email déjà utilisé' },
},
},
{
operationId: 'resendInvitation',
method: 'post',
path: '/users/{id}/invitation',
summary: 'Régénérer le lien dactivation dun compte non activé',
tags: ['users'],
pathParams: ['id'],
responses: {
201: { description: 'Nouveau lien', name: 'InvitationResponse', schema: InvitationResponseSchema },
409: { description: 'Compte déjà activé' },
},
},
{
operationId: 'updateUser',
method: 'patch',
path: '/users/{id}',
summary: 'Modifier une personne (rôle, équipes, activation du compte)',
tags: ['users'],
pathParams: ['id'],
request: { name: 'UserUpdate', schema: UserUpdateSchema },
responses: {
200: { description: 'Mise à jour', name: 'UserAdmin', schema: UserAdminSchema },
404: { description: 'Inconnue' },
},
},
{
operationId: 'getHealth',
method: 'get',

View File

@@ -1,5 +1,8 @@
export * from './permissions';
export * from './referentiel';
export * from './schemas/auth';
export * from './schemas/users';
export * from './schemas/users-admin';
export * from './schemas/referentiel';
export * from './schemas/health';
export * from './contract';

View File

@@ -0,0 +1,29 @@
/** Vocabulaires du référentiel (R1) — partagés API / web / seed. */
export const ASSET_STATUSES = [
'IN_SERVICE',
'OUT_OF_SERVICE',
'UNDER_MAINTENANCE',
] as const;
export type AssetStatus = (typeof ASSET_STATUSES)[number];
/** Libellés métier (charte §7 : vocabulaire du carnet). */
export const ASSET_STATUS_LABELS: Record<AssetStatus, string> = {
IN_SERVICE: 'En service',
OUT_OF_SERVICE: 'À larrêt',
UNDER_MAINTENANCE: 'En maintenance',
};
export const CATEGORY_KINDS = ['EQUIPMENT', 'COMPONENT_TYPE'] as const;
export type CategoryKind = (typeof CATEGORY_KINDS)[number];
export const CATEGORY_KIND_LABELS: Record<CategoryKind, string> = {
EQUIPMENT: 'Catégorie déquipement',
COMPONENT_TYPE: 'Type dorgane',
};
/** Hiérarchie d'emplacements : site → zone, jamais plus profond (invariant R1). */
export const LOCATION_MAX_DEPTH = 2;
/** Durée de validité d'un lien d'activation (invitation). */
export const INVITATION_TTL_DAYS = 7;

View File

@@ -0,0 +1,158 @@
import { z } from 'zod';
import { ASSET_STATUSES, CATEGORY_KINDS } from '../referentiel';
// ————— Catégories (référentiels administrables) —————
export const CategorySchema = z.object({
id: z.uuid(),
kind: z.enum(CATEGORY_KINDS),
name: z.string(),
isActive: z.boolean(),
usageCount: z.number().int(), // appareils ou organes qui l'utilisent
});
export type Category = z.infer<typeof CategorySchema>;
export const CategoriesResponseSchema = z.object({
categories: z.array(CategorySchema),
});
export type CategoriesResponse = z.infer<typeof CategoriesResponseSchema>;
export const CategoryCreateSchema = z.object({
kind: z.enum(CATEGORY_KINDS),
name: z.string().min(1).max(80),
});
export type CategoryCreate = z.infer<typeof CategoryCreateSchema>;
export const CategoryUpdateSchema = z.object({
name: z.string().min(1).max(80).optional(),
isActive: z.boolean().optional(), // désactivation — jamais de suppression si utilisé
});
export type CategoryUpdate = z.infer<typeof CategoryUpdateSchema>;
// ————— Emplacements (site → zone) —————
export const LocationSchema = z.object({
id: z.uuid(),
name: z.string(),
parentId: z.uuid().nullable(), // null = site
address: z.string().nullable(),
city: z.string().nullable(),
guardianName: z.string().nullable(),
guardianPhone: z.string().nullable(),
latitude: z.number().nullable(),
longitude: z.number().nullable(),
assetCount: z.number().int(), // appareils rattachés (zones incluses pour un site)
});
export type LocationDto = z.infer<typeof LocationSchema>;
export const LocationsResponseSchema = z.object({
locations: z.array(LocationSchema), // liste plate — le web construit l'arbre
});
export type LocationsResponse = z.infer<typeof LocationsResponseSchema>;
export const LocationCreateSchema = z.object({
name: z.string().min(1).max(120),
parentId: z.uuid().optional(),
address: z.string().max(200).optional(),
city: z.string().max(80).optional(),
guardianName: z.string().max(120).optional(),
guardianPhone: z.string().max(40).optional(),
latitude: z.number().min(-90).max(90).optional(),
longitude: z.number().min(-180).max(180).optional(),
});
export type LocationCreate = z.infer<typeof LocationCreateSchema>;
export const LocationUpdateSchema = LocationCreateSchema.partial();
export type LocationUpdate = z.infer<typeof LocationUpdateSchema>;
// ————— Appareils & organes —————
export const AssetComponentSchema = z.object({
id: z.uuid(),
typeId: z.uuid(),
typeName: z.string(),
designation: z.string().nullable(),
});
export type AssetComponentDto = z.infer<typeof AssetComponentSchema>;
export const AssetSchema = z.object({
id: z.uuid(),
reference: z.string(),
brand: z.string(),
model: z.string().nullable(),
serialNumber: z.string().nullable(),
commissionedAt: z.iso.datetime().nullable(),
loadKg: z.number().int().nullable(),
floors: z.number().int().nullable(),
status: z.enum(ASSET_STATUSES),
categoryId: z.uuid(),
categoryName: z.string(),
locationId: z.uuid(),
locationName: z.string(),
siteName: z.string(), // le site racine (= locationName si rattaché au site)
componentCount: z.number().int(),
});
export type AssetDto = z.infer<typeof AssetSchema>;
export const AssetsResponseSchema = z.object({ assets: z.array(AssetSchema) });
export type AssetsResponse = z.infer<typeof AssetsResponseSchema>;
export const AssetDetailSchema = AssetSchema.extend({
components: z.array(AssetComponentSchema),
});
export type AssetDetail = z.infer<typeof AssetDetailSchema>;
export const AssetComponentCreateSchema = z.object({
typeId: z.uuid(), // Category(kind=COMPONENT_TYPE) — vérifié service
designation: z.string().max(120).optional(),
});
export type AssetComponentCreate = z.infer<typeof AssetComponentCreateSchema>;
export const AssetCreateSchema = z.object({
reference: z.string().min(1).max(30),
brand: z.string().min(1).max(80),
model: z.string().max(80).optional(),
serialNumber: z.string().max(80).optional(),
commissionedAt: z.iso.datetime().optional(),
loadKg: z.number().int().positive().optional(),
floors: z.number().int().positive().optional(),
categoryId: z.uuid(), // Category(kind=EQUIPMENT) — vérifié service
locationId: z.uuid(),
components: z.array(AssetComponentCreateSchema).optional(),
});
export type AssetCreate = z.infer<typeof AssetCreateSchema>;
export const AssetUpdateSchema = AssetCreateSchema.omit({ components: true })
.partial()
.extend({ status: z.enum(ASSET_STATUSES).optional() });
export type AssetUpdate = z.infer<typeof AssetUpdateSchema>;
// ————— Équipes —————
export const TeamMemberSchema = z.object({
id: z.uuid(),
displayName: z.string(),
roleName: z.string(),
initials: z.string(),
});
export const TeamSchema = z.object({
id: z.uuid(),
name: z.string(),
description: z.string().nullable(),
members: z.array(TeamMemberSchema),
});
export type Team = z.infer<typeof TeamSchema>;
export const TeamsResponseSchema = z.object({ teams: z.array(TeamSchema) });
export type TeamsResponse = z.infer<typeof TeamsResponseSchema>;
export const TeamCreateSchema = z.object({
name: z.string().min(1).max(80),
description: z.string().max(200).optional(),
memberIds: z.array(z.uuid()).optional(),
});
export type TeamCreate = z.infer<typeof TeamCreateSchema>;
export const TeamUpdateSchema = TeamCreateSchema.partial();
export type TeamUpdate = z.infer<typeof TeamUpdateSchema>;

View File

@@ -0,0 +1,65 @@
import { z } from 'zod';
import { ROLE_NAMES } from '../permissions';
import { AuthResponseSchema } from './auth';
/** Statut dérivé : invité = pas de mot de passe + lien émis ; désactivé prime. */
export const USER_STATUSES = ['active', 'invited', 'disabled'] as const;
export const UserAdminSchema = z.object({
id: z.uuid(),
email: z.email(),
displayName: z.string(),
phone: z.string().nullable(),
role: z.object({ id: z.uuid(), name: z.enum(ROLE_NAMES) }),
teams: z.array(z.object({ id: z.uuid(), name: z.string() })),
status: z.enum(USER_STATUSES),
isDemo: z.boolean(),
});
export type UserAdmin = z.infer<typeof UserAdminSchema>;
export const UsersResponseSchema = z.object({ users: z.array(UserAdminSchema) });
export type UsersResponse = z.infer<typeof UsersResponseSchema>;
export const RolesResponseSchema = z.object({
roles: z.array(z.object({ id: z.uuid(), name: z.enum(ROLE_NAMES) })),
});
export type RolesResponse = z.infer<typeof RolesResponseSchema>;
export const UserUpdateSchema = z.object({
displayName: z.string().min(1).max(120).optional(),
phone: z.string().max(40).optional(),
roleId: z.uuid().optional(),
teamIds: z.array(z.uuid()).optional(), // remplace l'affectation
isActive: z.boolean().optional(),
});
export type UserUpdate = z.infer<typeof UserUpdateSchema>;
// ————— Invitation (maquettes R1 : jamais de mot de passe créé pour autrui) —————
export const InvitationCreateSchema = z.object({
email: z.email(),
displayName: z.string().min(1).max(120),
roleId: z.uuid(),
teamIds: z.array(z.uuid()).optional(),
phone: z.string().max(40).optional(),
});
export type InvitationCreate = z.infer<typeof InvitationCreateSchema>;
/** Le lien est construit par le web (`/activation?token=…`) — l'API ne
* connaît pas son origine publique. L'envoi d'email viendra plus tard ;
* en R1 l'admin copie le lien depuis l'interface. */
export const InvitationResponseSchema = z.object({
userId: z.uuid(),
activationToken: z.string(),
expiresAt: z.iso.datetime(),
});
export type InvitationResponse = z.infer<typeof InvitationResponseSchema>;
export const ActivateRequestSchema = z.object({
token: z.string().min(10),
password: z.string().min(8, '8 caractères minimum'),
});
export type ActivateRequest = z.infer<typeof ActivateRequestSchema>;
/** L'activation connecte directement la personne (AuthResponse). */
export const ActivateResponseSchema = AuthResponseSchema;