5f656d05af080608e146fa08f98b3698f0f2d5cf
11
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
f0ef6f12a1 |
Charger les codes Silae du dossier
Le point d'arrêt de PLAN.md §0 est levé : les codes viennent du paramétrage Silae du compte, relevé le 11 août 2026, et non d'une documentation publique — il n'en existe pas. Silae impose que le fichier d'import soit le miroir du dossier, si bien qu'un code n'a de sens que pour le client qui le porte. Ils vivent donc dans le seed, jamais dans src/domain/payroll. Écrire HS-HS50 dans un calcul rendrait l'outil inutilisable au deuxième dossier et incorrigible sans livraison le jour où le cabinet renumérote. Trois éléments restés ouverts à la conception sont résolus : heures supplémentaires à 50 % (HS-HS50), complémentaires à 10 % (HS-HC10) et à 25 % (HS-HC25). Les cinq types d'absence semés reçoivent le leur. Semés confirmés, contrairement aux propositions déduites d'un libellé : ceux-ci sont relevés dans la configuration active, pas devinés. Deux manques sont signalés au lieu d'être comblés. Le forfait jours n'a aucune rubrique au dossier — l'export échouera au premier cadre autonome, ce qui vaut mieux qu'un code inventé. Et quatre rubriques du dossier portent un préfixe « AB- » sans numéro (évènement familial, repos compensateur de nuit, repos compensateur d'habillement, visite médicale) : reprendre un code tronqué ferait échouer l'import, ou pire, le ferait réussir en imputant à la mauvaise rubrique. Le catalogue complet du dossier est conservé, y compris les rubriques hors périmètre actuel. Le jour où les heures de nuit ou les paniers repas entrent au calcul, le code est déjà là et n'aura pas à être redemandé au cabinet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
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 |
||
|
|
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 |
||
|
|
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). |
||
|
|
0213375a8f |
WP-06 : absences, registre de compteurs, calendrier
Le dernier module de démonstration disparaît : `src/lib/demo/` ne contient plus que l'aperçu. Le décompte, là où l'on se trompe - **`endDate` est le dernier jour d'absence, pas la date de reprise.** C'est la confusion la plus fréquente du domaine : elle décompte un jour de trop ou de trop peu à chaque demande, et le salarié s'en aperçoit au solde, des mois plus tard. Le champ du formulaire s'appelle « Dernier jour d'absence », et un test couvre explicitement la confusion. - **Un jour férié dans un congé ne se décompte pas**, sans que l'utilisateur ait à scinder sa demande. Exiger la scission, c'est lui déplacer la charge d'un calcul que l'outil sait faire. - Le rythme du contrat est respecté : un temps partiel qui ne travaille jamais le mercredi ne consomme pas de congé ce jour-là. - Ouvrables ou ouvrés est un **paramètre**, pas une constante : se tromper fausse tous les soldes de la même façon. Jours fériés calculés, pas listés Une table écrite à la main est juste l'année où on l'écrit et fausse dès la suivante — et un férié manquant se décompte comme un jour de congé, sans que personne ne le voie. Les onze jours légaux sont donc calculés, Pâques comprise (algorithme grégorien anonyme, vérifié sur quatre années de référence). Quand aucun férié n'est enregistré pour l'année demandée, la demande passe mais l'écran le dit : mieux vaut l'annoncer que laisser croire à un décompte complet. Le registre est la source de vérité - Aucun solde n'est stocké : le solde est la **somme** des écritures. Un solde stocké se désynchronise, et la désynchronisation ne se voit qu'au moment où un salarié conteste. - `UPDATE` et `DELETE` sont refusés par un trigger PostgreSQL, pas seulement par l'application : une règle applicative finit par être contournée par un script de reprise. Les tests d'intégration écrivent directement en base pour le prouver. - Annuler une absence acceptée **contre-passe** la prise au lieu de l'effacer, à la date de la correction — antidater masquerait la correction dans les soldes déjà communiqués. `reversesId` est unique : contre-passer deux fois ferait repartir le solde dans l'autre sens. - Un ajustement manuel sans justification est refusé. Confidentialité du motif médical Un manager voit qu'un salarié est absent, pas de quoi il souffre (matrice n° 9). Le motif d'un arrêt n'est **pas chargé** pour qui n'a pas la capacité de le lire — un champ absent de la réponse ne peut fuiter ni par le HTML ni par un journal. Sur la grille de planning, l'absence s'affiche « Absence » : c'est suffisant pour ne pas planifier quelqu'un. Acquisition 2,5 jours ouvrables par mois travaillé ; 2 jours par mois d'arrêt maladie non professionnelle, plafonnés à 24 par an — droit issu de la réforme de 2024, dont l'oubli prive le salarié d'un droit acquis. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv |
||
|
|
af9429901d |
WP-08 : export Silae, format relevé sur un export réel du dossier
Le format n'est plus déduit d'une documentation. Il est vérifié octet par octet sur un export du dossier (juillet 2026, 55 lignes) : un contrôle d'aller-retour a reproduit ce fichier **sans aucune ligne divergente**. Ce que le fichier réel a corrigé dans la spécification - L'export est en **ASCII pur**, pas en UTF-8 : aucun accent, y compris dans les libellés (« Heures travaillees », « Date debut »). Le sérialiseur translittère, pour qu'un salarié nommé « Rémi » n'introduise pas le premier octet non-ASCII du fichier. - Fins de ligne **CRLF**, la dernière comprise ; ni guillemet ni point-virgule final ; décimale point. - Heures avec au moins une décimale et au plus deux — `96.0`, `52.5`, `69.67` ; jours en entier nu — `14`, `22`. L'arrondi se fait **par ligne**, au centième : recomposer un total depuis les lignes peut donc s'écarter de quelques centièmes. C'est le comportement de l'export existant, et le reproduire est délibéré. - Un salarié sous contrat sans aucun créneau planifié figure quand même, avec sa durée contractuelle entière en heures manquantes. Ce que le fichier n'a pas dit Les codes sont maintenant connus — `AB-100`, `AB-200`, `AB-300`, `AB-630`, `EV-HDimanche`, `EV-HFerie`, `HS-HS25` — mais **savoir qu'un code existe ne dit pas ce qu'il désigne**. `EV-HDimanche` se lit ; `AB-300` non. Les premiers sont proposés, les seconds restent vides, et rien n'est confirmé d'office : l'export refuse de tourner tant que le gestionnaire de paie n'a pas validé chaque correspondance. C'est le signal d'arrêt de PLAN.md §8.2, maintenu. Refus plutôt que fichier partiel Un CSV incomplet se charge sans erreur dans Silae et rend la paie fausse pour les salariés qui en sont absents — l'échec est silencieux jusqu'au bulletin. L'export liste donc les manques et ne produit rien. Données personnelles L'export de référence porte les heures et les absences de salariés identifiables. Il **n'est pas versionné**, ni comme fixture ni comme donnée de démonstration. Ce sont les règles de forme qui sont figées dans les tests, avec les valeurs observées mais sans les matricules ni les volumes réels. Le fichier produit revient dans la réponse de l'action plutôt que par une URL : un fichier de paie ne doit pas rester adressable, mis en cache ou présent dans un historique de navigation. En base, seule l'empreinte est conservée — elle suffit à prouver qu'un réexport est identique. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv |
||
|
|
44b5163713 |
WP-05 : moteur de règles de convention, effectif-daté
Le planning cesse d'être un tableur : il est confronté aux durées légales et conventionnelles à chaque écriture. Domaine — dix-huit règles pures - `src/domain/compliance/` : chaque règle est une fonction pure, testée à sa borne exacte. La valeur limite passe, un cran au-delà déclenche — c'est la seule forme de test qui protège d'une inégalité écrite à l'envers, et une inégalité à l'envers sur un repos quotidien est une infraction que personne ne verra. - 58 tests de règles, 14 sur les tranches d'heures : 43 h donnent 8 h à +25 %, 45 h donnent 8 h à +25 % et 2 h à +50 %. Aucune valeur dans le code - Les seuils vivent en base (`CollectiveAgreement.parameters`), validés par un schéma Zod qui refuse un jeu amputé : un seuil manquant lu comme `undefined` désactiverait silencieusement une règle de sécurité. - Un test charge deux jeux différents et vérifie que les résultats diffèrent. - `MAX_DAILY_AMPLITUDE` reste muette : l'IDCC 1517 ne fixe pas d'amplitude quotidienne. Inventer une borne ferait désactiver l'ensemble des alertes par le premier manager excédé. Effectif-datage — exigence n° 1 de la matrice - Les versions de convention coexistent ; un trigger PostgreSQL refuse de réécrire le contenu d'une version publiée, tout en laissant enregistrer une approbation postérieure. - Chaque constat mémorise la version appliquée. Un test d'intégration pose deux versions et vérifie qu'une semaine de mars n'est pas jugée sur la règle publiée en juillet. Dimanche — la double contrepartie Le taux de 100 % ne vient pas de la convention : l'IDCC 1517 n'en fixe aucun, et l'entreprise n'a pas d'accord. Il vient de l'article L3132-27, qui impose la rémunération doublée **et** un repos compensateur d'égale durée. Le moteur produit les deux ; un test échoue si l'un manque. Le quota des douze dimanches du maire est opposable, avec la liste arrêtée par établissement. Restitution - Les bloquants — chevauchement, créneau pendant une absence — annulent la transaction : mieux vaut refuser une saisie que garder un planning dont les heures se comptent deux fois. - Les avertissements se franchissent, avec un motif enregistré sur chaque constat et une entrée d'audit. Le constat acquitté reste affiché avec sa justification : le faire disparaître donnerait l'illusion qu'il a été résolu alors qu'il a été assumé. - Une modification revalide la semaine **et ses voisines** : le repos entre dimanche soir et lundi matin appartient à deux semaines. Règles de mineurs — non implémentées, volontairement La matrice ne couvre que les majeurs et le dossier n'a aucune source primaire sur les moins de 18 ans. Les codes sont réservés, les seuils absents. Les inventer donnerait une fausse assurance sur la population que le droit protège le plus. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv |
||
|
|
023f236881 |
WP-04: planning sur données réelles — grille semaine, vue jour, publication par équipe
Le planning quitte le module de démonstration : la grille lit `WeeklySchedule` et `Shift`, et les quatre derniers fichiers de `src/lib/demo/` qui portaient des salariés fictifs disparaissent. Modèle - `TeamMember` : rattachement d'un salarié à une équipe, distinct de `MembershipScope` (qui dit ce qu'un manager a le droit de voir). Sans lui, un salarié sans créneau n'apparaîtrait pas dans la grille — c'est-à-dire l'état de départ de toute semaine en construction. - RLS sur `WeeklySchedule`, `Shift`, `Rest`, `DailyNote` et `TeamMember`. Temps - `src/domain/planning/week.ts` : repérage par couple année ISO + semaine ISO, jamais par date de début. Le lundi 29 décembre 2025 appartient à la semaine 1 de 2026 ; une clé fondée sur la date ferait apparaître deux semaines 1. - `zonedInstant` / `zonedMidnight` corrigent le décalage mesuré **à l'instant visé**. Le 29 mars 2026, minuit est en UTC+1 et 09 h en UTC+2 : ajouter neuf heures à minuit donnerait 10 h locales. - La semaine du retour à l'heure d'hiver dure 169 h, celle du passage à l'heure d'été 167 — vérifié par test. Écritures - Création, modification, suppression de créneau ; publication et dépublication **par équipe**, avec verrou optimiste sur `version` : deux managers sur la même grille est le cas normal, pas l'exception. - Chevauchement refusé en transaction, pas seulement dans le formulaire. - Modifier une semaine publiée exige `planning.edit_published`, capacité que le rôle manager n'a pas : un salarié a organisé sa semaine sur ce qu'il a lu. - Toute mutation laisse une entrée d'audit ; la suppression écrit sa trace **avant** l'effacement, sinon l'état supprimé serait perdu. Lecture - `planning.view_unpublished` filtre en base : sans cette capacité, les brouillons ne sont pas chargés du tout. Un test vérifie que les horaires n'apparaissent pas dans le HTML servi — un masquage CSS les y laisserait. - Vue jour reconstruite sur les mêmes données, amplitude déduite de la journée réelle plutôt que figée à 06 h–21 h. Vérification - 26 tests unitaires sur le repérage des semaines et la mise en grille. - Parcours e2e : poser un créneau, refus de chevauchement, publier, dépublier ; et ce que voient un salarié et un manager sur la même semaine. - `scripts/dev-db.sh` : la base de développement est éphémère dans cet environnement, la remonter ne doit pas être une redécouverte. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv |
||
|
|
8a314991d3 |
WP-03: employee records and contracts on real data
Replaces the demo module behind the team directory and the employee record with scoped queries, and adds contracts, amendments, work permits and the forfait-jours fields. Writing the end-to-end test exposed a modelling error worth naming: first and last names lived only on User, so an employee without an application account had no name at all — the directory rendered "— Salarié E0007". Most sales staff never sign in, and the personnel register requires their name, so the name belongs to the record, not to the login. Moved to EmployeeProfile with a data migration that carries the existing names down from User. Contract rules are pure functions tested at the boundaries. The case that matters is an open-ended contract: a CDI with no end date overlaps every later period, which a naive comparison of two date pairs misses, and two overlapping active contracts would count one employee twice in payroll. The check runs inside the transaction, not only in the form. Forfait jours is refused without a written individual agreement and a dated employee consent: without them the arrangement is unenforceable, and enabling it would also switch off every weekly-duration control. Salary and bank details are not merely hidden when the capability is missing — they are never loaded. A field absent from the response cannot leak through HTML, a log or an error message. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv |
||
|
|
2c0e9e8dd4 |
Wire authentication into the application
Adds the sign-in screen, sign-out, and a server-side guard on every application route. The guard lives in the layout rather than the proxy because the proxy cannot query the database to check whether a session was revoked — and revocation is the reason sessions are stored there. Sign-in returns one message for an unknown account and for a wrong password, and verifies a dummy hash when the account does not exist, so neither the wording nor the timing enumerates staff addresses. An end-to-end test compares the two messages rather than trusting the code to keep them aligned. The shell now shows the signed-in person and their role from the database instead of hardcoded initials. Playwright signs in once in a setup project and shares the cookie; argon2 is deliberately slow, and logging in per test would also drive the shared failed-attempt counter toward a lockout. The seed resets that counter so repeated local runs cannot lock the demo account. Two test locators had to be scoped to the form: Next's route announcer carries role="alert" and an empty string, which silently satisfied the assertion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv |
||
|
|
11763142d5 |
WP-01: tenancy, identity and capability authorization
Adds the data model for accounts, locations, teams, users, memberships and scopes, plus roles, the 70-capability catalogue, database-backed sessions, and the audit log. Isolation is enforced twice, independently. A Prisma extension injects accountId into every query, and PostgreSQL row-level security filters underneath it, keyed on a transaction-local setting. The first alone leaves raw queries unguarded; the second alone returns empty results without saying why. Integration tests prove both against a real database rather than through the application layer, which would only prove the application layer. They create a restricted role to do it — and that exposed a trap worth naming: **a PostgreSQL superuser bypasses row-level security even with FORCE**. Connecting the app as one silently disables the second layer while every application test still passes. checkTenantIsolation now refuses to start in production on such a database, warns in development, and reports through /api/sante. The README explains the role to create. The audit log is append-only by trigger, so it resists even a superuser: a trail that can be rewritten proves nothing. Entries carrying an adjustment or an unlock are rejected without a justification, and known secret-bearing fields are redacted before writing — the log is read, exported and kept for years, so it must not become a second unencrypted copy of what is encrypted elsewhere. Sensitive columns use AES-256-GCM with the key held outside the database. Sign-in verifies a dummy hash for unknown accounts so timing does not enumerate addresses. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv |