# Evalixo : la documentation technique

> Source : https://evalixo.com/tech/ (version du 10 septembre 2026). Ce fichier est généré automatiquement à partir de la page publique ; la page reste la référence.

L'architecture du produit : surfaces, modules, cloisonnement des données, contrats d'API. Utile à un intégrateur ou à un responsable informatique, pas à l'utilisateur quotidien.

back · branche **main** · **f983ae5**

Monolithe modulaire Spring Boot 4 / Java 21, découpé par fonctionnalité et non par couche technique. Cette page répond à quatre questions : **qui appelle qui**, **par où**, **avec quoi**, et surtout **pour quel périmètre** — c’est-à-dire quelles données chaque chemin a le droit de voir.

16modules métier

267fichiers Java

20 251lignes de code

47tables en base

17contrôleurs HTTP

3surfaces d’appel

## Trois surfaces, trois façons de savoir « à qui sont ces données »

Tout le reste en découle. Une requête arrive par l’une de ces trois portes, et c’est la porte qui détermine comment la société propriétaire est établie — donc ce que la requête a le droit de lire. La couleur de cette page encode cette distinction, partout.

### Recruteur

connecté · a une société

**Routes**

/api/candidates/api/tests/api/questions /api/campaigns/api/clients/api/positions /api/questionnaires/api/results/api/shares /api/result-shares /api/settings/api/dashboard

**Garde d’entrée**

`ROLE_RECRUITER` ou `ROLE_ADMIN`, jeton Keycloak validé

**Périmètre**

La société vient du **claim `tenant`** du jeton, traduite en identifiant puis mise en cache pour la requête.

Chaque requête SQL porte **`tenant_id = :tenantId`**, écrit à la main dans le service.

### Administration

connecté · sans société

**Routes**

/api/admin/**/api/admin/support-messages/actuator/**

**Garde d’entrée**

`ROLE_ADMIN` exclusivement

**Périmètre**

Aucune société : l’identifiant est **nul**. La persona traverse tous les clients — c’est son métier.

Le périmètre est **passé en paramètre** (`countFor(tenantId)`, `overviews(tenantId)`). Si le paramètre est faux, la donnée d’un autre client sort.

### Public

anonyme · un jeton

**Routes**

/api/public/evaluations/{token} /api/public/journeys/{token} /api/public/test-shares/{token} /api/public/result-shares/{token}

**Garde d’entrée**

`permitAll` — aucune authentification

**Périmètre**

Le **jeton opaque** désigne une évaluation ; la société du candidat en est déduite, puis sert de périmètre à toutes les lectures suivantes.

**Borne de durée**

Quand l’**évaluation** porte le réglage **passage en une seule fois** (figé à l’envoi, V6), **toute écriture de passage** est jugée contre `started_at + durée + marge` : au-delà, la session est close et l’écriture refusée en `409 SITTING_TIME_OVER`. La durée reste **vive** (dérivée du test), le réglage reste **figé** ; la marge est une tolérance serveur, **non exposée dans l’interface**.

Le jeton est **le seul justificatif**. Tout ce qui est lu ensuite doit descendre de lui, jamais d’un identifiant envoyé par le client.

## Le trajet d’une requête, de l’HTTP à la table

Quatre couches, toujours dans cet ordre, jamais de saut. Chaque module rejoue la même pile pour lui-même — c’est ce qui rend le découpage par fonctionnalité tenable.

filtresavant tout

Corrélation de requête, chaîne de sécurité, décodage du jeton Keycloak.

CorrelationIdFilterSecurityConfigJwtDecoder

api/contrôleur + DTO

Traduit HTTP en appel de service. Aucune règle métier, aucun accès direct à un dépôt.

17 contrôleursrecords DTOBean Validation

application/les règles

Le cœur : règles métier, transactions, **et le filtre de société**. C’est ici qu’on décide ce que l’appelant a le droit de voir.

CurrentUserProvider@Transactional27 services

domain/types purs

Règles sans dépendance : barème, difficulté, validité d’une échéance, états d’une campagne. Testables sans Spring, sans base.

ScoringRulesInvitationRulesCampaignRulesPositionRules

infra/entités + dépôts

JPA. Tout est `LAZY`, les listes se chargent en quelques `IN(...)` groupés — jamais une requête par ligne.

@Entity ×25batch_fetch_size 64Flyway

PostgreSQL47 tables

Schéma détenu par Flyway (`ddl-auto: none`). L’application s’y connecte avec **un seul compte, propriétaire du schéma**.

9 migrationsclés uuidindex (tenant_id, …)

## La matrice des dépendances entre modules

Un module ne touche **jamais** les entités ni les dépôts d’un autre : il passe par le service propriétaire. Chaque losange est un appel réel, et sa couleur dit la surface depuis laquelle il part — donc si le périmètre y est établi par une identité, par un paramètre, ou par un jeton public.

| appelant → appelé | Cand. | Test | Quest. | Résult. | Q-aire | Régl. | Camp. | Client | Poste | Dash. | Compte | Annu. | Mails |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| AdminServiceadmin |  |  |  |  |  |  |  |  |  |  |  |  |  |
| CampaignServicecampaign |  |  |  |  |  |  |  |  |  |  |  |  |  |
| CampaignLaunchServicecampaign |  |  |  |  |  |  |  |  |  |  |  |  |  |
| JourneyServicecampaign |  |  |  |  |  |  |  |  |  |  |  |  |  |
| CampaignViewAssemblercampaign |  |  |  |  |  |  |  |  |  |  |  |  |  |
| InvitationServicecandidate |  |  |  |  |  |  |  |  |  |  |  |  |  |
| SurveillanceServicecandidate |  |  |  |  |  |  |  |  |  |  |  |  |  |
| DossierServicecandidate |  |  |  |  |  |  |  |  |  |  |  |  |  |
| CandidateViewAssemblercandidate |  |  |  |  |  |  |  |  |  |  |  |  |  |
| DashboardServicedashboard |  |  |  |  |  |  |  |  |  |  |  |  |  |
| TestShareServiceshare |  |  |  |  |  |  |  |  |  |  |  |  |  |
| ShareDraftServiceshare |  |  |  |  |  |  |  |  |  |  |  |  |  |
| ShareAdoptionServiceshare |  |  |  |  |  |  |  |  |  |  |  |  |  |
| ShareViewAssemblershare |  |  |  |  |  |  |  |  |  |  |  |  |  |
| SittingServicesitting |  |  |  |  |  |  |  |  |  |  |  |  |  |
| TestServicetests |  |  |  |  |  |  |  |  |  |  |  |  |  |
| PositionServiceposition |  |  |  |  |  |  |  |  |  |  |  |  |  |
| InvitationMailServicecandidate |  |  |  |  |  |  |  |  |  |  |  |  |  |
| ShareMailServiceshare |  |  |  |  |  |  |  |  |  |  |  |  |  |

appel depuis la surface recruteur depuis l’administration depuis le passage public

Trois modules ont vu leur service unique éclater en **services à une responsabilité**, et la matrice le rend visible : les lignes qui portaient tout portent maintenant chacune leur part. Un module ne gagne pas de façade au passage — le contrôleur injecte les services dont il a besoin, et l’assembleur de vues reste **package-private**, jamais exposé hors module.

**Module `campaign`** — `CampaignService` tient le CRUD du brouillon et le cycle DRAFT → RUNNING → CLOSED ; `CampaignLaunchService` tient le **lancement** (génération des évaluations, un e-mail parcours par candidat) et la vie du vivier d’une campagne ouverte ; `JourneyService` tient le parcours public `/p/` et le listener de fin de parcours — le listener vit avec sa logique, jamais dans le service de CRUD. La composition partagée de la fiche (lectures batch des catalogues, scores bornés aux évaluations de LA campagne) vit dans `CampaignViewAssembler`.

**Module `share`** — `TestShareService` tient le cycle de vie du lien (création, relance, prolongation, révocation, clôture) ; `ShareDraftService` tient **tout ce que le tiers fait de son lien public** (consultation, verdict, éditeur du chantier, plafonds) — c’est la seule ligne verte du module ; `ShareAdoptionService` tient ce que le recruteur **décide d’un rendu** (revue, acceptation, demande de modification). `ShareViewAssembler` décore la ligne, et `ShareMailService` ne sert que les deux services recruteur — jamais le service public.

**Module `candidate`** — il n’a plus un service unique mais **cinq services à une responsabilité** : `CandidateProfileService` (profils, CV, recherche paginée et facettes), `InvitationService` (cycle de vie des évaluations, surface campagne), `DossierService` (dossiers questionnaire), `SurveillanceService` (consentement, incidents, captures) et `SittingAccessService` (le port du module `sitting`) — plus `CandidateViewAssembler`, la composition batch des fiches, **package-private**, jamais exposée hors module. Seuls ceux qui traversent une frontière apparaissent en ligne : `CandidateProfileService` et `SittingAccessService` restent entièrement dans le module.

Deux enseignements se lisent directement dans la matrice. **Les services les plus consommés** sont `TestService`, `ResultService` et les services du module `candidate` — ils sont appelés depuis les trois surfaces à la fois, ce qui en fait les points où une erreur de périmètre se propage le plus loin. Et **le graphe reste acyclique** : aucun couple de services ne s’appelle mutuellement, ce qui est obtenu en passant par du SQL natif là où un cycle menaçait (`positions` ↔ `clients`, `campaigns` ↔ `positions`).

## Comment la société est établie, cas par cas

Recruteur

- Le jeton Keycloak porte un claim `tenant` (un slug, ex. `demo`).
- Il est traduit en identifiant de société, et la société ainsi que la ligne utilisateur sont **provisionnées à la volée** au tout premier appel.
- Le résultat est **mis en cache pour la durée de la requête**, pour ne pas refaire la traduction à chaque service.
- Claim absent → `403 TENANT_MISSING`.

Administration

- La persona est **pure jeton** : aucun rattachement, identifiant de société nul.
- Un identifiant nul restreint naturellement les requêtes de catalogue aux items **officiels**.
- Pour voir la production d’un client, l’admin appelle des **surcharges à périmètre explicite** : `countFor(tenantId)`, `overviews(tenantId)`, `adminSearch(tenantId, …)`.
- Les comptes recruteurs sont gérés dans Keycloak via un compte de service, pas en base.

Public

- Aucun jeton d’identité : il n’y a pas d’utilisateur courant sur ce chemin.
- Le jeton d’URL résout **une** évaluation ; la société du candidat en est déduite et devient le périmètre de tout ce qui suit.
- Trois jetons distincts : `/t/` le passage d’une épreuve, `/p/` le parcours d’une campagne, `/s/` le partage d’un test à un tiers.
- Jeton inconnu → **404 uniforme** ; échéance dépassée → `410`.

## Les seize modules et ce qu’ils possèdent

Chaque module possède ses tables et personne d’autre ne les lit. Les carrés indiquent les couches présentes : api · application · domain · infra.

candidate35 f.

Profils, invitations, dossiers, surveillance, CV. Le module le plus chargé — cinq services à une responsabilité, un assembleur interne, et les trois surfaces. `DossierService` (V9) possède aussi le dépôt des pièces jointes du dossier — `DossierFileView`, lu par une projection explicite, jamais par une liste.

candidatesevaluationscandidate_cvquestionnaire_dossiersdossier_filessecurity_eventssecurity_captures

question18 f.

Banque de questions : 7 types de réponse, visuels d’énoncé, catalogue officiel partagé.

questionsquestion_images

result17 f.

Sessions de passage et réponses. La note n’est jamais stockée : recalculée à chaque lecture.

resultsresult_answers

share16 f.

Partage d’un test à un tiers par lien expirant, commande d’un test à écrire, et partage d’un **rapport de candidat** (`/r/`, consultation ou correction déléguée). Des services à une responsabilité — cycle du lien, chantier du tiers, décision du recruteur, accès public d’un rapport — plus un assembleur interne. Le rapport lui-même est lu par un **port** de `result/application` : le module n’en touche jamais l’infra ni l’api.

test_sharesresult_shares

campaign16 f.

Brouillon → en cours → clôturée. Trois services à une responsabilité — CRUD et cycle, lancement, parcours public (`JourneyController` vit ici) — plus un assembleur interne.

campaignscampaign_candidates

tests15 f.

Ensembles ordonnés de questions — du **pur contenu** depuis V6 : plus aucun réglage de déroulement sur `tests`. Agrégats recalculés à chaque écriture, jamais saisis, dont la durée dérivée qui sert de base à la borne d’un passage.

tests

questionnaire12 f.

Le dossier déclaratif : ni note, ni chrono, ni catalogue officiel. Toujours privé. Les **pièces à joindre** (V9) sont déclarées ici — hors sections — et leur référentiel famille → extensions → content-types vit en un seul endroit, `domain/AttachmentFormats`.

questionnairesquestionnaire_sectionsquestionnaire_fieldsquestionnaire_attachments

client10 f.

La société cliente du cabinet. Fiche légère, réutilisable entre postes.

clientssectors

position9 f.

Le poste à pourvoir, réutilisable entre campagnes. Rattachement au client facultatif.

positions

settings8 f.

Compte par utilisateur ; marque blanche et gabarits d’e-mail par société.

tenant_settingsuser_settings

admin8 f.

Supervision de la plateforme. Ne possède aucune table : il agrège les autres modules.

aucune table

support6 f.

« Contactez-nous » : écriture seule côté recruteur, lecture côté admin.

support_messages

dashboard4 f.

Agrégation de la page d’accueil. Aucune table, aucune entité : il compose.

aucune table

sitting4 f.

Le passage du candidat. Petit, mais c’est la seule surface joignable sans authentification — et c’est lui qui fait respecter la borne de durée.

aucune table

shared69 f.

Le socle : identité de l’appelant, sociétés et comptes, modèle d’erreur RFC 9457, e-mails, événements — et `shared/sitting`, le type unique du déroulement, monté par `candidate` et `campaign`, lu par `sitting` : trois modules le partagent, il ne pouvait vivre dans aucun.

tenantsusers

config3 f.

La chaîne de sécurité, le CORS, l’exécuteur asynchrone des e-mails et le format de logs.

aucune table

## Les règles qui tiennent l’ensemble

Elles ne sont pas décoratives : chacune explique une forme qu’on retrouve partout dans le code.

**Un module ne franchit jamais la frontière d’un autre**Ni entité, ni dépôt. Les lectures croisées passent par le service propriétaire, qui renvoie des vues figées.

**Le serveur ne fait jamais confiance au client**Agrégats d’un test, compteurs d’usage, difficulté : tout est recalculé à l’écriture. Un champ dérivé envoyé par le front est ignoré.

**Le figé est du `jsonb`, le vivant est une table**Une réponse fige la question telle qu’elle était : réviser la banque ne réécrit jamais un rapport passé.

**Un nombre FIXE de requêtes par écran**Les listes chargent leurs dépendances en quelques `IN(...)` groupés. Jamais une requête par ligne — la règle est explicite et tenue.

**Une seule traduction des erreurs**Les règles lèvent des exceptions métier ; un unique gestionnaire les rend en Problem Details (RFC 9457) avec un code stable que le front connaît.

**Flyway possède le schéma**`ddl-auto: none` partout. Hibernate ne crée ni ne modifie jamais une table : 9 migrations versionnées font foi (V1 schéma, V2 démo, puis V3 à V9 incrémentales).

**Le déroulement se fige à l’envoi**Depuis V6, les dix interrupteurs de surveillance et le *passage en une seule fois* sont **figés sur l’évaluation** (et sur la campagne) au moment de l’envoi, comme `test_name` ou `difficulty` : le serveur ne les relit jamais sur le test. Une invitation partie est donc immuable, et deux envois du même test peuvent se dérouler différemment.

**Aucun job planifié : tout se dérive à l’écriture**La limite effective se recompose à la lecture (réglage figé de l’évaluation + durée **vive** du test), jamais persistée, et le dépassement se constate à l’**écriture suivante** du candidat. Aucun ordonnanceur ne ferme de session en fond.

## Le contrat des listes : la pagination est côté serveur

Les listes de candidats ne descendent plus entières : le serveur pagine, filtre et trie, et renvoie toujours la même enveloppe. Le contexte de score n’est calculé **que pour les candidats de la page servie** — un nombre fixe de requêtes batch, quelle que soit la taille du cabinet.

**Une enveloppe unique**`{ items, page, pageSize, totalCount }` — `totalCount` est le total **filtré**, stable d’une page à l’autre.

**Des paramètres communs**`page` (défaut 0) et `size` (défaut 25, **max 100**), clampés en silence — jamais un 4xx pour une borne ; `q` trimé, insensible à la casse, jokers `LIKE` échappés ; `sort` = `recent` (défaut) ou `alpha`.

**Le tri par score est abandonné**Le score est dérivé des réponses à la lecture : il ne se trie pas en SQL. Seuls `recent` et `alpha` subsistent.

**Les pickers ne sont pas un endpoint**Les sélecteurs de candidats des modales appellent la même liste avec `q` + `size=20` — aucun endpoint séparé.

| Endpoint | Contrat |
|---|---|
| GET /api/candidates | Paginé ; filtres portés par les évaluations du candidat (`status` — dont `expired` dérivé de l’échéance —, `testId` ou `test`, `domain`, `source`) ; `q` cherche sur prénom+nom et e-mail. |
| GET /api/candidates/facets | Les options de filtre du cabinet — `{ tests: [{id, name}…], domains: […] }`, deux `GROUP BY` sur les évaluations. |
| GET /api/admin/candidates | Paginé pareil (`page`/`size`/`sort`) ; `tenantId` et `q` optionnels — `q` exige 2 caractères utiles, sinon il est ignoré. |
| GET /api/results | **Supprimé.** Il n’y a plus de liste de résultats : seuls restent `GET /api/results/{id}` et le `PATCH` de correction. |
| POST /api/results/{id}/shares | **201.** Partage du **rapport** à un tiers — corps `{ clientId?, email, mode: VIEW\|REVIEW, expiresAt, includeSurveillance?, message? }`. Le rapport se partage **entier** ; seule la **surveillance** se retire (`includeSurveillance`, objet et non primitif, défaut **vrai**). Le rapport d’un autre cabinet est un **404**. |
| GET /api/result-shares | La liste du cabinet — sa **propre** ressource, jamais une section greffée sur `GET /api/shares` : deux collections, deux contrats, deux onglets. Puis `PUT …/{id}/expiry`, `POST …/{id}/close`, `POST …/{id}/remind` (204), `DELETE …/{id}` (204). |
| GET /api/public/result-shares/{token} | Le rapport complet servi au tiers : réponses, **instantanés entiers** (corrigé, barème, consignes), identité du candidat et **journal de surveillance** — d’un seul aller-retour, pour que la page du tiers se dessine à l’identique de celle du recruteur ; la section `surveillance` est **absente** quand le partage l’exclut (sanitisation à la source). Les octets (captures, visuels d’énoncé) se tirent à part, bornés au token. **404** uniforme sur un token inconnu, **410** `SHARE_EXPIRED` passé l’échéance ou après clôture. Le tenant vient de la ligne du token, jamais d’un JWT. |
| PATCH /api/public/result-shares/{token}/answers/{questionId}/review | La correction déléguée — réservée au mode `REVIEW`, sinon **422** `SHARE_INVALID` (le token est valide, c’est le lien qui ne permet pas le geste). Mêmes règles que la correction du recruteur : FREE notée seulement, 0…barème de l’**instantané**, score re-dérivé. |
| PUT /api/public/result-shares/{token}/verdict | La **décision** du tiers sur le candidat — même corps et mêmes valeurs que le `PUT` recruteur (`keep\|review\|reject\|null`). Réservée au mode `REVIEW` (422 `SHARE_INVALID` sinon) ; elle vit sur l’**évaluation**, via un port à périmètre explicite du module `candidate` — le tenant vient de la ligne du token, jamais d’un JWT. |
| GET/PUT /api/settings/result-share-email | Le gabarit tenant du partage de rapport — miroir de `share-email`, variables `{{candidateName}}`, `{{testName}}`, `{{companyName}}`, `{{link}}`, `{{expiresAt}}`. |

## Le contrat de l’envoi : le déroulement voyage dans un bloc `sitting`

Le déroulement d’un passage — les dix interrupteurs de surveillance et le *passage en une seule fois* — n’est plus une propriété du **test** mais un choix fait **à l’envoi**, transporté par un bloc facultatif du corps, puis **figé** sur l’évaluation. `TestRequest`/`TestDto` n’ont plus ni bloc `security` ni `timeLimitEnabled` : le builder est du pur contenu.

| Endpoint | Contrat |
|---|---|
| POST /api/candidates/{id}/evaluations | Corps `{ testId \| questionnaireId, expiresAt, cc?, sitting? }`. Le bloc `sitting` est **facultatif** et vaut pour **cette** invitation. |
| POST /api/campaigns/{id}/launch | Corps **facultatif** `{ sitting? }` — le **même** bloc. **Un seul** réglage pour toutes les épreuves de type test du parcours, validé contre la durée de **chacune**, figé sur la campagne puis recopié sur chaque évaluation de test générée. |
| GET /api/campaigns/{id} | `Detail.sitting` relit le réglage figé — neutre tant que la campagne est un brouillon. Un **enrôlement tardif** sur une campagne `RUNNING` réutilise ce réglage sans le redemander ni le rejuger. |
| GET /api/public/evaluations/{token} | Les mesures annoncées, le consentement et la borne de durée se lisent sur l’**évaluation**, jamais sur le catalogue. Idem pour `monitored` du parcours `/p/`. |

**La même forme en entrée et en sortie**`{ singleSitting?, marginMinutes?, security?: { fullscreenRequired, focusLossDetection, copyPasteBlocked, secondScreenDetection, cameraRequired, microphoneRequired, webcamCaptures, screenCaptures, shuffleQuestions, shuffleOptions } }` — **toutes** les clés facultatives, et des objets, jamais des primitifs : une clé absente vaut « désactivé » au lieu d’un 400 Jackson.

**Bloc absent ⇒ défauts neutres**Reprise libre, aucune mesure. `marginMinutes` absent avec `singleSitting: true` ⇒ **10** — le front ne l’envoie jamais, la tolérance reste serveur.

**Ignoré pour un questionnaire**Un dossier déclaratif ne se surveille ni ne se chronomètre : le bloc est accepté puis **ignoré**, et le front ne l’envoie pas.

**Un seul type, dans `shared/`**`SittingSettings` est un `@Embeddable` monté sur `evaluations` **et** `campaigns`, lu par le module `sitting`. Trois modules le touchent : un `infra` de module l’aurait rendu illégal partout ailleurs (ArchUnit).

## Les pièces à joindre du dossier : déclarées au questionnaire, figées à l’envoi

Le recruteur déclare les pièces attendues **sur le questionnaire** — libellé, `required`, familles de formats — dans des tables filles ordonnées (`questionnaire_attachments`, `questionnaire_attachment_formats`), jamais du `jsonb` vivant. Le gel à l’envoi les copie dans le gabarit du dossier ; le dépôt du candidat (`dossier_files`) référence l’`attachmentId` **figé** du gabarit — jamais une FK vers `questionnaire_attachments` — de sorte qu’un questionnaire remanié ou supprimé après l’envoi ne change rien à un dossier déjà parti.

| Endpoint | Contrat |
|---|---|
| PUT /api/public/evaluations/{token}/dossier/files/{attachmentId} | Multipart, champ `file`. Réponse **200** `{ attachmentId, filename, contentType, sizeBytes, uploadedAt }`. Refus **404** (pièce hors du gabarit figé), **422** `FILE_TYPE_NOT_ALLOWED`, **413** `UPLOAD_TOO_LARGE`, **409** si le dossier est déjà rendu. |
| DELETE /api/public/evaluations/{token}/dossier/files/{attachmentId} | Retire le dépôt. **204**, **idempotent** — retirer une pièce jamais déposée ne casse rien. |
| GET /api/candidates/{id}/evaluations/{eid}/dossier/files/{attachmentId} | Téléchargement du binaire, **authentifié**, tenant-scopé, 404 uniforme, `Content-Disposition: attachment`. |

**Un seul juge : l’extension**La correspondance famille → extensions → content-types vit en **un seul endroit**, `questionnaire/domain/AttachmentFormats`. L’extension (minusculée, chemin retiré) **tranche** ; le content-type déclaré ne fait que **confirmer** — un type absent ou générique (`application/octet-stream`) ne déclare rien, l’extension décide seule.

**Le plafond n’est jamais figé**Réglage de plateforme `app.questionnaire.file-max-mb` (env `QUESTIONNAIRE_FILE_MAX_MB`, défaut **10**, plafonné par `spring.servlet.multipart`) — pas une colonne par pièce, pas un choix du recruteur. Il est **relu à chaque dépôt**, jamais figé dans un dossier.

**Une exemption ciblée du filtre de taille public**`PublicRequestSizeFilter` refuse par défaut tout corps `/api/public/**` au-delà de 4 Mo — sauf **ce seul chemin, ce seul verbe** (`PUT .../dossier/files/{attachmentId}`), où le plafond devient `file-max-mb` + 1 Mo d’enveloppe multipart. `server.tomcat.max-swallow-size` passe de 4 à 12 Mo pour que le refus 413 soit **lisible** au lieu de couper la connexion — ce réglage n’accepte rien de plus, il avale un corps déjà refusé.

**La remise juge le gabarit figé**Une pièce obligatoire manquante refuse la remise en **422** `FORM_REQUIRED_FIELD_MISSING` — le même code que pour un champ texte obligatoire vide. Le rate limiting par jeton (4e segment de l’URL) couvre déjà l’endpoint, sans réglage propre.

## Les codes d’erreur transverses

Toute erreur sort au même format : Problem Details (RFC 9457) avec un **code stable** et le `requestId` de corrélation — l’en-tête `X-Request-Id` entrant est normalisé (64 caractères max, alphabet restreint, sinon remplacé). Aux codes métier propres à chaque module s’ajoutent quatre refus transverses, indépendants de l’écran — et les deux refus du **déroulement**, qui se répondent d’un bout à l’autre : l’un garde le réglage **à l’envoi**, l’autre l’applique côté passage public.

| HTTP | Code | Déclencheur |
|---|---|---|
| 413 | UPLOAD_TOO_LARGE | Téléversement multipart au-delà de **10 Mo**, ou tout corps `/api/public/**` au-delà de **4 Mo** — refusé sur le `Content-Length` déclaré, avant toute matérialisation. Seule exception à ce dernier plafond : le dépôt d’une pièce jointe de dossier (voir § Pièces à joindre), borné par le réglage de plateforme à la place. |
| 409 | CONCURRENT_MODIFICATION | Verrou optimiste : deux écritures concurrentes sur une question, un test, un candidat, une évaluation ou un résultat. Traduit proprement — jamais un 500. |
| 429 | TOO_MANY_REQUESTS | Rate limiting : `/api/public/**` à **120/min** avec un burst immédiat de **40**, par jeton de lien ; e-mail de test et formulaire de support à **5/min** par utilisateur. |
| 422 | SETTINGS_INVALID | Documents de préférences `jsonb` bornés : 32 clés racine max, valeurs simples (un niveau d’objet toléré), 16 Ko sérialisés — le document **fusionné** est jugé, un patch ne contourne pas la borne. |
| 409 | SITTING_TIME_OVER | Borne de durée dépassée sur une écriture de passage (réponse, remise, journal de surveillance, reprise) — l’**évaluation** portait le réglage **passage en une seule fois**. La session est d’abord **close d’office** avec les réponses déjà données — en transaction propre, sinon le rollback du refus emporterait la clôture — puis l’écriture est refusée. |
| 400 | INVALID_SITTING_SETTINGS | Réglage de déroulement refusé **à l’envoi** : marge hors de `[0, 240]` minutes (le miroir du `CHECK` de la base — borne API seulement, l’interface n’expose pas ce champ), ou **passage en une seule fois** demandé sur une épreuve qui ne dure rien — validé contre la durée de **chacune** des épreuves de type test au lancement d’une campagne. Même famille que `TEST_NOT_SENDABLE` : le front attend un **400** sur un refus d’envoi. `TEST_INVALID` a disparu avec la règle qu’il gardait. |

## Les e-mails : rendus en transaction, expédiés après commit

Un e-mail n’est jamais sur le chemin critique d’une requête HTTP — et il ne part jamais pour une transaction annulée.

**Rendu en transaction**Le service compose le message complet (destinataire, sujet, corps rendus) pendant la transaction métier, avec les données qu’elle voit.

**Expédié après commit, en asynchrone**Un listener *after-commit* le remet à un exécuteur borné : l’envoi n’a lieu que si la transaction a réussi, et un échec d’envoi ne fait jamais échouer la requête.

**Best-effort, mais compté**Tout échec est journalisé et mesuré : compteurs `skilltest.mail.sent` / `skilltest.mail.failures` exposés par l’actuator.

**Trois fournisseurs, zéro défaut silencieux**`logging` (dev — journalise sans envoyer), `smtp`, `brevo`. Hors dev, l’application **refuse de démarrer** sans fournisseur explicite : le mode `logging` journaliserait les liens de passage des candidats.

## Migrations & déploiement

**Une lignée gelée : V1 + V2**`V1__schema.sql` (le modèle complet) et `V2__demo.sql` (le contenu de démonstration) sont **figées** — elles ne se rééditent jamais. Une base restée sur l’ancienne lignée doit être réinitialisée ; une base déjà sur V1/V2 — la production — ne l’est jamais.

**Tout le reste est incrémental**Chaque évolution de schéma est une migration V3, V4… — `V3` ajoute le verrou optimiste (évaluations, résultats) et le rattachement des images téléversées aux chantiers de partage, `V4` installe l’extension `unaccent` de la recherche, `V5` les deux colonnes du *passage en une seule fois* (activé ou non, marge `CHECK [0, 240]` — tolérance serveur, non exposée dans l’interface), `V6` les déplace vers l’envoi, `V7` ajoute `result_shares` (le partage d’un rapport à un tiers) et la colonne de gabarit `tenant_settings.result_share_email`, `V8` ajoute `share_messages` (le fil de conversation d’une collaboration), `V9` ajoute les **pièces à joindre** d’un dossier de questionnaire.

**`V9` : les pièces à joindre**`questionnaire_attachments` (libellé, `required`, position) + table enfant `questionnaire_attachment_formats` (familles dans un `CHECK`, lignes ordonnées) côté questionnaire, et `dossier_files` (le dépôt du candidat, `UNIQUE (dossier_id, attachment_id)` — un fichier par pièce, redéposer **écrase** — `bytes` en `BYTEA`, cascade totale sur `questionnaire_dossiers`) côté dossier. **Aucune colonne de taille** : le plafond est un réglage de plateforme, jamais figé par pièce.

**`V7` : partager un rapport**`result_shares` — jumelle de `test_shares`, en plus court : la cible est un `result_id` (`ON DELETE CASCADE` — un partage sans rapport n’a aucun sens), le `mode` vaut `VIEW` ou `REVIEW`, et il n’existe **aucun** équivalent d’`allow_answers` : un rapport se partage **entier**. Seule exception, `include_surveillance` (défaut `TRUE`) : les signaux anti-triche parlent du **comportement** du candidat, pas de sa copie — le recruteur peut les retirer, et la section ne quitte alors jamais le serveur. La colonne `origin` (`CHECK ('MANUAL')`) garde la place du futur avis automatique aux tiers en copie, **sans le coder**.

**`V6` : le déroulement quitte le test**Les dix colonnes `sec_*` et les deux colonnes de borne sont ajoutées sur `evaluations` **et** sur `campaigns` (défauts neutres `NOT NULL`, même `CHECK [0, 240]`), **backfillées** depuis le test de chaque évaluation — et depuis la première épreuve de type test des campagnes déjà lancées — puis **DROPées de `tests`**. Rien ne change pour une épreuve déjà envoyée : le backfill lui rend ce que son test portait.

**Deux realms Keycloak**Le realm **DEV** (compose local) embarque les comptes de démonstration et un secret de dev. Le realm **PROD**, cuit dans l’image, n’embarque **ni compte ni secret** : le secret est généré à l’import et recopié une fois dans l’environnement du back.

**Le contrat d’environnement est un fichier**Les variables attendues sont documentées dans `skill-test-infra/northflank/*.env.example`. L’application est **fail-fast** : une variable manquante refuse le démarrage, jamais un défaut silencieux.

## Les garde-fous de qualité

Les règles d’architecture ne sont pas un document : elles cassent le build.

**ArchUnit : des frontières exécutables**Accéder au `api/` ou à l’`infra/` d’un autre module, faire dépendre un contrôleur d’un contrôleur, ou publier un contrôleur admin sans `@PreAuthorize` : le build échoue.

**L’étanchéité entre cabinets est testée**Le test d’intégration exerce le scénario cross-tenant : un recruteur ne lit jamais la donnée d’un autre cabinet, et la régression casserait la CI.

**La CI ne laisse rien passer en silence**Couverture JaCoCo remontée à SonarCloud (analyse bloquante), scan Trivy de l’image Docker, Dependabot hebdomadaire sur Maven et les GitHub Actions.

168tests back

1 863tests front

7parcours e2e Playwright

## Ce que main ne fait pas

### L’étanchéité entre clients repose entièrement sur le code applicatif

*×***Aucune politique de sécurité au niveau des lignes** en base. PostgreSQL sert tout ce qu’on lui demande ; c’est le `tenant_id = :tenantId` des services qui borne, et lui seul.

*×***Un seul compte de connexion**, propriétaire du schéma. Qui l’obtient lit toute la base, tous clients confondus.

*×***Une centaine d’endroits** où le filtre doit être pensé. Rien ne signale un oubli : ni le compilateur, ni un test, ni la base.

*×***Les tables filles ne sont pas filtrables** — réponses, options, champs de questionnaire n’ont pas de colonne de société. Elles ne sont protégées que par la convention « on part toujours du parent ».

Ce n’est pas une fuite connue : les 29 tests d’étanchéité passent, le filtrage actuel est correct. C’est un risque de **prochain oubli**. Le travail qui y répond — politique en base, second rôle SQL contraint — existe sur une branche dédiée, **ni poussée ni fusionnée**.
