Commit Graph
106 Commits
Author SHA1 Message Date
MichaelandClaude Opus 5 481af181fa Fermer les listes du dossier, et déclarer les titres de séjour
CI / verify (pull_request) Canceled after 0s
Quatre champs se saisissaient en texte libre là où la valeur appartient à
une liste : nationalité, pays de naissance, pays de l'adresse, département.
« Frnace » y entrait aussi bien que « France », et le registre imprimait la
faute.

Les pays sont stockés par leur code ISO 3166-1 et nommés à l'affichage par
`Intl.DisplayNames` : un nom dépend de la langue et des versions d'ICU, un
code ne bouge pas, et deux cent cinquante traductions n'ont pas à être
entretenues à la main. Les valeurs héritées — « France » en toutes lettres
— sont reconnues à la lecture puis normalisées au premier enregistrement,
plutôt que blanchies par un menu qui ne les retrouve pas.

Les départements portent la Corse et l'outre-mer : « 2A » n'est pas un
nombre, et le département de naissance sert à la déclaration sociale, qui
attend le code.

Les téléphones gagnent leur indicatif, choisi à part et recollé en un seul
champ. L'enregistrer séparément obligerait chaque lecture — annuaire,
export, SMS — à le recomposer, et un numéro à moitié lu ne joint personne.
La découpe prend l'indicatif le plus long : « +352 » commence par « +3 ».

Le titre de séjour se déclare enfin. `WorkPermit` existait depuis le début
sans qu'aucun écran ne l'alimente. L'échéance est exigée dès qu'un titre est
posé — employer quelqu'un dont l'autorisation a expiré est un délit, et sans
date rien ne peut le signaler à temps. Décocher retire le titre plutôt que
de le laisser alerter pour quelqu'un qui n'en relève plus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:48:51 +02:00
MichaelandClaude Opus 5 b608aa87cf Recueillir à l'embauche ce qu'exige le contrat
Créer un salarié demandait cinq champs. Le contrat, la déclaration et le
registre en réclament une vingtaine, et rien ne permettait de les saisir :
le sexe, le nom de naissance, le pays et le département de naissance, la
situation de famille, les personnes à charge, le téléphone fixe, le
complément d'adresse et l'heure d'embauche n'existaient pas en base.

La migration les ajoute, toutes facultatives : un dossier incomplet doit
pouvoir exister — c'est au registre de signaler ce qui lui manque, pas à
la base de refuser l'embauche. Seul l'envoi des plannings par SMS fait
exception : c'est un consentement, donc faux par défaut, la charge de la
preuve pesant sur l'employeur.

Le pays et le département de naissance sont des colonnes à part et non une
commune saisie librement : la déclaration sociale les demande séparément,
et les rétro-extraire échouerait au premier « Bar-le-Duc (Meuse) ».

Le formulaire devient un panneau latéral : une trentaine de champs posés
au centre masquaient l'annuaire, et on embauche en regardant qui est déjà
là. Le matricule y est proposé à la suite du dernier, attribué dans la
transaction pour que deux embauches simultanées ne tombent pas sur le même
rang. Le nom de naissance vaut le nom de famille quand il n'en diffère
pas, plutôt que d'imposer une ressaisie à l'immense majorité des dossiers.

Le responsable hiérarchique et la politique RTT se posent enfin à
l'embauche : les deux modèles existaient sans qu'aucun écran ne les
alimente.

Le registre unique du personnel gagne sa colonne « sexe » et se tient
désormais au nom de naissance — le nom d'usage peut changer sans que la
personne change.

La migration s'applique au redéploiement : l'entrypoint du conteneur passe
`prisma migrate deploy` avant de démarrer le serveur. Elle n'ajoute que des
colonnes nullables et deux types énumérés, sans réécriture de table.
Écrite et validée contre le schéma, non exécutée ici : aucune base n'était
ouverte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:34:10 +02:00
Michael e27d61dd4c Merge branch 'claude/mfa-lever-barriere-enrolement' 2026-08-10 17:05:09 +02:00
MichaelandClaude Opus 5 3bf2cbabff Éditer le registre unique du personnel
Le bouton menait au registre de paramétrage juridique — un autre document,
qui ne répond à aucune demande d'inspection. Le registre unique du
personnel n'existait pas.

Il se tient par établissement : celui-ci est demandé avant l'édition
plutôt que déduit, car en tirer un au hasard pour une entreprise qui
compte trente sites ne répondrait à rien.

Une ligne par contrat et non par personne : un salarié réembauché a deux
entrées et deux sorties, et les fondre effacerait l'interruption. Les
partis y figurent aussi — c'est l'historique que l'inspection vient
chercher.

Les mentions manquantes sont annoncées avant le téléchargement, et
comptées par salarié parce que la contravention l'est aussi. Découvrir un
registre incomplet en l'ouvrant, c'est le découvrir devant l'inspection.

L'édition est journalisée : le document rassemble l'identité, la
nationalité et le parcours de tout le personnel d'un site.

Une réserve, portée par le code et par le document : PlanFlow ne collecte
pas le sexe, mention pourtant exigée. La colonne reste vide et alimente le
décompte des dossiers incomplets, en attendant le champ.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 16:46:15 +02:00
MichaelandClaude Opus 5 8808fcdb95 Retirer le préambule du registre de paramétrage
L'écran s'ouvrait sur deux paragraphes de doctrine avant de montrer quoi
que ce soit. Le formulaire de saisie demande déjà valeur, source, date
d'effet, population et approbateur : la règle se lit dans les champs, où
elle s'applique, plutôt qu'au-dessus, où elle se saute.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 16:25:37 +02:00
LogiFlow b49ceb1f29 Merge pull request #38 from R0m1k3/claude/mfa-lever-barriere-enrolement
Alléger l'annuaire
2026-08-10 16:17:56 +02:00
MichaelandClaude Opus 5 894e880198 Alléger l'annuaire
Le formulaire d'embauche, posé au bas de la liste, occupait plus de place
que l'effectif qu'on vient consulter — une quinzaine de champs déployés en
permanence pour un geste occasionnel. Il s'ouvre désormais en modale,
depuis le bouton d'en-tête.

`<dialog>` natif plutôt qu'un panneau maison : le piège de focus, la
fermeture par Échap et le fond inerte viennent avec, et une `<div>` doit
les réimplémenter sans jamais les tenir tout à fait.

Le tableau perd sa carte et ses fonds : une liste de personnes se lit
mieux sans cadre autour. Les colonnes suivent l'annuaire de référence —
collaborateur, rôle, email, mobile, rattachement, invitation. Le contrat
quitte le tableau, où il doublait le filtre qui le cherche déjà ; son
absence reste signalée là où elle compte, dans le rattachement.

Le décompte ne s'affiche plus que filtré : « 87 salariés » au-dessus de 87
lignes n'apprend rien.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 15:45:28 +02:00
LogiFlow 1d64989239 Merge pull request #37 from R0m1k3/claude/mfa-lever-barriere-enrolement
Claude/mfa lever barriere enrolement
2026-08-10 12:32:28 +02:00
MichaelandClaude Opus 5 93724b9d66 Rendre le rattachement et le périmètre modifiables
L'onglet montrait les équipes et le périmètre sans jamais permettre de les
changer : un salarié rattaché à la mauvaise équipe n'apparaissait pas sur
la bonne grille, et rien à l'écran n'y remédiait.

Deux formulaires et deux capacités, parce que ce sont deux décisions.
Élargir un périmètre revient à donner accès à des dossiers et des
plannings qu'on n'avait pas : c'est distribuer des droits, donc
`settings.roles.manage`. Rattacher à une équipe relève de l'organisation
du travail, donc `settings.teams.manage`. Les réunir donnerait à l'une le
pouvoir de l'autre.

Le périmètre est remplacé, pas fusionné : un périmètre qui ne fait que
grandir ne se réduit jamais, et retirer un établissement resterait sans
effet — l'exact contraire de ce que l'écran promet. Un périmètre vide sans
accès généralisé est refusé : il ne laisserait plus rien voir.

L'établissement de rattachement reste en lecture seule. Il est porté par
le contrat, et le changer sans avenant ferait diverger le planning du
document opposable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 12:05:01 +02:00
MichaelandClaude Opus 5 91f3b240e1 Poser le contrat au moment de l'embauche
Créer un salarié demandait quatre champs et produisait un dossier vide :
sans contrat il n'apparaît sur aucune grille et ne se déclare pas, sans
équipe il ne s'ordonne nulle part — et jusqu'ici aucun écran ne permettait
de rattraper l'un ni l'autre.

Le contrat est proposé coché, et reste décochable : un remplaçant se
saisit parfois avant que son établissement soit tranché.

Dossier, contrat et rattachement entrent dans la même transaction. Créés
séparément, un incident au milieu laisserait exactement le dossier
incomplet que ce changement vise à supprimer.

Ouvrir un contrat reste une capacité distincte de celle de créer un
dossier : un gestionnaire d'annuaire n'engage pas l'entreprise. Le refus
le dit, et propose de créer le salarié sans contrat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 12:02:45 +02:00
MichaelandClaude Opus 5 5a113de02f Donner à l'annuaire une recherche et des filtres
Six colonnes et rien pour s'y retrouver : au-delà de vingt salariés,
l'effectif se parcourait au défilement. Recherche par prénom, nom ou
matricule, et quatre filtres — établissement, rôle, type de contrat,
présence — plus le tri.

Les critères vivent dans l'adresse et non dans le composant : un effectif
filtré s'envoie par lien, et le retour arrière défait le dernier choix
plutôt que de vider la page. Ils sont appliqués en base, pas après lecture :
plusieurs centaines de dossiers ne se filtrent pas en mémoire à chaque
affichage.

Le tri par nom reste en mémoire, lui : Postgres range « Étienne » après
« Etchegaray », un lecteur français avant.

Les colonnes suivent : rôle, mobile, rattachement et état de l'invitation
remplacent le matricule, qui ne s'affiche plus que sous le nom des salariés
sans compte applicatif — ceux pour qui c'est la seule adresse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 11:41:33 +02:00
MichaelandClaude Opus 5 bb8f84696a Rendre le contrat vivant depuis sa fiche
`createContractAction` et `createAmendmentAction` existaient depuis le
début sans qu'aucun écran ne les appelle : du code mort côté interface, et
un contrat qu'on ne pouvait ni ouvrir ni modifier une fois le salarié créé.

Trois gestes sur le contrat en cours — lire le détail, déclarer un
changement, terminer — et l'ouverture d'un contrat quand il n'y en a pas.

`endContractAction` date et clôt le contrat plutôt que de le supprimer :
un contrôle porte sur une période révolue, et l'effacer effacerait la
preuve que le salarié a travaillé, ainsi que la base de son solde de tout
compte.

Le formulaire de forfait jours ne découvre la convention individuelle et
sa date d'accord qu'une fois le forfait choisi : la règle métier les exige,
les autres contrats n'en ont que faire.

Les établissements du formulaire de création se lisent avec
`members.contract.create` et non `settings.access` : ouvrir un contrat
n'est pas administrer un établissement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 11:37:31 +02:00
MichaelandClaude Opus 5 c6abbaadc5 Faire de la fiche salarié un dossier à onglets, et le rendre saisissable
La fiche empilait tout sur une page, en lecture seule. Le dossier personnel
existait pourtant en base — date et lieu de naissance, nationalité, adresse,
contact d'urgence, NIR et IBAN chiffrés — sans qu'aucun écran ne l'écrive :
un salarié créé restait un dossier vide que rien ne permettait de remplir.

Cinq onglets, cinq routes : une fiche s'envoie par lien et se rouvre au même
endroit. Le bandeau porte ce qui ne change pas d'un onglet à l'autre, et le
gabarit le tient une fois pour toutes.

Deux points d'écriture et non un seul. L'état civil et les coordonnées
demandent `members.edit` ; le NIR, l'IBAN et le BIC exigent en plus de
pouvoir les lire. Fondus dans un même formulaire, un profil habilité à
modifier mais pas à lire aurait renvoyé des champs vides — et effacé ce qui
ne lui avait jamais été montré. Le journal retient qu'ils ont été renseignés,
jamais leur valeur.

Les parcours qui déposaient une pièce ou ouvraient un accès passent
désormais par l'onglet qui les porte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 11:20:18 +02:00
MichaelandClaude Opus 5 5a05fda2f9 Poser un CLAUDE.md à partir du prompt système Fable 5
Repris tel quel, à la demande. Le fichier décrit un autre harnais que
Claude Code : les sections outils, artifacts et mémoire n'y ont pas
d'effet, et les conventions du projet n'y figurent pas encore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 10:32:24 +02:00
MichaelandClaude Opus 5 61a273548b Lever la barrière d'enrôlement une fois les codes notés
Le layout applicatif calcule l'écran d'enrôlement à son propre rendu, et
un layout n'est pas rejoué par la navigation client : une fois le second
facteur activé, l'écran restait posé sur toutes les routes, quel que soit
le menu choisi.

L'action d'enrôlement ne peut pas réactualiser la route elle-même sans
emporter les codes de secours, qui ne sont affichés qu'une fois. Le
rafraîchissement est donc déclenché par l'écran des codes, sur un bouton :
c'est leur destinataire qui décide du moment où il les a notés.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 10:32:18 +02:00
LogiFlow d4b655f369 Merge pull request #36 from R0m1k3/claude/combohr-app-recreation-plan-yu7wre
Réparer l'intégration continue, et poser des ports internes non communs
2026-08-10 10:11:05 +02:00
Claude b184109e23 Réparer l'intégration continue, et poser des ports internes non communs
**Le test des heures ne pouvait que tomber.** Il interrogeait le mois
précédent, alors que le seed ne pose que deux semaines : la courante et la
précédente. Hors des premiers jours d'un mois, le mois précédent est donc
vide. Mesuré sur une base semée à neuf : un seul mois porte des créneaux,
le mois courant. Le défaut ne se voyait pas en développement, où la base
garde les créneaux des exécutions antérieures — 39 créneaux de juillet
survivaient chez moi à des semis d'il y a plusieurs semaines.

**Le seed ne remettait pas l'état de publication.** Son `update` était vide,
si bien qu'une semaine déjà semée gardait le statut qu'elle avait alors : la
semaine précédente, publiée par définition, restait en brouillon dès qu'elle
avait été semée du temps où elle était la semaine courante. D'où des tests
qui échouent en local et passent en intégration continue — l'écart le plus
coûteux à diagnostiquer. Le statut est désormais réimposé.

**Ports internes.** L'application écoute sur 9317 et la base sur 5439,
jusque dans l'image. Sur un réseau Docker deux conteneurs peuvent écouter le
même port sans se gêner — ce n'est donc pas une correction de collision —
mais une valeur unique de bout en bout lève l'ambiguïté quand plusieurs
piles cohabitent derrière le même proxy, et la configuration du
reverse-proxy porte partout le même nombre. Publication, sonde de santé,
serveur, chaîne de connexion et scripts d'amorçage sont alignés sur une
seule variable par service.

`pnpm verify` : 450 tests. Playwright : 77/77 après remise à zéro du semis.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
2026-08-10 08:10:35 +00:00
LogiFlow dd60dc33b8 Merge pull request #35 from R0m1k3/claude/combohr-app-recreation-plan-yu7wre
La base répare elle-même son mot de passe d'amorçage
2026-08-10 09:32:25 +02:00
Claude 607de2a1e7 Attacher la base au réseau du reverse-proxy, à la demande
L'exploitant veut `db` sur `nginx_default`. Ce n'est pas requis pour que
l'application joigne la base — les deux partagent déjà `interne`, et le
journal le prouve : « password authentication failed for user "planflow" »
suppose que le nom a été résolu, la connexion établie et le dialogue
d'authentification engagé. Une absence de réseau commun donnerait une
résolution de nom impossible, pas un refus d'identifiants.

Le commentaire dit donc ce que cela coûte : la base devient joignable par
tous les conteneurs que sert ce proxy, `POSTGRES_PASSWORD` cesse d'être une
formalité, et le nom `db` publié sur un réseau partagé peut entrer en
collision avec celui d'une autre pile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
2026-08-10 07:31:55 +00:00
Claude 859e51ea20 Faire réparer le mot de passe d'amorçage par le conteneur de la base
`POSTGRES_PASSWORD` n'est lu qu'à la création du volume. Un volume né d'un
déploiement antérieur garde le mot de passe d'alors, et plus rien dans la
pile ne peut le corriger : ni l'application, ni `db-init`, tous deux refusés
à l'entrée. Le symptôme visible — un P1000 sur `planflow_app` — désigne le
mauvais coupable, et la seule issue était du SQL à la main.

La seule position d'où la réparation est possible est l'intérieur du
conteneur de la base, où PostgreSQL accepte la socket locale sans mot de
passe. Le service y réaligne donc le compte d'amorçage sur la valeur de la
pile à chaque démarrage, avant de céder la main au point d'entrée officiel.

Le SQL passe par l'entrée standard et non par `-c` : `psql -c` n'interpole
pas les variables, si bien que `:'pw'` y partait littéralement — mesuré,
« syntax error at or near ":" ». Par l'entrée standard, c'est psql qui met
le mot de passe entre guillemets, et une apostrophe dans le mot de passe ne
casse rien. Vérifié avec un mot de passe en contenant une.

Éprouvé en exécutant le corps de la commande contre un vrai serveur, avec un
point d'entrée factice : la reprise attend le serveur, le réalignement
aboutit, et le mot de passe est bien posé.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
2026-08-10 07:30:47 +00:00
LogiFlow 1dddb948c7 Merge pull request #34 from R0m1k3/claude/combohr-app-recreation-plan-yu7wre
Poser le bit exécutable du point d'entrée dans l'image
2026-08-10 09:14:32 +02:00
Claude 943171234b Poser le bit exécutable du point d'entrée dans l'image
`COPY` recopie le mode du fichier source, et ce mode se perd hors d'un clone
git : archive envoyée à Portainer, contexte reconstitué, système de fichiers
qui ne porte pas la permission.

La panne que cela produit ne ressemble à rien. La forme exec de `CMD` échoue
avant le premier octet de sortie, si bien que le conteneur s'arrête sans une
seule ligne de journal — et l'on cherche un défaut applicatif là où il n'y a
jamais eu de processus.

Mesuré : sans le bit, « Permission denied » et aucune sortie du script ;
avec, le script s'exécute.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
2026-08-10 07:14:12 +00:00
LogiFlow b1be8a575c Merge pull request #33 from R0m1k3/claude/combohr-app-recreation-plan-yu7wre
Nommer le vrai obstacle : le mot de passe gravé dans le volume
2026-08-10 08:26:12 +02:00
Claude 90e069f66a Nommer le vrai obstacle : le mot de passe gravé dans le volume
Le diagnostic disait « mot de passe d'amorçage erroné » sans dire pourquoi
cela arrive, et on cherchait du côté du rôle applicatif — qui n'y est pour
rien.

`POSTGRES_PASSWORD` n'est lu qu'à l'initialisation du volume de la base. Un
volume créé lors d'un déploiement antérieur, fût-il un déploiement qui avait
échoué pour une autre raison, garde le mot de passe d'alors ; le changer
dans la pile n'y touche pas, le volume ne se réinitialise jamais. Le compte
d'amorçage est alors refusé, l'application ne peut plus réparer le rôle
applicatif, et le seul symptôme visible est un P1000 qui désigne le mauvais
coupable.

Le message nomme désormais ce cas et donne les deux issues : repartir d'un
volume neuf quand la base est vide, ou réaligner le mot de passe par la
socket locale — que l'image PostgreSQL accepte sans l'ancien.

Éprouvé sur la panne réelle, sous authentification par mot de passe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
2026-08-10 06:25:45 +00:00
LogiFlow dbb0d68634 Merge pull request #32 from R0m1k3/claude/combohr-app-recreation-plan-yu7wre
Étendre la préservation des formulaires, et corriger la publication
2026-08-10 07:57:59 +02:00
Claude b243df4772 Étendre la préservation des formulaires, et corriger la publication
Suite de PR #31. `PersistentForm` couvre désormais les formulaires où la
saisie compte : ajout d'un salarié, d'un établissement, d'une équipe, d'une
entrée de registre, demande d'absence, heures réelles, correspondances de
paie, réglages d'envoi, conservation, dépôt de pièce.

Le composant gère `select`, `textarea`, cases et boutons radio, et reçoit
`resetAfter` : passé l'état retourné par l'action en cas de succès, le
formulaire revient aux valeurs rendues par le serveur — vide pour un ajout,
la valeur enregistrée pour un champ d'édition. C'est ce qui rend visible une
normalisation faite côté serveur.

Les champs cachés sont exclus. Ils ne portent jamais une saisie mais l'état
de l'application, et les rétablir écrase ce que le serveur vient de
renvoyer. Le cas s'est produit sur `expectedVersion`, le verrou optimiste
d'une semaine de planning.

En éprouvant cela, un défaut plus grave est apparu, antérieur et sans
rapport avec la préservation : le formulaire de publication recevait tantôt
l'action de publication, tantôt celle de dépublication selon l'état, et
cessait de suivre le changement. Lecture de l'en-tête `Next-Action` sur
trois envois consécutifs :

    1er envoi   60b8097e  (dépublier)  → brouillon
    2e  envoi   6012d40f  (publier)    → publiée
    3e  envoi   6012d40f  (publier)    → publiée

Le bouton affichait « Dépublier » et republiait, sans message d'erreur
puisque l'action réellement exécutée réussissait. Une seule action reçoit
maintenant l'intention, portée par le bouton déclencheur — un champ caché
serait remis à sa valeur d'origine par la réinitialisation que React
applique après chaque action, un bouton ne l'est jamais.

Le test du planning fait désormais deux allers-retours dans le même
chargement : le premier envoi d'une page emploie toujours la bonne action,
si bien qu'un seul aller-retour laissait passer le défaut selon l'état où
la semaine avait été trouvée. Vérifié : le test échoue sur l'ancien
câblage, passe sur le nouveau.

450 tests unitaires et d'intégration, 77 de bout en bout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
2026-08-09 19:40:41 +00:00
LogiFlow f39e7a0552 Merge pull request #31 from R0m1k3/claude/combohr-app-recreation-plan-yu7wre
Écran de première installation
2026-08-09 21:11:00 +02:00
Claude 727c05407a Écran de première installation
Les migrations posent le schéma et rien d'autre : une instance neuve n'a
aucun compte et aucun utilisateur, donc personne ne peut se connecter. Le
seed n'y remédie pas — il installe une démonstration et refuse de tourner
en production, à raison.

La première visite est désormais redirigée vers `/installation` : nom de
l'entreprise, premier établissement avec son fuseau, et compte
administrateur. L'écran crée le compte, le catalogue des capacités, les
cinq rôles fournis, le propriétaire et son périmètre, puis ouvre la session
par le chemin ordinaire — le rôle propriétaire exige aussitôt un second
facteur, comme il se doit.

Il ne se rouvre pas. Une table `Installation` d'une seule ligne, contrainte
en base et protégée par un trigger append-only, marque l'instance. Elle est
délibérément hors RLS, et c'est sa raison d'être : la politique d'`Account`
ne laisse voir que le compte courant, si bien qu'une instance installée
paraîtrait vierge à qui n'a pas de session — et la création d'un
propriétaire se rouvrirait à tout venant. Le recensement des politiques
porte l'exception, affirmée dans les deux sens.

Deux défauts trouvés en éprouvant l'écran sur une base réellement vierge :

`INSERT ... RETURNING` sur `Account` était refusé. L'insertion est permise,
mais la relecture de la ligne écrite passe par la politique de lecture, qui
exige un compte courant. L'identifiant est donc tiré côté application et
annoncé avant la création — la règle de partout, appliquée à la
transaction qui crée le compte.

Et un défaut qui dépassait cet écran : React 19 vide les champs non
contrôlés dès qu'une action se termine, refus compris. Les champs vidés
portant `required`, le clic suivant était arrêté par la validation du
navigateur avant d'émettre un `submit` — le formulaire paraissait mort. Le
formulaire de connexion en souffrait aussi ; la suite l'avait manqué parce
qu'aucun test ne soumettait deux fois de suite. `PersistentForm`
photographie la saisie à l'envoi et la rétablit, mots de passe exclus.

Au passage, `Field` rattache son indication par `aria-describedby` : placée
dans le `<label>`, elle entrait dans le nom accessible du champ.

Éprouvé sur une base vierge, image de production, rôle NOSUPERUSER
NOBYPASSRLS : redirection, trois refus motivés, installation, second
facteur exigé, écran refermé pour le propriétaire comme pour un visiteur.
450 tests unitaires et d'intégration, 77 tests de bout en bout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
2026-08-09 19:10:25 +00:00
LogiFlow 759dd97c8e Merge pull request #30 from R0m1k3/claude/combohr-app-recreation-plan-yu7wre
Faire poser le rôle applicatif par l'application elle-même
2026-08-09 20:33:10 +02:00
Claude 49618c57bf Faire poser le rôle applicatif par l'application elle-même
`db-init` suppose un orchestrateur qui honore
`depends_on: service_completed_successfully`. Swarm l'ignore, et une pile
déployée avant l'ajout du service ne le contient même pas. L'application
redémarrait alors en boucle sur un refus d'authentification que le
diagnostic ajouté précédemment décrivait sans que personne puisse le
corriger.

Elle le corrige donc elle-même au démarrage, si on lui confie les
identifiants d'amorçage — et se tait sinon, pour ne pas contrarier un
déploiement qui préfère les garder hors du conteneur applicatif. Le point
d'entrée retire ces variables avant de lancer le serveur : le processus qui
sert les requêtes ne les voit jamais.

Le branchement create/alter se fait côté client et non dans un bloc `DO` :
le corps d'un `DO` est une chaîne littérale, où `$1` n'est pas un paramètre
de requête. L'échappement du mot de passe est confié à `quote_literal`, et
le nom de rôle est refusé s'il n'a pas la forme d'un identifiant.

Éprouvé sous authentification scram réelle : après réalignement, l'ancien
mot de passe est refusé et le nouveau accepté, le rôle reste NOSUPERUSER
NOBYPASSRLS et devient propriétaire de la base.

Au passage, `withTenant` pose un budget de transaction explicite. Tout accès
aux données passe par lui, si bien que le défaut Prisma de 5 s plafonnait en
réalité chaque requête de l'application, et l'échec se présentait en `P2028`
qui ne désigne ni la requête ni la cause.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
2026-08-09 18:32:38 +00:00
LogiFlow 8c6e2ad52a Merge pull request #29 from R0m1k3/claude/combohr-app-recreation-plan-yu7wre
Dire ce qu'il faut faire quand la connexion à la base est refusée
2026-08-09 19:20:25 +02:00
Claude 4767d10507 Dire ce qu'il faut faire quand la connexion à la base est refusée
L'application redémarrait en boucle sur « P1000 » sans rien indiquer. Le point
d'entrée distingue désormais les deux causes et donne le remède.

Les deux codes ne disent pas la même chose, et c'est la mesure qui a permis de
les séparer : **P1010** signifie que le rôle n'existe pas, **P1000** qu'il existe
mais que son mot de passe ne correspond pas à celui que porte la pile — le cas
d'un rôle posé lors d'un déploiement antérieur avec une autre valeur. Le remède
est le même : `db-init` crée le rôle ou réaligne son mot de passe.

Le script d'initialisation lui-même a été éprouvé une fois de plus, y compris
avec l'argument parasite que Docker ajoute quand `entrypoint` est surchargé sans
`command` : il aboutit et pose le rôle.

Ce que je ne peux pas voir d'ici, et qu'il faut regarder sur le serveur : si le
service `db-init` figure dans la pile déployée, et ce qu'il a journalisé. Un
rôle dont le mot de passe ne correspond pas est précisément ce qu'il corrige.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
2026-08-09 17:20:04 +00:00
Michael b5e8ae43c8 Faire passer le seed par la connexion d amorçage
Le seed installe un premier compte de démonstration : il ne peut pas le
créer dans un contexte locataire qui n existe pas encore. Or il se
connectait avec le rôle applicatif, soumis à la row-level security en
FORCE — l upsert Prisma (INSERT ... ON CONFLICT) se heurtait à la politique
de lecture « id = planflow_current_account() », renvoyant NULL.

Utiliser ADMIN_DATABASE_URL (compte d amorçage), comme le fait déjà le
harnais de tests d intégration (tests/integration/admin-db.ts).
2026-08-09 19:05:10 +02:00
LogiFlow 4d0e784b8e Merge pull request #28 from R0m1k3/claude/combohr-app-recreation-plan-yu7wre
Créer le rôle applicatif à chaque démarrage, pas seulement au premier
2026-08-09 17:55:09 +02:00
Claude 4e61d9d4e5 Fusionner le provisionnement du rôle applicatif
Deux implémentations du même correctif se sont croisées. La fusion garde de
chaque côté ce qui manquait à l'autre : le traitement explicite de PGHOST venu
de main, les valeurs par défaut et le transfert de propriété des objets déjà
présents venus d'ici — sans quoi une base ayant tourné avant l'existence du rôle
resterait inexploitable par lui.

Le service db-init figurait deux fois après la fusion automatique ; il est
dédupliqué.

Le port PostgreSQL publié est **lié à la boucle locale**. Publier sert à se
connecter depuis le serveur — sauvegarde, psql — pas depuis le réseau : sans
127.0.0.1, Docker ouvre le port sur toutes les interfaces, et un jeu de données
RH derrière un mot de passe par défaut devient joignable de l'extérieur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
2026-08-09 15:52:54 +00:00
Claude 72809cefb6 Créer le rôle applicatif à chaque démarrage, pas seulement au premier
Les migrations échouaient sur « Authentication failed for planflow_app ». Le
script qui crée ce rôle était monté dans /docker-entrypoint-initdb.d, lequel ne
s'exécute qu'à la **toute première** initialisation du volume : une pile dont le
volume existait déjà — créé par une tentative antérieure — n'a jamais vu passer
le rôle. Je l'avais noté dans le README au lieu de le traiter.

Un service `db-init` rejoue désormais le script à chaque démarrage, et le script
est rendu rejouable : il crée le rôle ou aligne son mot de passe, transfère la
propriété de la base, du schéma et des objets déjà présents. Les identifiants du
superutilisateur restent dans ce service ; les confier au conteneur applicatif
lui donnerait de quoi contourner la row-level security, ce que tout ce montage
cherche à empêcher.

Éprouvé sur une base créée par le superutilisateur, comme celle du client :
rôle posé en NOSUPERUSER NOBYPASSRLS, propriétaire de la base et des tables
préexistantes, capable de se connecter et d'exécuter du DDL ; les 22 migrations
s'appliquent ; la RLS le filtre — deux comptes visibles par le superutilisateur,
zéro par lui hors périmètre. Rejoué une seconde fois sans effet de bord.

Non vérifiable ici : l'authentification par mot de passe, mon cluster de
développement étant en mode `trust`.

Corrigé au passage une interférence entre tests : celui qui retire « Gérer les
rôles » aux rôles semés, pour atteindre le refus de verrouillage, s'exécutait en
parallèle de ceux qui s'appuient sur cette capacité et les faisait échouer par
intermittence. Le fichier passe en série.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
2026-08-09 15:50:23 +00:00
Michael 2c3c67b949 Provisionner le rôle applicatif à chaque docker compose up
Le rôle planflow_app n etait créé qu à la première initialisation du volume :
une base existante au rôle absent (ou au mot de passe changé) bloquait l app
en échec P1000 sans remède automatique.

Ajouter un service db-init, idempotent, qui rejoue docker/init-app-role.sh à
chaque up — branche ELSE re-synchronisant le mot de passe — avant que l app
ne démarre.
2026-08-09 14:22:05 +02:00
Michael f4e3608d72 Publier PostgreSQL sur un port hôte non commun
Plusieurs instances PostgreSQL coexistent sur le serveur : publier la base
sur 55432 par défaut (configurable via POSTGRES_PORT), sans changer le port
interne 5432 utilisé par l'application.
2026-08-09 13:50:50 +02:00
Michael ce52d2a733 Corriger l-execution des scripts shell dans les conteneurs Docker: le checkout Windows (core.autocrlf) convertissait les fins de ligne en CRLF, cassant le shebang des scripts shell dans les images Linux. Imposer eol=lf pour *.sh. 2026-08-09 13:36:34 +02:00
LogiFlow bce8e7ca32 Merge pull request #27 from R0m1k3/claude/combohr-app-recreation-plan-yu7wre
Livrer la CLI de migration dans une disposition qui tient
2026-08-09 13:09:43 +02:00
Claude 0a15275d1b Livrer la CLI de migration dans une disposition qui tient
Le conteneur démarrait puis s'arrêtait sur « Cannot find module
'@prisma/config' ». Le Dockerfile prélevait à la main quelques répertoires de
node_modules — `prisma`, `.bin/prisma`, `@prisma` — en supposant une disposition
plate. pnpm range les dépendances dans un magasin virtuel `.pnpm`, sous des
répertoires au nom haché : la CLI arrivait sans les siennes.

Elle est désormais installée par npm, qui produit une disposition plate,
copiable telle quelle. La version est lue dans package.json plutôt que figée,
pour qu'elle ne diverge pas au premier changement.

Tout ce qui sert aux migrations — modules, schéma, configuration — vit dans un
arbre séparé. Les superposer aux modules de l'application les ferait entrer en
collision : la sortie `standalone` porte `react` en lien symbolique vers le
magasin pnpm, là où l'installation npm l'apporte en répertoire réel. Deux arbres
n'ont rien à s'écraser.

`prisma.config.ts` n'importe plus `dotenv` de façon ferme : la sortie
`standalone` n'embarque que ce que le serveur utilise, et `dotenv` n'en fait pas
partie — l'import aurait fait échouer les migrations au démarrage.

Cette fois l'image a été reconstituée à l'identique et **démarrée** : clé
produite, migrations appliquées, serveur prêt, `/connexion` en 200 et
`/api/sante` rapportant `tenantIsolation: enforced`. C'est ce que j'aurais dû
faire aux trois tentatives précédentes, où je n'avais éprouvé que des morceaux.

La simulation a d'ailleurs trouvé un chemin `/migrator` en dur dans le point
d'entrée ; il est désormais surchargeable, comme l'emplacement de la clé.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
2026-08-09 11:09:23 +00:00
LogiFlow 93bdcd2686 Merge pull request #26 from R0m1k3/claude/combohr-app-recreation-plan-yu7wre
Corriger la construction de l'image, pour de bon
2026-08-09 12:56:05 +02:00
Claude db43356919 Corriger la construction de l'image, pour de bon
`pnpm db:generate` échouait avant même le build : `prisma.config.ts` résolvait
DATABASE_URL par `env()`, qui lève dès le **chargement** du fichier de
configuration quand la variable manque. Or ce fichier est lu par toutes les
commandes du CLI, y compris `generate`, qui ne se connecte à rien — la
construction de l'image exigeait donc une base.

Ma vérification précédente ne valait rien : j'avais éprouvé `pnpm build` seul,
pas `pnpm db:generate && pnpm build`, la ligne qui échoue. Cette fois c'est la
commande exacte du Dockerfile qui a été jouée, sans .env, et elle sort en 0.

Corrigé au passage, du même genre que la troncature de l'écran de conservation :
la liste des périodes de paie s'arrête aux 24 plus récentes sans le dire. Une
période créée hors de cette fenêtre semblait ne pas s'être enregistrée. Le total
est désormais rapporté.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
2026-08-09 10:55:36 +00:00
LogiFlow 006e2db833 Merge pull request #25 from R0m1k3/claude/combohr-app-recreation-plan-yu7wre
Rendre l'image constructible sans les secrets de production
2026-08-09 12:13:55 +02:00
Claude 495b91bf7f Rendre l'image constructible sans les secrets de production
La construction échouait : `src/lib/env.ts` validait le contrat d'environnement
à l'import, et `src/server/db.ts` ouvrait la connexion Prisma de même. Next.js
évalue les modules serveur pendant la construction, où ni la base ni la clé
n'existent — l'image exigeait donc les secrets de production pour être bâtie,
c'est-à-dire de les confier au constructeur. C'était à l'envers : ce sont des
valeurs d'exécution.

Les deux sont désormais résolus à la première lecture. La garantie ne bouge
pas : le premier appel utile arrive bien avant qu'une requête aboutisse, et
échoue avec le même message. Éprouvé en construisant sans aucune variable, ce
que fait exactement Docker.

Les tests décrivaient l'ancien contrat — ils restauraient l'environnement avant
de lire, ce qui ne prouvait plus rien une fois la lecture différée. Réécrits
pour lire pendant que les valeurs sont posées, avec deux cas de plus :
l'import seul n'exige rien, et la lecture d'une configuration absente échoue.

Ajout d'un `.dockerignore`, absent jusqu'ici. `.env` en tête, et ce n'est pas un
détail : il porte la clé de chiffrement et les mots de passe de la base, et
copié dans le contexte il aurait fini dans une couche de l'image, lisible par
quiconque la récupère.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
2026-08-09 10:13:32 +00:00
LogiFlow 6a8c310c87 Merge pull request #24 from R0m1k3/claude/combohr-app-recreation-plan-yu7wre
Produire la clé de chiffrement au premier démarrage
2026-08-09 11:41:48 +02:00
Claude e5524c6299 Produire la clé de chiffrement au premier démarrage
Le déploiement restait bloqué sur ENCRYPTION_KEY. J'ai tenu trop longtemps la
position « pas de valeur par défaut », en confondant deux choses : refuser une
clé livrée avec l'image — ce qui reste juste, une clé publiée dans un dépôt ne
protège rien — et exiger qu'un humain en fabrique une avant tout démarrage.

Une variable d'environnement n'est d'ailleurs pas un bon coffre : elle s'affiche
dans `docker inspect` et dans l'interface de gestion. Un fichier produit au
démarrage, dans un volume distinct de la base et des documents, n'est pas moins
protégé — et une sauvegarde de l'un n'emporte plus la clé de l'autre.

Le point d'entrée la produit donc si elle manque, l'écrit en 0600, et l'affiche
une fois dans les journaux avec ce qu'il faut en faire. Une clé fournie
explicitement l'emporte toujours : un déploiement qui gère ses secrets ailleurs
ne doit pas être contrarié. La pile démarre désormais sans aucune variable.

Éprouvé sur le script lui-même : première exécution, clé de 32 octets produite
et annoncée ; deuxième, reprise en silence depuis le fichier ; avec une clé
fournie, le fichier reste intact.

Corrigé au passage, révélé par un échec transitoire du test : une écriture
disque impossible — volume plein, droits, montage absent — remontait en
exception non traitée. L'écran restait muet, la pièce n'était pas déposée et
rien ne le disait.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
2026-08-09 09:41:24 +00:00
LogiFlow 68e0fd69f0 Merge pull request #23 from R0m1k3/claude/combohr-app-recreation-plan-yu7wre
Rendre le déploiement Docker correct, et la RLS réellement active
2026-08-09 10:40:26 +02:00
Claude ef2868b980 Rendre le déploiement Docker correct, et la RLS réellement active
Trois problèmes, dont un grave, trouvés en corrigeant un échec de déploiement.

**Le compose connectait l'application en superutilisateur PostgreSQL.** Un
superutilisateur contourne toute politique de sécurité au niveau ligne, y
compris déclarée en FORCE : la seconde couche d'isolation était présente en
base et absente des faits. Le README l'interdisait déjà noir sur blanc ; le
chemin de déploiement que nous livrons faisait exactement l'inverse. Un script
d'initialisation crée désormais un rôle `planflow_app` NOSUPERUSER NOBYPASSRLS,
propriétaire de la base — il lui faut ce droit pour migrer, et les politiques
sont en FORCE précisément pour s'appliquer aussi au propriétaire. Mesuré : en
superutilisateur, deux lignes visibles sans compte courant ; avec le rôle
dédié, zéro.

Basculer la base de développement sur ce même rôle a révélé le défaut que le
superutilisateur masquait : `resolveSession` lisait `Membership`, table filtrée
par compte, sans périmètre. Avec la RLS active, plus personne ne pouvait se
connecter. La résolution passe maintenant par une porte étroite — une politique
qui n'ouvre que les lignes dont l'utilisateur est titulaire, sous `app.user_id`
— le temps de trouver le compte, puis repasse par le périmètre ordinaire. La
suite de tests traverse enfin la RLS au lieu de la contourner.

**Les pièces du dossier salarié n'avaient aucun volume.** Elles étaient écrites
dans la couche du conteneur et disparaissaient au premier redéploiement. Une
pièce d'identité perdue ne se reconstitue pas.

Le reste répond à la demande : Postgres préconfiguré — la base n'étant ni
publiée ni attachée au réseau du proxy, ce mot de passe protège d'un conteneur
voisin, pas d'Internet — réseau `nginx_default` déclaré externe avec
l'application seule dessus, et port publié peu courant.

ENCRYPTION_KEY reste la seule variable sans valeur par défaut, et n'en aura
pas : elle chiffre le NIR, l'IBAN et les arrêts de travail. Une clé livrée avec
l'image serait connue de quiconque lit ce dépôt.

Au démarrage, l'application contrôle ses propres privilèges : elle refuse de se
lancer si la base porte plus d'un compte, et se contente d'un avertissement
s'il n'y en a qu'un — bloquer une installation mono-compte fermerait l'accès de
l'entreprise à ses données pour une fuite entre clients qui ne peut pas se
produire.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
2026-08-09 08:39:54 +00:00
LogiFlow ba6482d4ff Merge pull request #22 from R0m1k3/claude/combohr-app-recreation-plan-yu7wre
Configurer les rôles et leurs capacités
2026-08-09 10:08:09 +02:00