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