back · branche main · f983ae5

Comment le back fonctionne

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
Le point de départ

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.

Par où ça passe

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, …)
Qui appelle qui

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.

Dépendances entre les services applicatifs
appelant  →  appelé Cand.TestQuest. Résult.Q-aireRégl. Camp.ClientPoste Dash.CompteAnnu. 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 campaignCampaignService 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 shareTestShareService 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 (positionsclients, campaignspositions).

Pour quel périmètre

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.
La carte

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
Avec quoi

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

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é.
EndpointContrat
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

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.

EndpointContrat
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: true10 — 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).
V9

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.

EndpointContrat
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.
Quand ça refuse

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.

HTTPCodeDé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.
Best-effort

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.
Livrer

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 écrasebytes 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.
Tenu par la CI

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
Honnêteté

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.