444 Commits
Author SHA1 Message Date
IliesAllali 92e3c74673 Revert "Merge pull request #217 from IliesAllali/feat/onglets-nom-et-proches"
This reverts commit 82ae5e289d, reversing
changes made to 09b2acf4fe.
2026-11-07 01:54:20 +01:00
IliesAllali 4a447c7aef Revert "Merge pull request #219 from IliesAllali/feat/onglets-par-nom"
This reverts commit 4544edde6f, reversing
changes made to 82ae5e289d.
2026-11-07 01:54:19 +01:00
IliesAllali 79df032f10 Revert "Merge pull request #220 from IliesAllali/feat/reglages-arbre-onglets"
This reverts commit 97966054ac, reversing
changes made to 4544edde6f.
2026-11-07 01:54:19 +01:00
IliesAllali 97966054ac Merge pull request #220 from IliesAllali/feat/reglages-arbre-onglets
Onglets : « Tout l'arbre » en option et vraiment complet, ancien découpage par largeur retiré
2026-11-07 01:44:43 +01:00
IliesAllaliandClaude Opus 5.5 e787e4488e onglets : « Tout l'arbre » seulement si coché, sinon la famille de la personne de départ s'ouvre
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 01:40:50 +01:00
IliesAllaliandClaude Opus 5.5 1256f09c8f nettoyage : l'ancien découpage par largeur retiré du placement, des pastilles, de la barre et du CSS
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 01:18:18 +01:00
IliesAllaliandClaude Opus 5.5 5eb1d9e7bd Tout l'arbre : plus aucune belle-famille réduite derrière une pastille quand l'arbre est dessiné en entier
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 01:14:13 +01:00
IliesAllali 4544edde6f Merge pull request #219 from IliesAllali/feat/onglets-par-nom
Onglets de familles : un onglet par nom, seuil réglable au curseur
2026-11-07 00:48:47 +01:00
IliesAllaliandClaude Opus 5.5 b89ceb4e00 réglages : curseur du seuil, onglets créés affichés en direct
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 00:45:15 +01:00
IliesAllaliandClaude Opus 5.5 61d9bcc328 réglages : « À partir de N personnes au même nom »
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 00:11:32 +01:00
IliesAllaliandClaude Opus 5.5 8f770371fb réglages : seuil des onglets sur une ligne
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 00:10:37 +01:00
IliesAllaliandClaude Opus 5.5 8cbe5ce262 onglets par nom : repère pris à l'écran, pas de doublon de Tout l'arbre
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 00:08:43 +01:00
IliesAllaliandClaude Opus 5.5 9b77cca771 onglets : un onglet par nom de famille, seuil réglable, ancien découpage par largeur retiré
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 00:04:25 +01:00
IliesAllali 82ae5e289d Merge pull request #217 from IliesAllali/feat/onglets-nom-et-proches
Onglets de familles : les porteurs du nom dans leur famille, proches de l'autre côté réglables
2026-11-06 18:25:59 +01:00
IliesAllaliandClaude Opus 5.5 642b6020c3 onglets : les porteurs du nom dans leur famille, proches de l'autre côté réglables
Un enfant d'un membre qui porte le nom d'un onglet entre dans cet onglet (nom de naissance d'abord,
homonymes sans lien laissés dehors), avec son couple ; l'autre parent est estompé. Nouveau réglage
d'affichage : proches de l'autre côté à 0, 1, 2 ou 3 liens, 1 par défaut. Libellés des réglages
d'affichage réécrits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 18:12:21 +01:00
dependabot[bot] 09b2acf4fe build(deps): bump the frontend-minor-patch group (#216)
Bumps the frontend-minor-patch group in /frontend with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [@lucide/icons](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide) | `1.61.0` | `1.66.0` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.61.0` | `1.66.0` |
| [posthog-js](https://github.com/PostHog/posthog-js) | `1.438.21` | `1.444.0` |
| [eslint](https://github.com/eslint/eslint) | `10.13.0` | `10.14.0` |
| [globals](https://github.com/sindresorhus/globals) | `17.13.0` | `17.14.0` |


Updates `@lucide/icons` from 1.61.0 to 1.66.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.66.0/packages/lucide)

Updates `lucide-react` from 1.61.0 to 1.66.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.66.0/packages/lucide-react)

Updates `posthog-js` from 1.438.21 to 1.444.0
- [Release notes](https://github.com/PostHog/posthog-js/releases)
- [Changelog](https://github.com/PostHog/posthog-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/PostHog/posthog-js/compare/posthog-js@1.438.21...posthog-js@1.444.0)

Updates `eslint` from 10.13.0 to 10.14.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.13.0...v10.14.0)

Updates `globals` from 17.13.0 to 17.14.0
- [Release notes](https://github.com/sindresorhus/globals/releases)
- [Commits](https://github.com/sindresorhus/globals/compare/v17.13.0...v17.14.0)

---
updated-dependencies:
- dependency-name: "@lucide/icons"
  dependency-version: 1.66.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-minor-patch
- dependency-name: lucide-react
  dependency-version: 1.66.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-minor-patch
- dependency-name: posthog-js
  dependency-version: 1.444.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-minor-patch
- dependency-name: eslint
  dependency-version: 10.14.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-minor-patch
- dependency-name: globals
  dependency-version: 17.14.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-06 12:20:06 +01:00
dependabot[bot] 51924229da build(deps): bump the backend-minor-patch group (#215)
Bumps the backend-minor-patch group in /backend with 2 updates: [@fastify/cookie](https://github.com/fastify/fastify-cookie) and [@fastify/jwt](https://github.com/fastify/fastify-jwt).


Updates `@fastify/cookie` from 11.1.3 to 11.1.5
- [Release notes](https://github.com/fastify/fastify-cookie/releases)
- [Commits](https://github.com/fastify/fastify-cookie/compare/v11.1.3...v11.1.5)

Updates `@fastify/jwt` from 10.2.5 to 10.2.6
- [Release notes](https://github.com/fastify/fastify-jwt/releases)
- [Commits](https://github.com/fastify/fastify-jwt/compare/v10.2.5...v10.2.6)

---
updated-dependencies:
- dependency-name: "@fastify/cookie"
  dependency-version: 11.1.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: backend-minor-patch
- dependency-name: "@fastify/jwt"
  dependency-version: 10.2.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: backend-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-06 12:19:58 +01:00
dependabot[bot] 64979ef66a build(deps): bump posthog-js (#214)
Bumps the landing-minor-patch group in /landing with 1 update: [posthog-js](https://github.com/PostHog/posthog-js).


Updates `posthog-js` from 1.438.21 to 1.444.0
- [Release notes](https://github.com/PostHog/posthog-js/releases)
- [Changelog](https://github.com/PostHog/posthog-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/PostHog/posthog-js/compare/posthog-js@1.438.21...posthog-js@1.444.0)

---
updated-dependencies:
- dependency-name: posthog-js
  dependency-version: 1.444.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: landing-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-06 12:19:32 +01:00
IliesAllaliandClaude Opus 5.5 ef620c9cb5 mesure : copie du lien de partage, page vie privée à jour
share_copied quand le propriétaire copie le lien, le mot de passe ou le message d'invitation
(what, seasonal pour le message de Noël), ou crée le lien d'un co-gestionnaire (manager_invite).
Rien de ce qui est copié ne part. La page vie privée (fr, en) décrit maintenant tout ce que l'app
mesure : démo, modifications comptées sans leur contenu, propositions, partage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 19:32:10 +01:00
IliesAllaliandClaude Opus 5.5 332b5711ff mesure : proposal_sent pour toutes les propositions
L'événement ne partait que de la fenêtre d'envoi (App.jsx) : les raccourcis de la fiche passent par
handleSubmitContributionSession sans lui, d'où zéro proposal_sent en 60 jours pour deux propositions
relues. L'événement part maintenant de la fonction commune, après réponse du serveur, avec le statut
(pending, ou approved pour un co-gestionnaire).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 19:24:19 +01:00
IliesAllaliandClaude Opus 5.5 9bf55a16f3 landing : mesure d'audience des pages statiques, provenance gardée jusqu'à l'app
Les guides, l'aide, la vidéo et les pages vie privée (public/, copiés sans Vite) n'avaient aucune
mesure. src/pages.js, construit par Vite avec une empreinte et posé au build dans chaque page par
scripts/inject-pages.mjs, envoie les mêmes événements que la page d'accueil (page_viewed, scroll_depth_50
et 100, signup_cta_clicked) et video_played sur /video/. Les liens vers l'app gardent le ?source=
d'arrivée au lieu de la provenance fixe de la page. Page vie privée (fr, en) complétée.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 19:01:07 +01:00
IliesAllaliandClaude Opus 5.5 2c7e5361cc texte : l'invitation de Noël parle aussi de la voix
Le message d'invitation de décembre (SIGNUP_PROMPTS) propose d'enregistrer
une histoire de famille racontée à table, en plus des vieilles photos.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 18:09:16 +01:00
IliesAllaliandClaude Opus 5.5 98de13aaa0 deps(front): minimatch 11 pour eslint (alerte Dependabot #24)
minimatch < 11.0.1 (déni de service par amplification des globparts). Seul eslint
en dépend, outillage de dev ; Dependabot ne peut pas monter la version lui-même
(eslint demande ^10), d'où l'override. Lint, 359 tests, build, npm audit à 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 17:29:36 +01:00
IliesAllaliandClaude Opus 5.5 1558b0dc6b 1.6.0 : changelog
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 17:23:49 +01:00
IliesAllali ce82aa5f3b Merge pull request #213 from IliesAllali/fix/photos-couple
Onglet Vie : les photos d'un mariage chez les deux conjoints
2026-11-04 17:23:21 +01:00
IliesAllaliandClaude Opus 5.5 43dda9c962 fix(vie): les photos d'un mariage se voient chez les deux conjoints (#209)
Le moment du couple arrivait bien chez les deux, mais ses souvenirs n'étaient cherchés
que parmi ceux de la fiche ouverte. L'onglet Vie et la fenêtre d'un moment du couple
lisent aussi les souvenirs du conjoint ; une photo du conjoint s'ouvre à son nom.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 16:41:16 +01:00
IliesAllaliandClaude Opus 5.5 69b08116cf i18n(en): Partner à la place de Spouse sur la fiche et l'arbre
Même mot que les liens de parenté depuis #204 : « spouse » suppose un mariage,
« conjoint » non.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 16:39:13 +01:00
IliesAllali 419fa4cda0 Merge pull request #204 from Arc676/cousins
Fix some English relations
2026-11-04 16:38:05 +01:00
IliesAllaliandClaude Opus 5.5 68ab7924f2 ci(images): la vraie version dans le label OCI (#207)
org.opencontainers.image.version vaut X.Y.Z sur un tag et la sortie de git describe
sur main (1.5.9-11-gd5f9da6), au lieu de « latest ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 16:33:25 +01:00
IliesAllali d5f9da6777 Merge pull request #212 from IliesAllali/feat/demo-creer-mon-arbre
feat(demo): « Créer mon arbre » en bouton noir de la barre
2026-11-03 16:54:03 +01:00
IliesAllaliandClaude Opus 5.5 0ef5d08fec feat(demo): « Créer mon arbre » en bouton noir de la barre, « Modifier » en clair
Dans la démo, l'inscription n'était que dans le menu du rond de profil et dans un
lien après une modification. Elle devient le bouton noir de la barre du bas ;
« Modifier » reste à côté en clair (crayon seul sur téléphone) et quitte le noir.
« Créer mon arbre » quitte le menu du rond pour ne pas apparaître deux fois.

Seulement sur l'instance qui invite à s'inscrire (SIGNUP_PROMPTS, inscription
ouverte), sans compte, et pas dans la démo intégrée à la landing (?embed).
Clic suivi : create_account_clicked, via=demo-barre.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:48:17 +01:00
IliesAllali 7b7a589062 Merge pull request #211 from IliesAllali/fix/lien-source-demo
fix(entree): un lien de diffusion (?source=) sans session ouvre la démo
2026-11-03 16:34:02 +01:00
IliesAllaliandClaude Opus 5.5 0cf851475f fix(entree): un lien de diffusion (?source=) sans session ouvre la démo
Depuis l'accueil à deux choix (18/10), un visiteur sans session arrivé par
app.parenthese.io/?source=… tombait sur « Que voulez-vous faire ? » au lieu
de l'arbre d'exemple. Tous les liens de diffusion (presse, créateurs, groupes)
sont écrits sans ?demo, dont plusieurs déjà envoyés.

Sans session et avec ?source : la démo, comme avant. Sans paramètre : l'accueil
à deux choix reste. ?account=register et les liens de famille ne changent pas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:26:02 +01:00
IliesAllaliandClaude Opus 5.5 db09620a58 Page vidéo : Parenthèse en 30 secondes (#210)
/video/ sur parenthese.io : la vidéo validée par Patch le 03/11, en 16:9 sur
ordinateur et 9:16 sur téléphone (la seconde ne se charge qu'au besoin), aperçu,
balises og:video et JSON-LD VideoObject, bouton vers l'arbre d'exemple
(source=video). Lien à donner dans les mails de presse et aux créateurs.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 11:33:09 +01:00
IliesAllaliandClaude Opus 5.5 374fa35fbd Guide anglais « A family tree as a Christmas gift » (#208)
/en/family-tree-gift/, pendant du guide de Noël français (hreflang dans les deux sens,
lien English et Français dans le pied de page), lien dans le pied de page des huit pages
anglaises, plan du site. « family tree gift » : 590 recherches par mois aux États-Unis,
1 900 en décembre (DataForSEO, 02/11).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 15:17:19 +01:00
IliesAllaliandClaude Opus 5.5 bb6ad7d5d2 Guide « Arbre généalogique en cadeau de Noël » (#206)
Nouvelle page /arbre-genealogique-cadeau-noel/ sur le gabarit des guides (HowTo et FAQPage
en JSON-LD, français seul), lien dans le pied de page des huit pages françaises, plan du site.
Bouton avec source=guide-noel. En ligne avant mi-novembre pour être classée en décembre
(« arbre généalogique cadeau » x3,4 en décembre).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 01:38:42 +01:00
IliesAllaliandClaude Opus 5.5 7fee10478d kinship en : 4th cousin, great-uncle, sans virgule avant « removed »
- ordinal() : il manquait le $, un cousin au 4e degré s'affichait « {n}th cousin »
- Great-uncle et great-great-uncle, la forme courante, au lieu de granduncle
- « First cousin once removed », sans virgule
- Style du dépôt (guillemets simples, sans points-virgules)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 20:41:25 +01:00
IliesAllaliandClaude Opus 5.5 f990e1c5de Miniatures de portrait : le recadrage s'applique partout (#205)
Seuls l'arbre et la fiche appliquaient le cadrage enregistré du portrait. Les miniatures
(auteur et « Sur la photo » dans la visionneuse, recherche, liens de la fiche, ajout d'un
proche, compte, menu du compte, choix d'un souvenir, recherche ailleurs) affichaient la
photo brute. Un composant commun, PortraitThumb, place l'image avec imagePlacement,
comme la fiche. Signalé par Phyll sur Discord (29/10).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 19:51:53 +01:00
IliesAllaliandClaude Opus 5.5 1565552141 Plusieurs prénoms dans le champ Prénom : « Paul, Marie, Jean » crée trois enfants (#203)
Pour un enfant ou un frère, une virgule sépare les prénoms : une personne par prénom, mêmes liens,
nom et dates pour chacune (vides et doublons ignorés, 20 au plus). Parent et conjoint gardent un seul
prénom. Vaut pour le propriétaire, la famille (brouillons) et la démo ; la bulle dit « Paul, Marie et
Jean … sont sur l'arbre ». Sur téléphone, aucune fiche ne s'ouvre et la caméra cadre toute la fratrie
(focusPersons dans Galaxy, qui attend la remise en place de l'arbre). person_added porte batch.

Le corps de l'ajout passe dans addOne : relire avec git diff -w.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 19:20:59 +01:00
Arc676/Alessandro Vinciguerra 568db606da kinship: implement g-rule
Allow arbitrary depth

Signed-off-by: Arc676/Alessandro Vinciguerra <alesvinciguerra@gmail.com>
2026-11-01 12:06:21 +01:00
Arc676/Alessandro Vinciguerra 24e7ea1994 kinship: use nibling
Signed-off-by: Arc676/Alessandro Vinciguerra <alesvinciguerra@gmail.com>
2026-11-01 11:27:46 +01:00
Arc676/Alessandro Vinciguerra e0f7eb4913 kinship: compute cousin type by degree and removal
Signed-off-by: Arc676/Alessandro Vinciguerra <alesvinciguerra@gmail.com>
2026-11-01 10:40:54 +01:00
Arc676/Alessandro Vinciguerra e2468f6d39 kinship: use partner in english
Signed-off-by: Arc676/Alessandro Vinciguerra <alesvinciguerra@gmail.com>
2026-11-01 10:40:10 +01:00
IliesAllaliandClaude Opus 5.5 e596887a44 Invitations à créer son arbre, coupées en auto-hébergement (SIGNUP_PROMPTS) (#202)
- La famille sans compte trouve « Faire le mien » en bas du menu Ajouter ; la ligne quitte le menu du
  compte. Après l'inscription, l'arbre de la famille est rattaché et l'assistant de création s'ouvre.
- Démo : les messages des liens et la bulle après « Enregistrer et quitter » proposent l'inscription
  (lien cliquable, message gardé 6 s). La démo lit aussi les options de l'instance.
- Message d'invitation de Noël du 1er décembre au 6 janvier.
- SIGNUP_PROMPTS (API, /auth/options.signupPrompts) : éteint par défaut, allumé pour l'instance hébergée
  dans ecosystem.config.cjs, toujours coupé quand l'inscription est fermée. README, .env.example, OpenAPI.
- Mesures : via sur create_account_clicked et account_screen_viewed, source arbre-partage ou demo
  quand l'adresse n'en porte pas.
- « Quitter l'édition ? » dans la démo ne dit plus que les modifications restent sur l'appareil :
  la démo n'enregistre rien et tout disparaît au rechargement.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 18:17:59 +01:00
IliesAllaliandClaude Opus 5.5 473414744d Aperçu des liens partagés : balises Open Graph fixes pour l'app (#201)
Un lien d'arbre ou de démo collé dans WhatsApp ou Messenger s'affichait sans
image, avec pour seul texte la description destinée aux moteurs. L'app sert
désormais les mêmes balises pour toutes ses adresses : un titre, une phrase et
l'image de la démo (copie de celle de la landing). Rien ne vient de l'arbre
partagé, aucun nom de famille ne part chez les robots d'aperçu.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 18:06:41 +01:00
IliesAllaliandClaude Opus 5.5 0269b97efd 1.5.9 : changelog
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 15:12:39 +01:00
IliesAllaliandClaude Opus 5.5 cd6dfc6955 Enfant ajouté à une personne qui n'a qu'un conjoint : enfant du couple (#200)
Avant, « + Enfant » sur la fiche d'Isabelle (un seul conjoint, Robert) rattachait l'enfant à Isabelle seule :
il pendait sous elle et un parent « Inconnu », loin de ses frères et sœurs. Il passe maintenant par les liens
du couple (getCoupleParentLinks), comme un frère ou une sœur, pour la démo, la famille qui propose et le
propriétaire. Sans conjoint, avec plusieurs conjoints, ou en adoption : l'enfant reste au seul parent choisi.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:44:23 +01:00
IliesAllaliandClaude Opus 5.5 f595af581a Téléphone : plus de réglette de zoom, la loupe seule reste (on zoome à deux doigts) (#199)
La notification n'a plus à se décaler pour l'éviter.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:35:38 +01:00
IliesAllaliandClaude Opus 5.5 b803f6291d Fiche : « – 1995 » ne se coupe plus après le tiret ; bouton loupe nommé « Zoomer »
Une espace insécable seule ne suffit pas : Chrome coupe après un tiret demi-cadratin. Le gluon U+2060
devant l'espace insécable garde « – 1995 » d'un bloc (vu sur la fiche de Robert, démo, 1440 px).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 13:43:45 +01:00
IliesAllaliandClaude Opus 5.5 1b13b093ba 1.5.8 : changelog
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 13:08:57 +01:00
IliesAllaliandClaude Opus 5.5 c1734501ae Onglets de familles : noms, repère et liens entre onglets (#192)
- Nom d'un onglet : le nom le plus porté par ses membres (naissance, sinon usage), et non plus celui
  de la seule personne de référence. Deux onglets
  au même nom : le plus petit prend le nom suivant le plus porté chez lui, les prénoms de la charnière
  en dernier recours.
- Changer d'onglet garde la personne charnière à sa place et à son zoom : seules les familles autour changent.
- Survol d'un onglet : la charnière s'allume dans l'arbre affiché (halo de la recherche), sans texte.
- Barre rangée comme l'arbre (chaque famille après celle par laquelle on y entre), groupée par branche
  (10 px dans une branche, 16 px entre branches), trait d'union entre deux onglets dont le second part
  du premier, terracotta sur le chemin de l'onglet ouvert.
- Pastille de retour : même place que celle de l'aller, flèche retournée (« ‹ Famille X · n »).
- Nombre d'un onglet : « n personnes » au survol.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 13:08:25 +01:00
b444c1ad39 build(deps): bump @node-rs/bcrypt from 1.10.7 to 2.0.0 in /backend (#197)
* build(deps): bump @node-rs/bcrypt from 1.10.7 to 2.0.0 in /backend

Bumps [@node-rs/bcrypt](https://github.com/napi-rs/node-rs) from 1.10.7 to 2.0.0.
- [Release notes](https://github.com/napi-rs/node-rs/releases)
- [Commits](https://github.com/napi-rs/node-rs/compare/@node-rs/bcrypt@1.10.7...@node-rs/bcrypt@2.0.0)

---
updated-dependencies:
- dependency-name: "@node-rs/bcrypt"
  dependency-version: 2.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

* bcrypt 2 : le coût passe en options (hash(value, { cost }))

Les empreintes déjà enregistrées restent valables : une empreinte faite en 1.10.7 se vérifie en 2.0.
Les nouvelles sortent en $2b$ au lieu de $2y$, même coût.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: IliesAllali <pro.allali.ilies@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 12:28:22 +01:00
dependabot[bot] 04dc7f0e88 build(deps): bump the landing-minor-patch group (#196)
Bumps the landing-minor-patch group in /landing with 2 updates: [posthog-js](https://github.com/PostHog/posthog-js) and [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `posthog-js` from 1.438.12 to 1.438.21
- [Release notes](https://github.com/PostHog/posthog-js/releases)
- [Changelog](https://github.com/PostHog/posthog-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/PostHog/posthog-js/compare/posthog-js@1.438.12...posthog-js@1.438.21)

Updates `vite` from 8.3.4 to 8.3.5
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.3.5/packages/vite)

---
updated-dependencies:
- dependency-name: posthog-js
  dependency-version: 1.438.21
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: landing-minor-patch
- dependency-name: vite
  dependency-version: 8.3.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: landing-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-29 12:24:13 +01:00
dependabot[bot] 1c78c043bd build(deps): bump the frontend-minor-patch group (#195)
Bumps the frontend-minor-patch group in /frontend with 4 updates: [@lucide/icons](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide), [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react), [posthog-js](https://github.com/PostHog/posthog-js) and [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `@lucide/icons` from 1.59.0 to 1.61.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.61.0/packages/lucide)

Updates `lucide-react` from 1.59.0 to 1.61.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.61.0/packages/lucide-react)

Updates `posthog-js` from 1.438.12 to 1.438.21
- [Release notes](https://github.com/PostHog/posthog-js/releases)
- [Changelog](https://github.com/PostHog/posthog-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/PostHog/posthog-js/compare/posthog-js@1.438.12...posthog-js@1.438.21)

Updates `vite` from 8.3.4 to 8.3.5
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.3.5/packages/vite)

---
updated-dependencies:
- dependency-name: "@lucide/icons"
  dependency-version: 1.61.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-minor-patch
- dependency-name: lucide-react
  dependency-version: 1.61.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-minor-patch
- dependency-name: posthog-js
  dependency-version: 1.438.21
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-minor-patch
- dependency-name: vite
  dependency-version: 8.3.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-29 12:24:08 +01:00
dependabot[bot] cd93a470b8 build(deps): bump dotenv in /backend in the backend-minor-patch group (#194)
Bumps the backend-minor-patch group in /backend with 1 update: [dotenv](https://github.com/motdotla/dotenv).


Updates `dotenv` from 18.0.8 to 18.0.9
- [Changelog](https://github.com/motdotla/dotenv/blob/master/CHANGELOG.md)
- [Commits](https://github.com/motdotla/dotenv/compare/v18.0.8...v18.0.9)

---
updated-dependencies:
- dependency-name: dotenv
  dependency-version: 18.0.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: backend-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-29 12:24:04 +01:00
dependabot[bot] b39d1f68e8 build(deps): bump docker/login-action (#193)
Bumps the actions-minor-patch group with 1 update: [docker/login-action](https://github.com/docker/login-action).


Updates `docker/login-action` from 4.6.0 to 4.7.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/dbcb813823bdd20940b903addbd779551569679f...81c6f93d4bc991e510b342a00b419b3bb4efc5c6)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-29 12:23:54 +01:00
IliesAllaliandClaude Opus 5.5 d013c8b8fc Âge : l'anniversaire compte une fois passé (#198)
L'âge d'une personne vivante était l'année en cours moins l'année de naissance : né en décembre,
on prenait un an dès janvier. Même défaut pour l'âge au décès et les âges de l'onglet Vie.
Quand les deux dates sont complètes, l'âge se compte au jour près (utils/age.js) ; une année seule
garde la différence des années.

Fixes #198

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 12:23:45 +01:00
IliesAllaliandClaude Opus 5.5 3dfc8625e9 README : OIDC, seule la mise en place réelle avec Pocket ID est citée (#153)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 10:40:39 +01:00
IliesAllaliandClaude Opus 5.5 1a65f28b21 README : OIDC n'est plus expérimental, le conteneur api doit joindre le fournisseur (#153)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 10:22:23 +01:00
IliesAllaliandClaude Opus 5.5 cc59b7190f 1.5.7 : changelog
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 08:29:07 +01:00
IliesAllali ba6f63ff09 Merge pull request #190 from IliesAllali/feat/vie-couples
Life tab: neutral couple label, couple date and place, names link to people
2026-10-28 08:28:51 +01:00
IliesAllali 4e9c0171dd Merge pull request #189 from IliesAllali/fix/camera-telephone
Phone: tree first on arrival, a tapped person moves above the profile (#188)
2026-10-28 08:28:46 +01:00
IliesAllaliandClaude Opus 5.5 1fae061a2d Onglet Vie : « En couple avec » sauf mariage dit, date et lieu d'un couple, prénoms en liens
Un couple naît « mariage » dès qu'un enfant a deux parents : la frise disait « Épouse X » pour tous.
Elle dit maintenant « En couple avec X », et « Mariage avec X » seulement quand c'est dit (date, lieu ou
documents de mariage, case cochée). Toucher la ligne d'un couple (administrateur) ouvre sa fiche avec
« C'était un mariage », la date (même approximative) et le lieu ; PATCH /unions accepte startPlace et
startDatePrecision. Le prénom d'un proche (conjoint, enfant, frère ou sœur, parent) ouvre sa fiche et
amène la caméra sur lui.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 08:26:24 +01:00
IliesAllaliandClaude Opus 5.5 e39c33f977 Téléphone : l'arbre d'abord à l'arrivée, la personne touchée remonte au-dessus de la fiche (#188)
Sur téléphone, la fiche de la personne de départ ne s'ouvre plus à l'arrivée : elle couvrait le bas de
l'écran et la personne avec, laissant un écran vide. La personne de départ arrive à 40 % de la hauteur
au lieu de 56 %. Toucher quelqu'un dans l'arbre le remonte dans le tiers du haut, au-dessus de la fiche,
comme le fait déjà la recherche (focusPerson). Ordinateur inchangé.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 08:15:51 +01:00
IliesAllaliandClaude Opus 5.5 cdb75655dd 1.5.6 : changelog
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 07:41:26 +01:00
IliesAllali c47f0ee459 Merge pull request #187 from IliesAllali/fix/ouverture-lisible
Ouverture : arrivée lisible à 0,95, centrée à côté de la réglette sur téléphone
2026-10-28 00:11:54 +01:00
IliesAllaliandClaude Opus 5.5 8c2c740efe Ouverture : arrivée lisible, à 0,95, et centrée à côté de la réglette sur téléphone
#181 faisait arriver le recul à 0,5 : les prénoms y restent dessinés mais font
6,5 px, et un arbre de 35 personnes s'ouvrait en entier, illisible. Retour à
0,95 (prénoms à 12 px), le recul s'arrête là.

Sous 768 px, la réglette de zoom reste ouverte en bas à gauche : la personne de
départ se centre dans la largeur qu'elle laisse libre au lieu du milieu de
l'écran.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 00:05:38 +01:00
IliesAllali 0762602cdb Merge pull request #184 from IliesAllali/feat/ctrl-k
Ctrl+K ouvre la recherche, raccourcis affichés
2026-10-28 00:05:25 +01:00
IliesAllali 51ac0de755 Merge pull request #186 from IliesAllali/feat/noms-complets
Tree: optional last name under the first name, maiden name first (#168)
2026-10-28 00:00:54 +01:00
IliesAllali 983c50159d Merge pull request #185 from IliesAllali/fix/bandeau-annuler
Bandeau « Annuler » accordé, sans chevaucher la réglette de zoom ; bouton de zoom caché sous la fiche
2026-10-28 00:00:35 +01:00
IliesAllaliandClaude Opus 5.5 f8a0826dfb Notification sur téléphone : décalée seulement quand la réglette de zoom se voit (centrée sous un panneau ou une fiche)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 19:35:49 +01:00
IliesAllaliandClaude Opus 5.5 4375fb6ba3 Bandeau « Annuler » : accordé au sexe connu, sans chevaucher la réglette de zoom ; bouton de zoom caché sous la fiche
Relevés par la recette de la 1.5.5 :
- « Odette est supprimé·e » : accordé quand le sexe est connu (supprimée, supprimé), « ·e » seulement quand
  il ne l'est pas ; même chose pour le message d'échec. L'anglais ne change pas (« was deleted »).
- Sous 768 px, le bandeau (et la notification après un ajout, même classe) passait sur la réglette de zoom,
  toujours ouverte en bas à gauche : il commence maintenant à 72 px du bord.
- Sur ordinateur, le bord du bouton de zoom dépassait sous une fiche ouverte : il est caché tant que la
  fiche est là.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 19:21:16 +01:00
IliesAllaliandClaude Opus 5.5 379102d3fe Arbre : réglage « Le nom de famille sous le prénom », celui de naissance s'il est connu (#168)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:49:03 +01:00
IliesAllaliandClaude Opus 5.5 debe51c313 Ctrl+K (⌘K sur Mac) ouvre la recherche ; raccourcis affichés
- Ctrl+K, de n'importe où, ouvre la recherche d'une personne, ou y remet le curseur si elle est ouverte.
- La loupe dit son raccourci au survol : « Rechercher une personne (Ctrl K) », « (⌘K) » sur Mac.
- Dans le menu « Modifier », « Ctrl E » à droite de « Modifier l'arbre » (raccourci qui existait déjà
  sans être dit). Composant Kbd, caché sur écran tactile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:43:26 +01:00
IliesAllaliandClaude Opus 5.5 dbc811d722 1.5.5 : changelog
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:32:07 +01:00
IliesAllali 7b08996b09 Merge pull request #183 from IliesAllali/feat/landing-prechargement
Landing : pages préchargées au survol d'un lien
2026-10-27 18:28:08 +01:00
IliesAllaliandClaude Opus 5.5 fcfb7d1494 Landing : pages préchargées au survol d'un lien (speculationrules)
- Règles de préchargement dans l'accueil et les 15 guides : une page du site est préparée quand on survole
  son lien (appui sur téléphone), et s'ouvre presque tout de suite. Pages seulement, pas les fichiers ;
  l'application n'est pas concernée. Ignoré par les navigateurs qui ne connaissent pas ces règles.
- Une page préchargée ne compte pas de visite avant d'être vraiment ouverte (PostHog attend
  prerenderingchange), et l'animation du logo se joue à l'ouverture, pas pendant le préchargement.
- CSP de la landing (deploy/nginx.conf) : empreinte des règles, et nouvelle empreinte du script du logo.
  Le mot-clé 'inline-speculation-rules' ne suffisait pas à Chrome.
- Test : chaque script en ligne des pages a son empreinte dans la CSP, chaque page a ses règles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:05:12 +01:00
IliesAllali c45bcb0463 Merge pull request #182 from IliesAllali/feat/zones-tap
Zones de tap d'au moins 44 px sur écran tactile
2026-10-27 17:54:37 +01:00
IliesAllaliandClaude Opus 5.5 2b81a2a2d8 Zones de tap d'au moins 44 px sur écran tactile, sans changement visible
Croix de la recherche (36 px) et de la fiche (40 px), onglets de la fiche (34 px de haut), petits boutons,
« + » en pastilles, ajout et retrait d'un lien : une zone invisible déborde autour de chacun sur écran
tactile (pointer: coarse) jusqu'à 44 × 44 px. Rien ne bouge à l'œil, et rien ne change à la souris.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:44:50 +01:00
IliesAllali e2199b0445 Merge pull request #181 from IliesAllali/feat/ouverture-recul
Ouverture d'un arbre : la caméra recule de la personne de départ vers sa famille
2026-10-27 17:36:54 +01:00
IliesAllali 3327a344ff Merge pull request #180 from IliesAllali/fix/barre-familles
Barre des familles : elle ne s'écarte que si la fiche monte jusqu'à elle
2026-10-27 17:36:50 +01:00
IliesAllaliandClaude Opus 5.5 8ef87c7163 Ouverture d'un arbre : la caméra recule de la personne de départ vers sa famille
À la première ouverture d'un arbre, la caméra part serrée sur la personne de
départ et recule en 1,1 s (ease-out quint) jusqu'à la vue d'arrivée. Chaque
personne apparaît quand elle entre dans le champ, la personne de départ dès la
première image. Le premier geste (glisser, molette, recherche) arrête le recul.

- Un grand arbre s'ouvre à 0,5 au lieu de 0,95 : on voit la famille autour,
  les prénoms restent lisibles.
- Ensuite (arbre modifié, autre onglet de famille), rien ne s'anime : l'arbre
  s'affiche net. Avant, la cascade se rejouait à chaque modification.
- Un portrait qui entre part de 92 % de sa taille en fondu, en 360 ms (30 % en
  700 ms avant).
- Mouvements réduits : pas de recul, un fondu de 200 ms.

Avant, la cascade partait des générations les plus anciennes : sur un grand
arbre ouvert sur la personne de départ, l'écran restait vide près de 3 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:18:29 +01:00
IliesAllaliandClaude Opus 5.5 154f8219c5 Barre des familles : elle ne s'écarte que si la fiche monte jusqu'à elle
La fiche, posée en bas à gauche, ne couvre le haut que si son contenu est long.
Une fiche courte poussait la barre à droite pour rien.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:16:11 +01:00
IliesAllali 0f98b8b507 Merge pull request #178 from IliesAllali/feat/squelettes
Blocs gris pendant le chargement (onglet Vie, panneaux de l'arbre)
2026-10-27 17:15:18 +01:00
IliesAllali a11b71a1f4 Merge pull request #179 from IliesAllali/perf/gros-arbres
Gros arbres : ouverture plus rapide, onglets calculés à part
2026-10-27 17:13:39 +01:00
IliesAllaliandClaude Opus 5.5 1925624e2a Onglet Vie : blocs gris seulement quand aucune date n'est connue ; les dates connues restent affichées pendant le chargement
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:02:05 +01:00
IliesAllaliandClaude Opus 5.5 5b9e4e2685 Blocs gris pendant le chargement, à la place de « Chargement… » et d'un faux vide
- Onglet Vie : pendant le premier chargement, la place de la frise au lieu de « Pas encore de date
  connue », qui s'affichait puis disparaissait une seconde plus tard.
- Propositions à relire, co-gestionnaires, visites, paramètres de l'arbre, sources de recherche : des
  lignes grises à la forme de ce qui arrive, au lieu d'un « Chargement… ».
- Composant Skeleton : rien avant 300 ms (une réponse rapide ne fait pas clignoter), aucun mouvement,
  libellé pour les lecteurs d'écran.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:42:15 +01:00
IliesAllali a8f37b33d2 Merge pull request #176 from IliesAllali/feat/glisser-souvenir
Souvenir sur téléphone : glisser la photo vers le bas le ferme
2026-10-27 16:27:00 +01:00
IliesAllali 048ce96f9d Merge pull request #177 from IliesAllali/feat/blurhash
Aperçus flous pendant le chargement des photos (BlurHash)
2026-10-27 16:20:28 +01:00
IliesAllaliandClaude Opus 5.5 291dde8073 Aperçus flous (BlurHash) pendant le chargement des photos et des portraits
Les photos partent en taille réelle, sans miniature : sur un réseau lent, une vignette restait un carré
vide et un portrait montrait ses initiales. Chaque photo et chaque portrait a maintenant un aperçu flou
aux bonnes couleurs (une chaîne de 28 caractères), affiché jusqu'à l'arrivée de l'image, sans fondu.

- Base : media_items.blurhash et persons.avatarBlurhash (migration additive 20261027_001).
- API : calculé à l'envoi d'une photo et d'un portrait (sharp, 32 px), publié dans le graphe ; les autres
  chemins (Immich, import GEDCOM, propositions acceptées) et les photos d'avant sont rattrapés en tâche de
  fond (lots de 25, puis une passe par minute). Un fichier illisible reçoit un aperçu vide.
- Navigateur : vignettes de la fiche, de la frise et du gestionnaire de souvenirs, portrait de la fiche,
  portraits et souvenirs dessinés sur l'arbre.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:17:58 +01:00
IliesAllaliandClaude Opus 5.5 ec4b0a1428 outils(layout-bench) : temps et empreinte du placement, temps d'ouverture d'un arbre
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:10:26 +01:00
IliesAllaliandClaude Opus 5.5 d7eac40cff perf(onglets): liste des familles calculée dans un worker
Quand l'arbre affiché est celui de la famille de départ, il ne dépend pas de
la liste des onglets : il s'affiche tout de suite, la liste est calculée dans
galaxy/familyTabsWorker.js et la barre suit, sans geler la page. Les pastilles
prennent le nom et le compte de leur onglet à l'arrivée de la liste.

Calcul sur place comme avant dans un autre onglet, à l'arrivée par le lien
d'un onglet, sans Worker ou si le worker échoue (couples de l'arbre affiché
remis). Une demande en cours est abandonnée dès qu'une autre part.

Royal92 (3 010 personnes) : arbre utilisable en 1,6 s au lieu de 8,7 s,
barre des onglets à 6 s sans bloquer (médianes de 3, machine chargée).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:08:04 +01:00
IliesAllali 53737b0718 Merge pull request #175 from IliesAllali/fix/barre-edition-telephone
Téléphone, mode édition : onglets en haut, « Terminé » en toutes lettres, une seule sortie
2026-10-27 16:01:39 +01:00
IliesAllaliandClaude Opus 5.5 c78a102d08 « Terminé » à la hauteur du « + » (46 px), retour de Patch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:59:06 +01:00
IliesAllaliandClaude Opus 5.5 14f62aee90 perf(placement): ouverture des gros arbres deux fois plus rapide
- elkLayout : le rang des nœuds est tenu à jour à chaque ajout au lieu d'être
  reconstruit pour chaque chaîne (quadratique sur un gros arbre)
- blockLayout : fusion des contours en un tri par rangée au lieu d'insérer
  les intervalles un à un
- la famille de départ n'est plus placée deux fois (liste des onglets puis
  affichage) : takeRootLayout reprend le placement et ses couples
- chemin de parenté au survol : adjacence et index des personnes en cache,
  file lue par index

Placement identique (empreinte des positions, arêtes et onglets) sur royal92,
pres2020, asc-23, asc-61, desc-3, desc-5. Royal92 (3 010 personnes) :
9,3 s -> 4,6 s avant que l'arbre réponde (médiane de 3, machine chargée).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:58:28 +01:00
IliesAllali d5d446bc32 Merge pull request #174 from IliesAllali/feat/idempotence-envois
Envois de la famille sans doublon (Idempotency-Key)
2026-10-27 15:43:38 +01:00
IliesAllaliandClaude Opus 5.5 c712ca8a15 Souvenir sur téléphone : glisser la photo vers le bas le ferme, comme la fiche
- Même geste et mêmes seuils que la fiche (useSheetDrag) : la carte suit le doigt, se ferme passé un tiers ou sur un
  lâcher rapide, revient sinon ; tirée vers le haut, elle résiste. Zone : la photo seulement (.mv-photo-box), la
  légende garde ses boutons et son défilement. Coupé pendant l'identification et quand la carte a défilé
  (nouvelle option canStart du hook).
- L'entrée de la carte ne garde plus sa valeur finale sur téléphone (fill-mode backwards), sinon elle l'emportait sur
  le transform du glisser. Fond plein sous la carte collée en bas (sous 560 px).
- Un toucher simple sur la photo montre toujours les prénoms ; un glisser n'en déclenche pas.

Planche validée par Patch le 27/10 : _bmad-output/parenthese/motion/2026-10-27-glisser-souvenir/

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:41:45 +01:00
IliesAllaliandClaude Opus 5.5 16e765c231 Téléphone, mode édition : onglets en haut, « Terminé » en toutes lettres, une seule sortie
- Les onglets de familles restaient abaissés sous un message qui avait lui-même été descendu (68 px) : la bulle
  « Vous proposez » couvrait « Famille X / Famille Y ». Ils restent sur la première ligne, à côté de la loupe.
  Recherche ouverte en mode édition : ils passent sous la bulle.
- La croix à côté du « + » devient « Terminé », le mot qu'emploie déjà la barre pour sortir du dessin. Plus de
  rotation au survol ; pression à 0,97.
- Quand « Terminé » est là, la bulle du haut n'a plus sa propre croix : une seule sortie.

Patch, 27/10 : planche _bmad-output/parenthese/motion/2026-10-27-barre-edition/

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:35:09 +01:00
IliesAllaliandClaude Opus 5.5 b978b96864 Tests du client : ceux du délai maximal remis, ceux de l'idempotence à la suite
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:15:37 +01:00
IliesAllaliandClaude Opus 5.5 0132ecb342 Envois de la famille sans doublon : en-tête Idempotency-Key
Sur un réseau faible, une proposition, un souvenir, une photo posée sur l'arbre ou un moment pouvait être
créé deux fois : la réponse se perdait (délai dépassé), la personne renvoyait, le serveur recréait tout.

- Navigateur (api/client.js) : un envoi qui n'a pas abouti garde sa clé, et le même envoi refait (même
  adresse, même contenu) la réutilise. Une réussite libère la clé.
- Serveur (lib/idempotency.ts) : sur les quatre routes de création, une clé déjà vue par la même personne
  renvoie la réponse du premier envoi ; deux essais simultanés n'en traitent qu'un ; un échec n'est pas gardé.
  En mémoire, 24 h, 5 000 clés au plus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:14:32 +01:00
IliesAllali b866b36ca9 Merge pull request #173 from IliesAllali/feat/visages-discrets
Visages sur la photo : rien par défaut, ronds fins à la demande
2026-10-27 15:05:36 +01:00
IliesAllali e1b25265dd Merge pull request #172 from IliesAllali/feat/annuler-moments-medias
Souvenirs et moments supprimés : Annuler, comme pour une personne
2026-10-27 14:56:10 +01:00
IliesAllali a0afcfeafe Merge pull request #171 from IliesAllali/feat/mouvement-souvenir
Souvenir : grandit depuis sa vignette, et la visionneuse respecte les mouvements réduits
2026-10-27 14:29:16 +01:00
IliesAllaliandClaude Opus 5.5 2aab008766 Visages sur la photo : rien par défaut, ronds fins à la demande
- Les ronds des personnes identifiées ne restent plus posés sur les visages. Ils apparaissent au survol de la photo
  (ordinateur), quand on touche la photo (téléphone, un second toucher les cache), ou pour une seule personne quand
  on survole ou focalise son prénom sous la photo. Toujours visibles pendant l'identification.
- Rond plus large (34 px) et plus fin (1,5 px) : il entoure le visage au lieu de le couvrir.
- Sur téléphone, les prénoms ne s'affichent plus en permanence par-dessus la photo.

Planche validée par Patch le 27/10 (option A) : _bmad-output/parenthese/motion/2026-10-27-visages/

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 14:26:08 +01:00
IliesAllaliandClaude Opus 5.5 dbc6338f9b Souvenirs et moments supprimés : Annuler, comme pour une personne
Un hook commun (hooks/useUndoable.js) porte les trois suppressions annulables : bandeau 6 s,
Ctrl+Z, requête envoyée à la fin du délai ou à la fermeture de la page.
- Souvenir (gestionnaire de souvenirs) : il n'y avait aucune confirmation, la suppression était immédiate.
- Moment (onglet Vie) : plus de « Supprimer / Garder ».

Corrige deux défauts de #169 :
- Les tableaux du graphe étaient mutés en place (splice, push), alors que les index par personne
  (getPersonById, getPersonMedias) ne se reconstruisent qu'au remplacement du tableau. Nouveau
  replaceGraph() dans mockData, utilisé pour la suppression et l'ajout d'une personne en démo.
- Le rechargement après l'envoi vidait la sélection : une fiche ouverte pendant le délai se fermait.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 14:16:30 +01:00
IliesAllaliandClaude Opus 5.5 913de99f9e Souvenir : grandit depuis sa vignette à l'ouverture, et la visionneuse respecte les mouvements réduits
- Une vignette de la fiche touchée est retenue un court instant (utils/openOrigin.js) ; la visionneuse part de là,
  même largeur, et grandit jusqu'à sa place en 280 ms, transform seul, sans fondu. Le point de départ suit
  l'ancrage de la fenêtre : centre sur ordinateur, bord bas sur téléphone (pz-overlay en place-items: end).
- Ouverte autrement (photo de l'arbre, liste), la visionneuse ne change pas. Fermeture inchangée, d'un coup.
- Mouvements réduits : la carte n'avait pas la classe .pz-modal et jouait sa montée quand même ; elle s'affiche
  désormais d'un coup.

Planche validée par Patch le 27/10 : _bmad-output/parenthese/motion/2026-10-27-souvenir/

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 13:42:58 +01:00
IliesAllali 0b429e78a9 Merge pull request #169 from IliesAllali/feat/annuler-suppressions
Suppression d'une personne annulable (Annuler à la place de la confirmation)
2026-10-27 13:37:25 +01:00
IliesAllali 00b880708d Merge pull request #170 from IliesAllali/feat/mouvement-fiche
Fiche : apparaît d'un coup sur ordinateur, feuille plus vive, résistance vers le haut, coins continus
2026-10-27 13:37:13 +01:00
IliesAllaliandClaude Opus 5.5 3a18ac9eab Fiche : apparaît d'un coup sur ordinateur, feuille en 240 ms, résistance vers le haut, coins continus
- Ordinateur : plus d'entrée animée (fondu et flou de 260 ms) ; on ouvre une fiche des dizaines de fois par visite.
- Téléphone : la feuille monte en 240 ms au lieu de 400 ; en mouvements réduits, fondu court seulement sur téléphone.
- Geste des feuilles (fiche et menu de la barre) : tirée vers le haut, la feuille cède de moins en moins (40 px au plus)
  et revient au lâcher, au lieu de buter net. Logique pure sheetOffset, testée. Une ombre pleine comble le dessous.
- Coins continus (corner-shape: squircle) sur la fiche, le portrait et les vignettes, seulement là où le navigateur
  les dessine ; ailleurs rien ne change.

Planche validée par Patch le 27/10 : _bmad-output/parenthese/motion/2026-10-27-fiche/

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:57:34 +01:00
IliesAllaliandClaude Opus 5.5 a2a2a81a12 Suppression d'une personne annulable : Annuler à la place de la confirmation
La personne disparaît tout de suite et un bandeau propose « Annuler » (ou Ctrl+Z) pendant 6 s.
La requête ne part qu'à la fin du délai, ou à la fermeture de la page (fetch keepalive).
Un rechargement de l'arbre pendant le délai ne fait pas revenir la personne.
Démo : même bandeau, retour à l'état d'avant.

Corrige au passage la recherche, qui gardait des résultats périmés après un ajout ou une
suppression (le tableau des personnes est modifié sur place).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:15:17 +01:00
IliesAllaliandClaude Opus 5.5 59d3c89b1c 1.5.4 : changelog
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 16:24:53 +01:00
IliesAllaliandClaude Opus 5.5 42e4ed63bb Lien d'un onglet de famille : ?famille=<charnière> ouvre l'arbre sur cette famille, et le lien de « La famille participe » suit l'onglet ouvert (#167)
- Galaxy : l'onglet du lien s'ouvre dès que l'arbre et ses onglets sont connus, et de nouveau si la personne de départ arrive après (visiteur), tant qu'on n'a pas changé d'onglet soi-même ; onglets coupés dans ce navigateur, l'arbre entier s'ouvre sur la charnière
- App : l'adresse de la page porte l'onglet ouvert, le lien à envoyer aussi, avec le nom de la famille dans le titre
- Même mot de passe, même accès : le lien choisit l'onglet d'arrivée, il ne restreint rien (Patch, 26/10)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 16:21:36 +01:00
IliesAllaliandClaude Opus 5.5 b9f301619b 1.5.3 : changelog
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:19:20 +01:00
IliesAllaliandClaude Opus 5.5 20aded7ee0 Frontend : zod 4.6.7 (faille haute < 4.6.6), via eslint-plugin-react-hooks, fichier de verrouillage seul (#166)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:15:47 +01:00
IliesAllaliandClaude Opus 5.5 3aad835908 Tailwind 4 : app et landing, rien ne bouge à l'écran (#165)
* Tailwind 4 (app) : plugin Vite, remise à zéro de la v3 gardée (main sur les boutons, placeholder, bordure grise), thème et utilitaires inutilisés retirés

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tailwind 4 (landing) : plugin Vite comme l'app, remise à zéro de la v3 gardée, thème inutilisé retiré ; marge gauche du graphe du hero retirée, écrasée par lg:mx-0 en v3 et devenue visible en v4

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:06:33 +01:00
IliesAllaliandClaude Opus 5.5 1af0e6842e Déploiement : le contrôle des dépendances backend ne charge plus @prisma/client, qui n'existe qu'après generate depuis Prisma 7
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:03:06 +01:00
IliesAllaliandClaude Opus 5.5 cc01059c47 Prisma 7 : pilote pg, client généré, prisma.config.ts (#164)
* Prisma 7 : client généré dans src/generated, pilote pg, prisma.config.ts

- générateur prisma-client (ESM, extension .js), compilé dans dist : plus de node_modules/.prisma à copier dans les images
- @prisma/adapter-pg, délai de connexion de 5 s comme le moteur de Prisma 6
- prisma.config.ts : adresse de la base et .env pour le CLI, sans dépendre de prisma/config à l'exécution (CLI à part dans les images)
- CI : migrate diff --from-config-datasource --to-schema
- deploy.sh : arrêt avant installation si Node est trop vieux pour Prisma 7
- remplace les PR Dependabot #135 et #137

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Prisma 7 : import du client généré dans token-revocation (#163)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 13:58:42 +01:00
IliesAllali ef503c570d Comptes : une déconnexion survit au redémarrage de l'API (#163)
* Comptes : une déconnexion reste valable après un redémarrage de l'API (sessions fermées gardées en base)

* Déconnexion durable : sans rechargement des sessions fermées, l'API ne démarre pas ; modèle remonté au-dessus du commentaire des photos
2026-10-26 13:24:06 +01:00
IliesAllaliandClaude Opus 5.5 f1b4ad8db2 Compte à mot de passe : relier le fournisseur OIDC soi-même (#161)
* OIDC : un compte à mot de passe relie lui-même son identité du fournisseur (ticket à usage unique), pour passer une instance en PASSWORD_LOGIN=false sans l'enfermer dehors

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Compte : « Se connecter aussi avec … » relie le fournisseur à un compte à mot de passe

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* README : relier un compte à mot de passe avant PASSWORD_LOGIN=false

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:34:28 +01:00
IliesAllali 856a80d111 Souvenirs : envoi en flux et par plages (Range), pour les vidéos sur Safari (#162)
* Souvenirs : envoi en flux et par plages (Range, 206), pour Safari ; le fichier ne passe plus en entier en mémoire

* Range : fichier vide refusé en 416 ; test d'un souvenir en attente demandé par morceaux
2026-10-26 12:33:24 +01:00
IliesAllali 3bfacc5b1a Déploiement : commande forcée pour la clé de GitHub, qui ne peut plus que déployer un commit de main 2026-10-26 12:24:54 +01:00
IliesAllali 124ceb1c3e Sécurité : bcrypt natif hors de la boucle de l'API ; un souvenir en attente ne se lit que par le gestionnaire et son auteur (#160) 2026-10-26 12:10:55 +01:00
IliesAllaliandClaude Opus 5.5 00aff350f1 1.5.2 : changelog, #154 et #156 ajoutées
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:44:30 +01:00
IliesAllali dcc5fe74f7 1.5.2 : changelog 2026-10-26 11:38:49 +01:00
IliesAllali a6b37bca22 Merge pull request #159 from IliesAllali/fix/noms-monde
Noms en chinois, arabe, hébreu : une seule initiale, lieu et surnom isolés du sens de la ligne
2026-10-26 11:33:43 +01:00
IliesAllali bb6ec09fd1 Premiers visages : import de initialsOf, la fonction locale s'appelait elle-même ; test du panneau sans aperçu 2026-10-26 11:28:47 +01:00
IliesAllali 0f06f5a9f0 Merge pull request #157 from IliesAllali/fix/badge-conflit
Propositions : étiquette posée sur le bord de la carte, « À vérifier » à la place de l'action, bulle du compteur ronde
2026-10-26 11:24:17 +01:00
IliesAllali bc5ab7899e Merge pull request #158 from IliesAllali/fix/dates-region
Dates : ordre et format de la région du navigateur, mois en lettres (#152)
2026-10-26 11:23:16 +01:00
IliesAllali 01ed08d26f Noms en chinois, arabe, hébreu : une seule initiale, lieu et surnom isolés du sens de la ligne 2026-10-26 11:20:32 +01:00
IliesAllali 5b21021aca Propositions : une suppression en conflit garde son mot (« Suppression, à vérifier ») ; « suggestion » en anglais 2026-10-26 11:18:27 +01:00
IliesAllali b6eaa6da78 Dates : marques diacritiques retirées par \p{M}, lisible 2026-10-26 11:18:00 +01:00
dependabot[bot] cb1dfb39a6 build(deps): bump fast-uri in /backend (#155)
Bumps  and [fast-uri](https://github.com/fastify/fast-uri). These dependencies needed to be updated together.

Updates `fast-uri` from 3.1.8 to 3.1.9
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.8...v3.1.9)

Updates `fast-uri` from 4.2.1 to 4.2.3
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.8...v3.1.9)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.9
  dependency-type: indirect
- dependency-name: fast-uri
  dependency-version: 4.2.3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-26 11:15:09 +01:00
IliesAllali 8875f2baf0 Dates : ordre et séparateur de la région du navigateur (fr-CA, en-US…), mois en lettres accepté (#152) 2026-10-26 11:08:22 +01:00
IliesAllali fdc4254cdd Propositions : étiquette posée sur le bord de la carte, « À vérifier » remplace l'action en cas de conflit ; bulle du compteur ronde 2026-10-26 11:08:21 +01:00
IliesAllali 3c4eef1c92 Dates : en anglais, l'ordre jour/mois suit la région du navigateur (#152) 2026-10-26 11:08:21 +01:00
IliesAllali 52a3ccd466 Merge pull request #156 from IliesAllali/fix/toast-mobile
Notification après un ajout ou un envoi : sa vraie largeur sur téléphone
2026-10-26 10:57:44 +01:00
IliesAllaliandClaude Opus 5.5 b753e91fb4 Notification après un ajout ou un envoi : sa vraie largeur sur téléphone (plus quatre lignes dans une pilule), sans animation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:52:41 +01:00
IliesAllali f49875f821 Merge pull request #154 from IliesAllali/fix/pastilles-proches
Fiche : les « + » des proches en pastilles
2026-10-26 10:47:24 +01:00
dependabot[bot] cd844f12a7 build(deps): bump ip-address from 10.7.3 to 10.7.4 in /backend (#144)
Bumps [ip-address](https://github.com/beaugunderson/ip-address) from 10.7.3 to 10.7.4.
- [Release notes](https://github.com/beaugunderson/ip-address/releases)
- [Commits](https://github.com/beaugunderson/ip-address/compare/v10.7.3...v10.7.4)

---
updated-dependencies:
- dependency-name: ip-address
  dependency-version: 10.7.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-26 10:47:15 +01:00
IliesAllaliandClaude Opus 5.5 1f7ed7d96f Fiche : les « + » des proches en pastilles, comme « + Surnom » en modification
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 00:12:26 +01:00
IliesAllaliandClaude Opus 5.5 c530875775 1.5.1 : changelog daté
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 22:44:19 +01:00
IliesAllali 7ed0117890 Merge pull request #151 from IliesAllali/feat/propositions-fantomes
1.5.1 : les propositions de la famille sur l'arbre, photos de la famille, ajout d'un proche depuis la fiche
2026-10-25 22:41:17 +01:00
IliesAllali 4829fd77f4 Merge remote-tracking branch 'origin/main' into feat/propositions-fantomes
# Conflicts:
#	CHANGELOG.md
#	frontend/src/App.jsx
2026-10-25 22:38:47 +01:00
IliesAllaliandClaude Opus 5.5 f034adf761 Famille : la fenêtre d'envoi ne s'ouvre plus après chaque ajout, la personne est sur l'arbre en brouillon
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 22:32:35 +01:00
IliesAllaliandClaude Opus 5.5 ba6e4cbdf2 1.5.1 : changelog
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:06:35 +01:00
IliesAllaliandClaude Opus 5.5 54535cb596 Molette : plus d'erreur console à chaque cran (preventDefault dans un écouteur passif, déjà fait par l'écouteur natif)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 20:38:38 +01:00
IliesAllaliandClaude Opus 5.5 1d04f9ac3d Famille : après l'envoi, l'arbre est relu (plus de photo affichée comme acceptée) et le message dit que c'est en pointillés
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 20:37:00 +01:00
IliesAllaliandClaude Opus 5.5 2f60df2ed9 Relecture depuis l'arbre : une proposition déjà relue le dit, et l'arbre se remet à jour
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 20:35:34 +01:00
IliesAllaliandClaude Opus 5.5 82c895d10a Revue de droits 1.5.1 : photos refusées libérées après la relecture, citations lues dans le JSON décodé, contenu normalisé à l'envoi, plafond tenu sous verrou
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 20:27:35 +01:00
IliesAllaliandClaude Opus 5.5 73be6a7cfd Compteurs d'édition (sans contenu de l'arbre) : personne ajoutée et par où, proposition envoyée ou relue, modifications enregistrées, doublon ouvert
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 20:21:41 +01:00
IliesAllaliandClaude Opus 5.5 5ebfa8337d Ajouter un proche depuis la fiche : créer depuis la recherche d'un lien, frère ou sœur, « + » en lecture, panneau prérempli, alerte de doublon
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 20:20:27 +01:00
IliesAllaliandClaude Opus 5.5 6e42a1f12d Erreurs : la photo posée sur l'arbre dit pourquoi elle échoue ; « Mettre à jour l'arbre » garde dans le brouillon ce qui n'a pas été enregistré
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 20:16:02 +01:00
IliesAllaliandClaude Opus 5.5 0c74adb012 Fantômes : propositions en attente, brouillon et envois de la famille dessinés sur l'arbre ; acceptation ou refus depuis le fantôme
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 20:14:58 +01:00
IliesAllali 0feb6c55ab Merge pull request #150 from IliesAllali/fix/badge-a-verifier
Chercher quelqu'un : la famille ajoute en proposition ; marque « à vérifier » sur le coin
2026-10-25 20:06:10 +01:00
IliesAllaliandClaude Opus 5.5 ddc2231a8a Relecture : une ancienne annotation incomplète est écartée, pas la relecture entière
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 20:05:48 +01:00
IliesAllaliandClaude Opus 5.5 eebb8f2cb1 Propositions de la famille : photos posées sur l'arbre en attente de relecture, annotations posées par le serveur à l'acceptation, état d'une proposition
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 20:04:13 +01:00
IliesAllaliandClaude Opus 5.5 d89da4c7f8 Chercher quelqu'un : la famille ajoute en proposition ; marque « à vérifier » sur le coin
Un membre crée ou complète une personne trouvée : ça part dans ses modifications,
le propriétaire valide. Lieux, « à vérifier » et source suivent la personne proposée.
Le « ? » passe à cheval sur le coin du portrait, il ne cache plus le visage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 20:03:58 +01:00
IliesAllaliandClaude Opus 5.5 01aeb69aff 1.5.0 : changelog
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:26:05 +01:00
IliesAllali da680bceea Merge pull request #149 from IliesAllali/feat/bouton-icone
Icône de l'arbre et bouton « Modifier »
2026-10-25 19:25:19 +01:00
IliesAllali c207d25b60 Merge pull request #148 from IliesAllali/feat/cogestion-145
Plusieurs gestionnaires pour un arbre (#145)
2026-10-25 18:34:55 +01:00
IliesAllali b9faa2a4b4 Merge branch 'feat/cogestion-145' into feat/bouton-icone
# Conflicts:
#	CHANGELOG.md
2026-10-25 18:31:39 +01:00
IliesAllali 83e8282d94 Merge remote-tracking branch 'origin/main' into feat/cogestion-145
# Conflicts:
#	CHANGELOG.md
#	backend/src/app.ts
#	frontend/src/i18n/en/index.js
2026-10-25 18:30:28 +01:00
IliesAllaliandClaude Opus 5.5 b61ce5c8ad Icône de l'arbre (un emoji, dans les menus, le compte et la page d'invitation) et bouton « Modifier » pour qui gère l'arbre
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 18:22:48 +01:00
IliesAllali f192665f17 Merge pull request #146 from IliesAllali/feat/recherche-sources
Chercher quelqu'un ailleurs : panneau à droite de l'arbre
2026-10-25 18:19:56 +01:00
IliesAllali 3814a192f4 Merge pull request #147 from IliesAllali/fix/fiche-modifier-ipad
Fiche : « Modifier » d'un clic, « ? » entier sur les cadres ronds, arbre jusqu'en bas sur iPad
2026-10-25 18:19:46 +01:00
IliesAllaliandClaude Opus 5.5 7c9ea3c95e Chercher quelqu'un : créer la personne trouvée, rattachée à un proche
« Ajouter à l'arbre » s'ouvre sur Nouvelle personne : lien (parent, enfant, conjoint,
frère ou sœur) et proche dans l'arbre, par le même chemin qu'« Ajouter quelqu'un ».
Dates, lieux, sexe, source en note, à vérifier. « Déjà dans l'arbre » complète une fiche.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 18:15:03 +01:00
IliesAllaliandClaude Opus 5.5 f9fefd83e1 Chercher quelqu'un : recherche repliée après résultats, cartes plus complètes
La recherche tient en une ligne au-dessus des résultats. Chaque carte donne les
années de vie, l'âge au décès, des phrases accordées au sexe et la raison de sa note.
WikiTree limité au même nom de famille ; flèche des liens collée au texte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:51:43 +01:00
IliesAllaliandClaude Opus 5.5 d0ab96d634 Pastilles : emoji entier en initiale (arbre, compte, personne) ; fiche : message clair quand les droits ont été retirés
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:35:31 +01:00
IliesAllaliandClaude Opus 5.5 bff3f54b00 Chercher quelqu'un ailleurs : panneau à droite de l'arbre
Entrée « Quelqu'un ailleurs » dans le menu +. On tape un nom, une année et un lieu.
Liens préremplis vers Gallica, FranceArchives, Mémoire des hommes, Léonore, Geneanet,
Open Archives, WikiTree, FreeBMD et FamilySearch, ouverts par le navigateur.
Le serveur interroge les décès INSEE depuis 1970 (matchID) et WikiTree quand
SEARCH_SOURCES les nomme ; MATCHID_TOKEN au-delà d'une centaine de recherches.
Un résultat ajouté ne remplit que les champs vides, passe la personne à vérifier
et garde la source dans sa note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:33:41 +01:00
IliesAllaliandClaude Opus 5.5 aa50123e9f Paramètres : cinq onglets qui tiennent à 360 px
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:20:19 +01:00
IliesAllaliandClaude Opus 5.5 9221f8b63f CHANGELOG : 1.5.0, co-gestion et correctifs de la fiche
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:05:11 +01:00
IliesAllaliandClaude Opus 5.5 07d69896ae Co-gestion (#145), écrans : onglet Gestion, page du lien d'invitation, Quitter pour le co-gestionnaire, avertissement à la suppression du compte, Immich caché
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 16:57:57 +01:00
IliesAllaliandClaude Opus 5.5 ac9c33d43f Co-gestion (#145), revue de droits : Immich réservé au propriétaire, retrait qui coupe aussi l'accès par le partage, rattachement OIDC sur l'adresse du compte
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 16:57:11 +01:00
IliesAllaliandClaude Opus 5.5 5be134e0e6 Co-gestion d'un arbre (#145) : invitations, rattachement OIDC sur email vérifié, compte ouvert par invitation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 16:57:11 +01:00
IliesAllaliandClaude Opus 5.5 befe8fe19a « À vérifier » : drawPortrait, et un test qui échoue si la marque repasse sous la découpe du cadre
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 16:57:10 +01:00
IliesAllaliandClaude Opus 5.5 230ec2ae05 Fiche : « Modifier » d'un clic pour l'administrateur ; « à vérifier » entier sur un cadre rond ; arbre jusqu'en bas de l'écran sur iPad
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 16:36:41 +01:00
IliesAllali 3c9031e6b2 Merge pull request #143 from IliesAllali/release/1.4.0
1.4.0 : changelog
2026-10-23 16:31:58 +02:00
IliesAllaliandClaude Opus 5.5 387029f4e7 1.4.0 : changelog
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 16:29:36 +02:00
IliesAllali c35d13885a Merge pull request #136 from IliesAllali/dependabot/npm_and_yarn/frontend/frontend-minor-patch-8146d706b9
build(deps): bump the frontend-minor-patch group in /frontend with 8 updates
2026-10-23 16:29:08 +02:00
IliesAllali ccd9cd0936 Merge pull request #134 from IliesAllali/dependabot/npm_and_yarn/backend/backend-minor-patch-7298adcaed
build(deps): bump the backend-minor-patch group in /backend with 3 updates
2026-10-23 16:29:03 +02:00
IliesAllali 07b10559e6 Merge pull request #133 from IliesAllali/dependabot/npm_and_yarn/landing/landing-minor-patch-00b7d6252c
build(deps): bump the landing-minor-patch group in /landing with 4 updates
2026-10-23 16:28:58 +02:00
IliesAllali 6030b1ff6f Merge pull request #142 from IliesAllali/fix/souvenirs-non-etires
Souvenirs en orbite recadrés, plus étirés dans leur carré
2026-10-23 16:09:48 +02:00
IliesAllaliandClaude Opus 5.5 aa22ff1422 Souvenirs en orbite recadrés, plus étirés dans leur carré
Retour de Patch du 23/10 sur l'arbre d'Anaïs : les vignettes de souvenirs autour des portraits
étaient « souvent tassées horizontalement ou verticalement ». drawMediaContent étirait la photo
dans le carré quel que soit son format ; elle passe par drawImageCover, le recadrage centré
des portraits.

Vérifié ailleurs, rien à corriger : fiche, liste et gestion des souvenirs, onglet Vie, moments,
propositions et Immich sont déjà en object-fit cover, la visionneuse en contain, les photos
posées sur l'arbre gardent leur rapport.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 16:05:04 +02:00
IliesAllali 2345dd5505 Merge pull request #140 from IliesAllali/feat/a-verifier
Personnes « à vérifier » : la famille signale ce dont elle n'est pas sûre
2026-10-23 15:30:36 +02:00
IliesAllali 8fe2f53b34 Merge remote-tracking branch 'origin/main' into feat/a-verifier 2026-10-23 15:27:25 +02:00
IliesAllali 4817cea93b Merge pull request #141 from IliesAllali/fix/annotations-ancrees
Annotations rattachées à une personne, texte posé modifiable
2026-10-23 15:27:07 +02:00
IliesAllali 93a5e41794 Merge remote-tracking branch 'origin/main' into fix/annotations-ancrees
# Conflicts:
#	frontend/src/App.jsx
#	frontend/src/components/Galaxy.jsx
2026-10-23 15:24:46 +02:00
IliesAllali e91c3206f2 Merge pull request #138 from IliesAllali/feat/onglets-ligne-directe
Onglets de familles : la ligne directe vers « C'est moi » dans chaque onglet
2026-10-23 15:20:30 +02:00
IliesAllali fb176c0cc6 Merge remote-tracking branch 'origin/main' into feat/a-verifier 2026-10-23 15:18:38 +02:00
IliesAllali d579c4a34b Merge remote-tracking branch 'origin/main' into fix/annotations-ancrees 2026-10-23 15:18:35 +02:00
IliesAllali 4ff34705c1 Merge remote-tracking branch 'origin/main' into feat/onglets-ligne-directe 2026-10-23 15:18:31 +02:00
IliesAllali 09c1d2337d Merge pull request #139 from IliesAllali/fix/dompurify
deps: dompurify 3.4.17 (avis critique du 23/10)
2026-10-23 15:18:09 +02:00
IliesAllaliandClaude Opus 5.5 2e5db68969 Annotations rattachées à une personne, texte posé modifiable
Retour d'Anaïs du 23/10 : un texte posé dans l'onglet d'une famille réapparaissait au même
endroit dans l'onglet d'une autre. L'annotation ne gardait que x et y, or chaque onglet recalcule
le placement.

- Chaque annotation garde dans son style la personne la plus proche quand on la pose et le
  décalage à son portrait (style.anchor, pas de migration). Elle s'affiche à côté de ce portrait
  dans le placement affiché, et pas du tout si la personne n'y est pas. Déplacer recalcule l'ancre.
- Les anciennes annotations sont rattachées au premier affichage, dans le placement de l'onglet
  de départ, puis enregistrées une fois (style seul) quand on administre l'arbre.
- Un texte déjà sélectionné se rouvre au clic pour être modifié (double clic sur ordinateur,
  deux touchers sur téléphone), et n'est plus dessiné sous sa saisie.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:10:46 +02:00
IliesAllaliandClaude Opus 5.5 e9d57e5954 Personnes « à vérifier » : la famille signale ce dont elle n'est pas sûre
Retour d'Anaïs du 23/10 : plutôt que de dessiner sur l'arbre, marquer une
personne « à vérifier ». Champ toVerify (migration additive, faux par défaut),
posé dans la fiche en modification (ligne « Vérification ») par un admin ou
par la famille via une proposition, montré par une pastille dans la fiche et
un petit rond terracotta avec un « ? » sur le portrait (absent au dézoom).
Taper « à vérifier » dans la recherche liste les personnes marquées.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:02:29 +02:00
IliesAllaliandClaude Opus 5.5 64821c9c99 deps: dompurify 3.4.17 sous posthog-js (GHSA-cg2g-xh64-v4q7, critique)
L'audit de la CI bloque toutes les PR depuis la publication de l'avis du 23/10.
Seuls les fichiers de verrou changent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:55:38 +02:00
IliesAllaliandClaude Opus 5.5 03f172b59b Onglets de familles : la ligne directe vers « C'est moi » dans chaque onglet
Dans l'onglet d'une autre famille, la personne commune (celle que le visiteur
désigne par « C'est moi », sinon la personne de départ de l'arbre) reste visible,
reliée par sa ligne directe : le plus court chemin de parenté jusqu'à la famille,
avec les deux parents à chaque pas, sans fratries autour. Ces portraits sont
estompés et un peu réduits.

Un visiteur sans compte peut maintenant dire qui il est dans l'arbre (menu du
visiteur), choix gardé dans son navigateur pour cet arbre.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:49:46 +02:00
dependabot[bot] 831aa4c0ac build(deps): bump the frontend-minor-patch group
Bumps the frontend-minor-patch group in /frontend with 8 updates:

| Package | From | To |
| --- | --- | --- |
| [@lucide/icons](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide) | `1.56.0` | `1.59.0` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.56.0` | `1.59.0` |
| [posthog-js](https://github.com/PostHog/posthog-js) | `1.438.9` | `1.438.12` |
| [react](https://github.com/react/react/tree/HEAD/packages/react) | `19.3.0` | `19.3.1` |
| [react-dom](https://github.com/react/react/tree/HEAD/packages/react-dom) | `19.3.0` | `19.3.1` |
| [eslint](https://github.com/eslint/eslint) | `10.12.0` | `10.13.0` |
| [postcss](https://github.com/postcss/postcss) | `8.5.30` | `8.5.31` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `5.0.4` | `5.0.5` |


Updates `@lucide/icons` from 1.56.0 to 1.59.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.59.0/packages/lucide)

Updates `lucide-react` from 1.56.0 to 1.59.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.59.0/packages/lucide-react)

Updates `posthog-js` from 1.438.9 to 1.438.12
- [Release notes](https://github.com/PostHog/posthog-js/releases)
- [Changelog](https://github.com/PostHog/posthog-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/PostHog/posthog-js/compare/posthog-js@1.438.9...posthog-js@1.438.12)

Updates `react` from 19.3.0 to 19.3.1
- [Release notes](https://github.com/react/react/releases)
- [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/react/react/commits/v19.3.1/packages/react)

Updates `react-dom` from 19.3.0 to 19.3.1
- [Release notes](https://github.com/react/react/releases)
- [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/react/react/commits/v19.3.1/packages/react-dom)

Updates `eslint` from 10.12.0 to 10.13.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.12.0...v10.13.0)

Updates `postcss` from 8.5.30 to 8.5.31
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.30...8.5.31)

Updates `vitest` from 5.0.4 to 5.0.5
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v5.0.5/packages/vitest)

---
updated-dependencies:
- dependency-name: "@lucide/icons"
  dependency-version: 1.59.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-minor-patch
- dependency-name: lucide-react
  dependency-version: 1.59.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-minor-patch
- dependency-name: posthog-js
  dependency-version: 1.438.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-minor-patch
- dependency-name: react
  dependency-version: 19.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-minor-patch
- dependency-name: react-dom
  dependency-version: 19.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-minor-patch
- dependency-name: eslint
  dependency-version: 10.13.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-minor-patch
- dependency-name: postcss
  dependency-version: 8.5.31
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-minor-patch
- dependency-name: vitest
  dependency-version: 5.0.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-22 01:15:01 +00:00
dependabot[bot] 4796d921a2 build(deps): bump the backend-minor-patch group
Bumps the backend-minor-patch group in /backend with 3 updates: [@fastify/jwt](https://github.com/fastify/fastify-jwt), [dotenv](https://github.com/motdotla/dotenv) and [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest).


Updates `@fastify/jwt` from 10.2.4 to 10.2.5
- [Release notes](https://github.com/fastify/fastify-jwt/releases)
- [Commits](https://github.com/fastify/fastify-jwt/compare/v10.2.4...v10.2.5)

Updates `dotenv` from 18.0.7 to 18.0.8
- [Changelog](https://github.com/motdotla/dotenv/blob/master/CHANGELOG.md)
- [Commits](https://github.com/motdotla/dotenv/compare/v18.0.7...v18.0.8)

Updates `vitest` from 5.0.4 to 5.0.5
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v5.0.5/packages/vitest)

---
updated-dependencies:
- dependency-name: "@fastify/jwt"
  dependency-version: 10.2.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: backend-minor-patch
- dependency-name: dotenv
  dependency-version: 18.0.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: backend-minor-patch
- dependency-name: vitest
  dependency-version: 5.0.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: backend-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-22 01:14:38 +00:00
dependabot[bot] 5a7cb03a01 build(deps): bump the landing-minor-patch group
Bumps the landing-minor-patch group in /landing with 4 updates: [posthog-js](https://github.com/PostHog/posthog-js), [react](https://github.com/react/react/tree/HEAD/packages/react), [react-dom](https://github.com/react/react/tree/HEAD/packages/react-dom) and [postcss](https://github.com/postcss/postcss).


Updates `posthog-js` from 1.438.9 to 1.438.12
- [Release notes](https://github.com/PostHog/posthog-js/releases)
- [Changelog](https://github.com/PostHog/posthog-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/PostHog/posthog-js/compare/posthog-js@1.438.9...posthog-js@1.438.12)

Updates `react` from 19.3.0 to 19.3.1
- [Release notes](https://github.com/react/react/releases)
- [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/react/react/commits/v19.3.1/packages/react)

Updates `react-dom` from 19.3.0 to 19.3.1
- [Release notes](https://github.com/react/react/releases)
- [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/react/react/commits/v19.3.1/packages/react-dom)

Updates `postcss` from 8.5.30 to 8.5.31
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.30...8.5.31)

---
updated-dependencies:
- dependency-name: posthog-js
  dependency-version: 1.438.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: landing-minor-patch
- dependency-name: react
  dependency-version: 19.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: landing-minor-patch
- dependency-name: react-dom
  dependency-version: 19.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: landing-minor-patch
- dependency-name: postcss
  dependency-version: 8.5.31
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: landing-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-22 01:14:30 +00:00
IliesAllali dd0eb93965 Merge pull request #132 from IliesAllali/fix/arbre-sans-plus-photo
Arbre : plus de « + photo » sur un portrait vide
2026-10-21 21:26:21 +02:00
IliesAllaliandClaude Opus 5.5 754fd62689 Arbre : plus de « + photo » sur un portrait vide en modification
Au survol, un portrait sans photo montrait « + photo » et un clic ouvrait le sélecteur de fichiers au lieu de la fiche. Le clic ouvre maintenant la fiche ; la photo s'ajoute par le badge appareil photo de la fiche. Retire le dessin, la zone de clic, l'entrée de fichier cachée et les demandes de recadrage et de source qui n'existaient que pour lui.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:23:31 +02:00
IliesAllali 06d0404903 Merge pull request #131 from IliesAllali/feat/souvenirs-personnes
Personnes identifiées sur un souvenir
2026-10-21 20:48:19 +02:00
IliesAllali bf1d1c1a3e Merge remote-tracking branch 'origin/main' into feat/souvenirs-personnes 2026-10-21 20:33:52 +02:00
IliesAllali 01b6f47b1f Merge pull request #130 from IliesAllali/fix/fiche-sans-ajout-photo
Fiche sans bouton d'ajout de photo, rond du compte bien rond
2026-10-21 20:33:28 +02:00
IliesAllaliandClaude Opus 5.5 f65273e95a Personnes identifiées sur un souvenir
Sur une photo ou une vidéo, on ajoute les personnes de l'arbre qui y figurent et, sur une photo,
on touche leur visage pour poser un point. Le souvenir apparaît aussi sur la fiche de chaque
personne identifiée (« Avec Odette »), un prénom ouvre sa fiche et recentre l'arbre.

L'administrateur enregistre directement. La famille propose, l'administrateur relit dans
« Propositions à relire ». Suppression d'une personne ou d'un souvenir : identifications retirées.

Table media_person_tags (migration additive), route PUT /trees/:id/media/:mediaId/people.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:32:22 +02:00
IliesAllaliandClaude Opus 5.5 8c684847ca Fiche sans bouton d'ajout de photo hors modification, rond du compte bien rond
Hors mode modification, un portrait vide montre les initiales : la photo s'ajoute en modification.
La photo du rond du compte suivait sa hauteur naturelle dans la grille et s'étirait en ovale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 19:38:11 +02:00
IliesAllali bfda31f2b4 Merge pull request #129 from IliesAllali/release/1.3.0
1.3.0 : changelog
2026-10-21 18:47:43 +02:00
IliesAllaliandClaude Opus 5.5 af9f81bbc2 1.3.0 : changelog
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 17:12:38 +02:00
IliesAllali e064ee5040 Merge pull request #128 from IliesAllali/feat/immich-memories-retouches
Souvenirs depuis Immich (#103 d'Askrosm) et retouches de design
2026-10-21 17:09:48 +02:00
IliesAllaliandClaude Opus 5.5 0a0668410e Souvenirs depuis Immich : relecture des retouches
- Le titre tiré du nom de fichier ne retire plus que l'extension (point échappé).
- Zone de dépôt unique, un label comme sur main, avec « depuis Immich » en bouton dedans.
- Le lien Immich n'apparaît que pour une photo ou une vidéo.
- Plusieurs souvenirs venus d'Immich : le champ Titre disparaît, le bouton dit « Ajouter ces n souvenirs ».
- Message vide et nom dans l'aperçu en serif 20 px, comme ailleurs dans l'app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:47:53 +02:00
IliesAllaliandClaude Haiku 5.5 bfa8d2e01a Souvenirs depuis Immich : multi-sélection dans le panneau et retouches de design
Reprend les commits de la PR #103 d'Askrosm (sélecteur Immich).

- Sélecteur : aide retirée sous le titre, aide de recherche déplacée dans le titre du champ, onglets Photos/Vidéos et filtre visage sur une ligne sur mobile, erreur Immich affichée en haut des résultats avec Réessayer.
- Panneau souvenirs : plusieurs fichiers Immich peuvent être cochés, « Terminer la sélection » les ajoute à la liste ; à l'enregistrement, un souvenir est créé par fichier, titré par son nom de fichier.
- Le bouton « Depuis Immich » pleine largeur devient un lien dans la zone de dépôt.
- Cible de sélection portée à 44 px, choix de visage à 44 px minimum.
- Tailles de police et rayon de bordure ramenés à l'échelle de pz.css.
- useMediaManager : handleUploadPersonMedia renvoie true ou false pour arrêter l'envoi à la première erreur.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-21 16:25:18 +02:00
IliesAllali 0ff143f2c1 Merge origin/main dans feat/immich-memories-retouches 2026-10-21 16:16:22 +02:00
IliesAllali 1d57c64f70 Merge pull request #127 from IliesAllali/release/1.2.1
1.2.1 : changelog
2026-10-21 16:01:12 +02:00
IliesAllaliandClaude Opus 5.5 e6bb549be5 1.2.1 : changelog
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:58:56 +02:00
IliesAllali f07903c0be Merge pull request #126 from IliesAllali/fix/1.2.1-relecture
1.2.1 : export GEDCOM 5.5.1 valide, suppression de compte OIDC, identité OIDC par sub, HEIC reconnu
2026-10-21 15:58:14 +02:00
IliesAllaliandClaude Haiku 5.5 0fb4aeec0d fix(médias) : HEIC et AVIF reconnus dans les octets, plus typés vidéo/mp4
Une photo HEIC (marques heic, heix, mif1, msf1, heim, heis) était typée video/mp4. Elle est maintenant image/heic,
et AVIF image/avif. Elles restent refusées à l'envoi (invalid_media_mime_type), comme avant : sharp ne décode
que l'AVIF en entrée HEIF, pas le HEVC des HEIC. La marque hevc n'est une photo que si l'envoi se déclare photo,
pour qu'une vidéo HEVC en MP4 reste acceptée. Les vignettes Immich ne sont servies que pour les types image
autorisés.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-21 15:51:48 +02:00
IliesAllaliandClaude Haiku 5.5 25b33ca056 fix(comptes) : suppression sans mot de passe si PASSWORD_LOGIN=false, identité OIDC liée par sub
Suppression de compte : un compte qui avait un mot de passe mais qui est relié à OIDC, sur une instance avec
PASSWORD_LOGIN=false, ne pouvait plus se supprimer (le mot de passe ne sert plus). Il se confirme désormais par
son email après une connexion récente, comme un compte né du fournisseur. /me et l'échange OIDC renvoient
hasPassword=false dans ce cas, pour que l'écran demande l'email : aucun changement d'interface.

Identité OIDC : un email vérifié déjà relié à une autre identité (un autre sujet) n'est plus repris par un
nouveau sujet (oidc_email_taken). La connexion d'une identité déjà reliée reste faite par émetteur et sujet,
jamais par email.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-21 15:51:47 +02:00
IliesAllaliandClaude Haiku 5.5 6bb0a832d2 fix(gedcom) : export en 5.5.1 avec SEX U pour un sexe « autre »
GEDCOM 5.5.1 n'admet que M, F et U ; SEX X n'existe qu'en 7.0, et l'export écrivait X sous un en-tête 5.5.1.
Un sexe « autre » s'écrit maintenant U. L'import lit toujours X comme avant.

Conséquence connue : à l'aller-retour, « autre » revient comme « non précisé » (5.5.1 n'a pas de valeur pour
« autre »). Décision à trancher, voir la description de la PR.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-21 15:51:47 +02:00
IliesAllali 65a1333d56 Merge pull request #125 from IliesAllali/fix/photo-hors-modification
Clic sur un portrait vide : la fiche, plus le sélecteur de fichiers (#124)
2026-10-21 15:42:47 +02:00
IliesAllaliandClaude Opus 5.5 d55ea6ce4a fix(arbre): « + photo » seulement en modification (issue #124)
Depuis la 1.2.0, chez le propriétaire, un clic sur un portrait vide ouvrait
le sélecteur de fichiers même en simple lecture, sur chaque personne sans
photo. En lecture, le clic ouvre maintenant la fiche, comme pour les autres
portraits ; l'invite « + photo » au survol et le sélecteur direct ne restent
qu'en mode modification. L'ajout de photo depuis la fiche ne change pas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:38:00 +02:00
Askrosm fb791b6752 fix: address Immich import review feedback
Use Immich previews for photos, enforce the image download cap while streaming,
reuse design tokens and remove unused translations. Add regression coverage.

Signed-off-by: Askrosm <155456680+Askrosm@users.noreply.github.com>
2026-10-20 22:05:42 +02:00
IliesAllali 3b08fc1dce Merge pull request #123 from IliesAllali/release/1.2.0
Release 1.2.0 : changelog, OIDC expérimental, jamais relié à un compte à mot de passe
2026-10-20 16:15:04 +02:00
IliesAllaliandClaude Opus 5.5 07e4fb11f9 1.2.0 : changelog, OIDC marqué expérimental, jamais relié à un compte à mot de passe
- OIDC : une identité n'est plus reliée automatiquement à un compte existant qui a un mot de passe.
  L'inscription ne vérifie pas l'adresse, un tiers aurait pu créer ce compte avec l'email de la victime et lire
  ce qu'elle y met ensuite. Refus avec oidc_email_taken et un message clair (FR, EN). Trouvé par la relecture
  de sécurité avant la release.
- Tests OIDC : chaque connexion simulée a sa propre adresse (limite de 20 appels par minute et par adresse).
- CHANGELOG 1.2.0, OIDC marqué expérimental dans les deux README.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:12:42 +02:00
IliesAllali 441693bf43 Merge pull request #118 from IliesAllali/feat/sexe-et-liens
Sexe d'une personne et liens de parenté genrés (#114)
2026-10-20 15:47:57 +02:00
IliesAllaliandClaude Opus 5.5 9ba1aee96f Merge origin/main dans feat/sexe-et-liens, trois choix à bascule, « Décédé(e), date inconnue » sur une ligne (#114)
- Fusion de main : graphAdapter.js supprimé par #119 ; le sexe est relu par
  normalizeSex au chargement du graphe (data/mockData.js), en plus du serveur
  qui renvoie déjà male/female/other/null. Mention retirée de i18n/en/app.js.
- Sexe : trois boutons à bascule (Femme, Homme, Autre, aria-pressed). Rien de
  choisi = non précisé (null), la valeur par défaut ; un second clic sur le
  choix actif y revient. La relecture affiche « Non précisé » pour une valeur
  vide (sexLabel).
- « Ajouter quelqu'un » : « Décédé(e), date inconnue » sort de la colonne Décès
  et tient sur une ligne, comme la case du prénom (règle CSS commune).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:45:26 +02:00
IliesAllali 504411adf3 Merge pull request #122 from IliesAllali/feat/connexion-oidc
Auto-hébergement : connexion par un fournisseur d'identité OIDC
2026-10-20 15:34:16 +02:00
IliesAllaliandClaude Opus 5.5 65900a4372 feat(comptes): connexion par un fournisseur OIDC (#115)
OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET et OIDC_REDIRECT_URI
ajoutent « Se connecter avec … » à l'écran de connexion. Tout le protocole
passe par openid-client 6 : découverte, PKCE S256, state et nonce, échange
du code, vérification de l'ID token (signature, émetteur, audience, dates).
State, nonce et vérificateur PKCE vivent dans un cookie signé, httpOnly,
SameSite=Lax, qui lie le retour au navigateur qui a commencé la connexion.

Le compte est trouvé par (émetteur, sub), sinon relié par email vérifié,
sinon créé sans mot de passe si ALLOW_REGISTRATION le permet (instance
vide comprise). Retour vers l'app par un jeton de passage de 60 s, à usage
unique, dans le fragment, échangé contre une session.

PASSWORD_LOGIN=false retire les mots de passe Parenthèse. Un compte sans
mot de passe se supprime en retapant son email, dans les 15 minutes qui
suivent une connexion. Code OIDC masqué dans les journaux.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:26:01 +02:00
IliesAllali 72a3869d52 Merge pull request #121 from IliesAllali/fix/pastille-propositions
Propositions à relire : la pastille s'allume dès l'ouverture de l'arbre
2026-10-20 15:25:40 +02:00
IliesAllaliandClaude Opus 5.5 7a43fe4aaf feat(personnes): quatrième choix « Autre » (SEX X), case du prénom inconnu sur une ligne (#114)
- Sexe : Femme, Homme, Autre, Non précisé. Autre est enregistré « other »,
  refusé en 400 comme toute valeur inconnue, et prend les formes neutres ou
  doubles pour nommer un lien.
- GEDCOM : import SEX X (GEDCOM 7) → other, SEX U → non précisé ; export
  other → SEX X, non précisé sans ligne SEX. Aller-retour testé.
- « Ajouter quelqu'un » : « Je ne connais pas son prénom » sort de la colonne
  Prénom et tient sur une ligne (sous les deux champs sur ordinateur, sous
  Prénom sur téléphone) ; les quatre choix du sexe tiennent sur une ligne.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:13:12 +02:00
IliesAllaliandClaude Opus 5.5 0ad44fbaa7 Propositions à relire : la pastille s'allume dès l'ouverture de l'arbre
Le compte des propositions en attente ne se chargeait qu'à l'ouverture de Partager ou du panneau des
propositions. Il se charge maintenant quand le propriétaire ouvre son arbre et se remet à jour quand il
revient sur l'onglet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:08:32 +02:00
IliesAllali 352951979a Merge pull request #120 from IliesAllali/fix/onglet-souvenirs
Fiche : « Souvenirs · 3 » sur une ligne en modification
2026-10-20 14:37:39 +02:00
IliesAllali 111a5d3df5 Merge pull request #119 from IliesAllali/chore/code-mort
Ménage : retirer onze modules de l'interface que plus rien n'importe
2026-10-20 14:37:26 +02:00
IliesAllaliandClaude Opus 5.5 c46cc747f6 Fiche : « Souvenirs · 3 » ne passe plus sur deux lignes en modification
Les quatre onglets (Liens en plus) se partagent la place selon leur texte, sans retour à la ligne.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:34:36 +02:00
IliesAllaliandClaude Opus 5.5 34454abc18 Ménage : retirer onze modules de l'interface que plus rien n'importe
AddPersonButton, DropTrashZone, CreateMyTreeButton, EditModeOverlay.jsx (App importe EditSessionBar sous ce
nom, sa feuille de style reste), InitialTreeWizard, InlinePersonForm, ProximityFeedback, useAddPersonDrag,
graphAdapter et graphDataHelpers, avec leurs feuilles de style. Aucun import, test compris ; lint, 253 tests et
build verts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:27:49 +02:00
IliesAllaliandClaude Opus 5.5 eaecac8fa5 feat(personnes): saisir le sexe et nommer les liens de parenté en conséquence (#114)
- Champ Sexe (femme, homme, non précisé) dans la fiche en modification, à l'ajout
  d'une personne et dans une proposition de la famille ; relu « Sexe » dans les
  propositions à relire.
- Valeurs ramenées à male / female / null à l'écriture et à la lecture (anciennes
  valeurs M/F relues sans migration), valeur inconnue refusée (400). Export GEDCOM
  inchangé dans sa forme.
- Lien au survol genré quand le sexe est connu (Père, Tante, Cousine germaine,
  Belle-mère…), forme double sinon. Nommage déplacé dans utils/kinship : une
  fonction par langue reçoit le chemin complet (pas, personnes, sexe, année de
  naissance), pour brancher d'autres langues.
- Anglais : beau-parent distingué de parent par alliance selon l'ordre du chemin
  (Stepmother / Mother-in-law).
- Propositions à relire : le nom de la personne modifiée vient de l'avant lu en
  base quand la modification ne porte que le champ changé.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:25:45 +02:00
IliesAllali 4dd0db9699 Merge pull request #117 from IliesAllali/feat/inscription-fermee
Auto-hébergement : fermer la création de compte (ALLOW_REGISTRATION)
2026-10-20 14:14:44 +02:00
IliesAllaliandClaude Opus 5.5 1b2d8ed6e2 feat(comptes): ALLOW_REGISTRATION ferme la création de compte (#113)
À false, POST /auth/register répond 403 registration_closed et l'app ne
propose plus de créer un compte (accueil, connexion, porte d'un arbre
partagé, menu du visiteur). Les comptes existants se connectent et créent
leurs arbres, le partage par lien et mot de passe ne change pas.

Une instance sans aucun compte accepte quand même le premier, sous un
verrou de transaction PostgreSQL (deux inscriptions simultanées n'en
créent qu'un). GET /auth/options expose l'état à l'écran d'accueil.

Défaut true : rien ne change pour les instances existantes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:12:18 +02:00
IliesAllali 5f115322e2 Merge pull request #116 from IliesAllali/feat/placement-un-seul-arbre
Placement : un seul arbre, grandes belles-familles en onglets
2026-10-20 13:59:31 +02:00
IliesAllali caa940f4ca Merge pull request #112 from IliesAllali/feat/layout-bench-v2
Banc du placement v2 : invariants, tracé réel, archétypes en texte, stabilité
2026-10-20 13:59:14 +02:00
IliesAllali da5c87d002 Merge pull request #111 from IliesAllali/fix/generation-parente
Placement : rangées = génération de parenté depuis la personne de référence
2026-10-20 13:58:19 +02:00
IliesAllali b7b67e76b9 Merge remote-tracking branch 'origin/main' into fix/generation-parente 2026-10-20 13:56:17 +02:00
IliesAllali b057733f64 Merge pull request #105 from IliesAllali/fix/placement-trois-correctifs
Placement : rangées stables, couples tournés vers leurs familles, fil libre malgré les conjoints de la fratrie
2026-10-20 13:54:58 +02:00
IliesAllaliandClaude Opus 5.5 ff078fd2bf onglets : la recherche ouvre l'onglet de la personne ; barre jamais cachée par la fiche ou la recherche
- Une personne trouvée par la recherche mais rangée dans un autre onglet : l'onglet s'ouvre et la vue va
  sur elle (avant, la fiche s'ouvrait et l'arbre ne bougeait pas).
- Téléphone : recherche ouverte, la barre passe dessous. Grand écran : fiche ouverte, la barre passe à sa
  droite ; recherche ouverte, elle s'arrête avant et se fond au bord.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:33:30 +02:00
IliesAllaliandClaude Opus 5.5 eb8e717638 onglets : pas d'onglet pour une famille de 10 personnes ou moins, elle reste dessinée
desc-3 : 30 → 5 onglets, croisements 30 → 41 ; arbre réel et asc-23 inchangés.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:00:54 +02:00
IliesAllaliandClaude Opus 5.5 e6aa6d7fda onglets : deux familles au même nom se distinguent par les prénoms de leur charnière
Sauf le plus grand, chaque onglet homonyme prend les prénoms du couple charnière (« Bonnet · Hugo et
Françoise »), et la pastille qui y mène porte le même nom.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:58:27 +02:00
IliesAllaliandClaude Opus 5.5 bfa85f83fc placement : le côté extérieur de la branche prime sur le centre ; oncles et tantes ne montent pas
- Une branche au bout de sa fratrie pose ses belles-familles au-delà, jamais dans la fratrie des aînés :
  la branche de Paul passe à droite d'Eugénie et de Louise, hors de la ligne d'Auguste et Yvonne.
- Une belle-famille qui monte au-dessus des parents du conjoint se réduit aux parents si elle y amène des
  oncles ou tantes ; une simple colonne d'ancêtres reste dessinée (archétype issue-67 inchangé).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:29:50 +02:00
IliesAllaliandClaude Fable 5.1 a1203fa767 placement : les parents du conjoint juste au-dessus de lui
- La rangée des parents n'est réservée que sur la moitié de l'enfant de la lignée, pour tout couple : au-dessus
  du conjoint, ses propres parents se posent (Louis et Joséphine au-dessus de Victor, 936 → 138 px).
- Un enfant parti dans un onglet ne laisse plus de place fantôme : ses parents se centrent sur les enfants
  dessinés.
- Cinq archétypes mis à jour : les parents du conjoint collés au-dessus de lui.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-20 10:20:40 +02:00
IliesAllaliandClaude Fable 5.1 a562ad9c6e placement : une belle-famille collée ne grimpe pas au-dessus des parents du conjoint
Avec des ancêtres plus haut que les parents, elle montait dans les rangées des aînés de la lignée et s'y
glissait entre deux frères (Marc et Sophie entre Anaïs et Germaine). Les parents restent au-dessus du
conjoint, le reste devient un onglet, sauf les toutes petites familles (FAMILY_INLINE_MAX = 6 au-delà des
parents) qui restent dessinées. asc-23 : croisements 15 → 0, fratries coupées 7 → 1 ; arbre réel 1 → 0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-20 10:08:09 +02:00
IliesAllaliandClaude Fable 5.1 10815498b7 placement : un enfant au bord d'un large bloc de descendants va du côté où son bloc déborde
Les couples d'une fratrie restent groupés sous leurs parents, le gros bloc part au-delà. asc-23 :
croisements 21 → 15, fratries coupées 17 → 7 ; desc-3 : 47 → 40 ; Antoine 1787 à 91 px de ses parents
au lieu de 2 600.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-20 00:03:34 +02:00
IliesAllaliandClaude Fable 5.1 33718470ee belle-famille qui ne tient pas au bout : parents seuls au-dessus du conjoint, le reste en onglet ; pastille au-dessus du portrait
- Règle du bout : seule une belle-famille au bout de la fratrie est dessinée entière. Un enfant marié au
  milieu de sa fratrie est rebâti avec les seuls parents de son conjoint au-dessus de lui ; frères, sœurs et
  ancêtres du conjoint deviennent un onglet dont ces parents sont les charnières.
- Même réduction pour une belle-famille que la pose pousse à plus de FAMILY_FIT_PX (480 px) de son enfant.
- Le badge « Famille X · n › » devient une pastille au-dessus du portrait, à la place des parents, dessinée
  après portraits et souvenirs (l'orbite évite le haut) : plus rien ne passe dessus.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-19 23:43:14 +02:00
IliesAllaliandClaude Fable 5.1 3df9abe63f barre d'onglets fondue aux bords, fil croisant la rangée des parents = pastille, le sang garde ses chaînes
- La barre d'onglets signale par un fondu le côté qui défile encore.
- Un fil de famille reliée deux fois qui passerait sous d'autres couples de la rangée des parents devient
  une pastille, comme celui qui passerait devant la rangée de l'enfant (archétype cousins mis à jour).
- Une belle-famille en construction ne s'approprie pas une chaîne du sang de la référence dont les parents
  sont ailleurs : elle ira sous eux.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-19 23:30:23 +02:00
IliesAllaliandClaude Fable 5.1 e23618806b placement : le côté des belles-familles se décide par le centre de la famille dessinée, plus par la référence
Au bord (dernière génération, charnière d'un onglet), la référence envoyait toutes les belles-familles
du même côté. Onglet Lecarpentier : croisements 3 → 1, fratries coupées 2 → 1.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-19 22:59:31 +02:00
IliesAllaliandClaude Fable 5.1 65049658a1 onglets : un onglet = la famille cachée, liste complète dès le chargement, réglage dans les paramètres
- Un onglet est exactement le bloc d'une grande belle-famille (plus les conjoints charnières), posé avec
  le conjoint comme référence : plus de descendants de la famille de départ dedans.
- La liste des onglets est calculée de proche en proche au chargement (familyTabsOf) : toutes les familles
  sont dans la barre dès le début, chaque personne dans un seul onglet, les charnières des deux côtés.
- Un second conjoint de la même famille renvoie au même onglet ; les familles emboîtées restent dans
  l'onglet qui les contient ; chaque charnière porte un badge de retour.
- La bascule « Tout l'arbre / Par famille » quitte la barre : case à cocher dans Paramètres de l'arbre,
  réglage du navigateur (parenthese.familyTabs).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-19 22:40:59 +02:00
IliesAllaliandClaude Fable 5.1 8637d7f323 familles : bascule « Tout l'arbre / Par famille », barre abaissée en mode modification, cadrage téléphone
- Deux modes au choix du spectateur, gardés dans son navigateur : « Par famille » (onglets) et « Tout
  l'arbre » (une belle-famille trop large reste dessinée d'un tenant). La bascule est en tête de la barre.
- En mode modification, la barre laisse la place au message du haut : moitié gauche sur grand écran,
  sous le message sur téléphone.
- Sur un écran étroit, la personne de départ s'ouvre plus bas, la barre ne couvre plus ses parents.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-19 22:19:48 +02:00
IliesAllaliandClaude Fable 5.1 2fab1edbef onglets : l'onglet actif se met en vue dans la barre (téléphone)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-19 22:12:45 +02:00
IliesAllaliandClaude Fable 5.1 ef46733329 onglets de familles : une belle-famille trop large devient son propre arbre
- Une belle-famille plus large qu'un écran (FAMILY_TAB_PX) n'est plus dessinée dans l'arbre : elle devient
  un onglet, le même arbre recalculé avec le conjoint comme référence. Ses personnes sortent du placement,
  le fil de ses parents vers le conjoint n'est pas dessiné.
- Le conjoint porte un badge « Famille X · n › » sous ses dates, un clic ouvre l'onglet.
- Barre d'onglets en haut à gauche : la famille de départ d'abord, puis chaque famille rencontrée, sans
  doublon (une famille déjà en onglet se reconnaît à sa personne de référence parmi les membres).
- Retrait des clusters, cartes de famille, échos, bandeau et carte des familles.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-19 21:50:40 +02:00
IliesAllaliandClaude Fable 5.1 c29610ee14 placement(global): l'arbre s'ouvre à partir de la référence
- Toute belle-famille, grosse (cluster) ou petite, part du côté opposé à la personne de référence : à droite
  pour un conjoint à sa droite, à gauche sinon. Passes successives jusqu'à stabilité (4 au plus).
- Dans une belle-famille, la branche qui mène au conjoint longe, à tous les niveaux, le bord qui regarde
  la famille principale : la carte de la famille principale est au bon bord du cluster.
- Les fratries ne sont plus dessinées en miroir pour ça : seul le bloc du conjoint change de place.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-19 18:43:07 +02:00
IliesAllaliandClaude Fable 5.1 77eef8e8a0 placement(remariage): l'enfant de la lignée reste près de sa fratrie, la carte au-dessus du conjoint
- Sur une chaîne de remariage où l'enfant de la lignée est à un bout, toute la suite de conjoints (ex-époux
  compris) part du côté de la belle-famille et l'enfant reste du côté de ses frères et sœurs.
- La rangée des parents n'est plus réservée que sur la moitié de l'enfant : la carte de famille peut se
  poser juste au-dessus du conjoint.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-19 18:29:39 +02:00
IliesAllaliandClaude Fable 5.1 e2af4fb580 placement(familles): côté du conjoint, carte au plus près, bandeau et carte des familles sans fondu
- Un cluster se range du côté où son conjoint se trouve dans la lignée principale (seconde passe si la
  première construction l'avait mis de l'autre côté) ; le couple se tourne vers sa famille.
- La carte de famille se pose au plus près au-dessus du conjoint, en préférant le côté de sa famille.
- La carte inverse réserve sa place sous l'écho du conjoint dans le cluster (plus de chevauchement).
- La reconstruction d'un cluster ne laisse aucune trace (clusters imbriqués, chaînes posées, arêtes
  externes) : 0 chevauchement sur l'arbre réel, 8 à 0 sur asc-3.
- Bandeau à l'écran avec le nom et la taille de la famille où l'on est.
- Carte des familles : bascule nette sous une échelle, sans fondu ni glissement ; famille principale au
  milieu, familles par alliance en colonne de leur côté, traits de bloc à bloc avec les prénoms du mariage.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-19 17:54:20 +02:00
IliesAllaliandClaude Fable 5.1 c3d0ac30e4 Carte des familles v2 : blocs calculés par le placement, repli net, traits de bloc à bloc
Retours de Patch : pas d'animation, familles rangées comme sur le schéma, lien
plus naturel, bloc calculé dans l'arbre et non dessiné par-dessus.

- Le placement donne à chaque famille sa place réelle et sa place sur la
  carte (autour de la famille principale, en coordonnées de l'arbre).
- Passé le seuil de dézoom, une famille par alliance se replie net en son
  bloc, qui glisse de sa place réelle vers sa place sur la carte à mesure
  qu'on dézoome ; plus bas, la famille principale se replie aussi. Plus de
  fondu, plus de clic sur les blocs.
- Les traits entre familles vont de bord à bord, avec les deux prénoms du
  mariage. Le pont d'une carte de famille est un fil plein.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-19 17:19:15 +02:00
IliesAllaliandClaude Fable 5.1 71c8e1a93d Carte des familles : au dézoom, chaque famille devient une tuile
Maquette J retenue par Patch le 19/10 (« quand on dézoome pas mal, une
famille devient un gros bloc »). Entre les échelles 0,3 et 0,2 l'arbre
s'efface et les tuiles apparaissent : une par famille (la lignée principale
et chaque cluster), de taille constante à l'écran, avec le nom, six
portraits et le nombre de personnes ; un trait par mariage entre familles,
avec le prénom de la personne qui relie. Les tuiles se rangent autour de la
famille principale à distance d'écran fixe, quel que soit l'éloignement réel
des familles sur la toile. Un clic sur une tuile s'approche de la famille
(même mécanique que les pastilles). Le placement expose `result.families`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-19 17:11:22 +02:00
IliesAllaliandClaude Fable 5.1 fb02139929 Placement : carte de famille et pont, les grosses belles-familles en cluster
Patch a écarté le plan arrière (« beaucoup trop d'infos, 150 personnes en
plus, illisible ») et retenu la maquette A. Une belle-famille plus large que
deux écrans devient un cluster dessiné à part, sur les mêmes rangées, par le
même placement. À sa place, au-dessus du conjoint, une carte de famille
(nom, quatre portraits, nombre de personnes) reliée à lui par un pont en
pointillé. Dans le cluster, le conjoint apparaît dans sa fratrie (écho de
la même personne) avec, sous lui, la carte inverse de la lignée qu'il a
rejointe. Un clic sur une carte mène de l'autre côté (même mécanique que
les pastilles). Le fil des parents vers le conjoint du plan principal est
masqué, il va à l'écho.

Nouveau type de nœud « family » dans le placement et le rendu, deux
constantes de carte, deux textes anglais. Le plan arrière et son calque
sont retirés.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-19 16:57:01 +02:00
IliesAllaliandClaude Fable 5.1 5a069c25c5 Placement : prototype des deux plans, les grosses belles-familles en arrière-plan
Maquette F retenue par Patch le 19/10 au soir. Une belle-famille plus large
que deux écrans ne se colle plus devant : elle passe sur un plan arrière,
dessinée par le même placement, à l'échelle 0,55, ancrée pour que la place
fantôme de son conjoint coïncide avec lui (ses parents juste au-dessus de
lui, en retrait). Le plan avant garde ses rangées compactes.

Rendu : le plan arrière est dessiné d'abord sur un calque (couples, fils,
nœuds à leur échelle), composé sous le plan avant à 42 % avec un léger flou.
Le rayon des nœuds, la hauteur des lignes de fratrie et le culling suivent
l'échelle du nœud. Les filiations du plan arrière et le fil des parents
arrière vers leur enfant avant ne comptent ni pour les renvois ni pour les
niveaux de lignes.

Banc : le plan avant seul est mesuré, le nombre de personnes en arrière est
compté. Famille réelle de 385 : 202 personnes derrière, plan avant à 0
croisement et 7 fils de plus d'un écran (contre 9 et 15). Prototype : pas
encore de survol, d'échange des plans ni de masquage sous les portraits.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-19 16:47:14 +02:00
IliesAllaliandClaude Fable 5.1 b4df1a6b69 Placement : belle-famille à côté de son enfant, jamais au-dessus de la place des parents
Retours de Patch sur l'arbre réel (385 personnes) : de longs fils et des
parents séparés de leurs enfants.

- La plus grande lignée est construite d'abord (sinon une petite branche
  aspirait toute la lignée principale comme « famille du conjoint »).
- Pour remonter au sommet d'une famille, on passe par le sang de la
  référence quand un membre en est : on retombe sur la lignée principale,
  rien à attacher, la seconde filiation reste un renvoi.
- Une famille reliée deux fois dont le fil devrait traverser plus d'un écran
  devient une pastille.
- La belle-famille change de côté si l'autre côté la rapproche d'un écran
  ou plus ; le couple se retourne avec elle.
- Si une belle-famille est poussée loin par les descendants du couple, le
  couple se décale au bord de sa rangée d'enfants, de son côté.
- Au-dessus de chaque couple, la place de ses propres parents est réservée :
  les parents du conjoint se posent à côté, pas dessus.
- Les parents qui évitent un obstacle sur leur rangée restent au-dessus de
  leurs enfants plutôt que de sauter de l'autre côté.

Témoins des 14 archétypes relus. Banc : arbre réel, pastilles 0,
croisements 9 ; arbre fictif de 265, pastilles 0, croisements 1.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-19 16:05:06 +02:00
IliesAllaliandClaude Fable 5.1 6950a9c21d Placement : plus jamais d'îlot, belle-famille posée depuis sa place idéale
Retour de Patch sur un arbre de 265 personnes : les familles « posées à
part » (trop loin pour être collées) faisaient une rangée d'îlots reliés
par des pastilles. Plus aucune famille n'est posée à part : une famille
collée garde son fil, aussi long soit-il.

La belle-famille part de sa place idéale (parents juste au-dessus de
l'enfant) et est poussée vers l'extérieur : sur les rangées de l'enfant et
en dessous (sa fratrie et leurs descendants), au-delà de tout ce qui est
déjà posé, jamais sous la ligne d'une fratrie ; sur les rangées au-dessus
(ses ancêtres, de petits couples), au premier trou libre, à côté des autres
ancêtres. Plusieurs belles-familles du même côté s'emboîtent comme des
parenthèses. Une rangée de lignes de fratrie peut prendre cinq niveaux.

Banc : famille réelle de 385, pastilles 2 → 0 ; descendance fictive de
1 454, pastilles 17 → 0 ; les 14 archétypes passent leurs invariants.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-19 15:43:12 +02:00
IliesAllaliandClaude Fable 5.1 02177d93b3 Placement : remariage, chaque union au-dessus de ses propres enfants
Retour de Patch sur un arbre de 265 personnes : dans un remariage, la fille
de la seconde union partait au bout de la fratrie entière et la ligne de son
union passait au-dessus des enfants de la première.

- Les enfants de chaque union restent groupés dans l'ordre des unions ; une
  belle-famille va du côté de son union, jamais au-delà des demi-frères.
- La chaîne se cale pour que chaque union soit au plus près au-dessus de son
  groupe : on garde, parmi les alignements possibles, celui où les lignes
  débordent le moins au-dessus des autres groupes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-19 15:30:09 +02:00
IliesAllaliandClaude Fable 5.1 c0d057a675 Placement : la belle-famille au bout de la fratrie, l'enfant marié avec elle
Retours de Patch sur un arbre de 102 personnes : une famille de conjoint se
glissait dans un trou du contour entre deux frères, et celle d'un enfant du
milieu se posait entre lui et ses frères, sa ligne passant au-dessus d'eux.

- La famille d'un conjoint est construite par la chaîne de l'enfant puis
  posée par le bloc du dessus, au-delà de toute l'étendue de la fratrie,
  jamais dans un trou.
- L'enfant dont le conjoint amène sa famille va au bout de sa fratrie, de
  son côté (l'ordre de naissance s'efface pour lui, choix de Patch), et sa
  belle-famille se colle à lui sans survoler personne.
- Le côté est hérité de la lignée : toutes les belles-familles d'une branche
  vont du côté où la branche est au bord de l'arbre.
- Le couple ne se tourne vers la belle-famille que si elle est vraiment
  attachée (un cousin marié garde son orientation naturelle).
- Le fil d'une famille collée est toujours dessiné ; pastille seulement pour
  une famille posée à part.
- Niveaux des lignes de fratrie : une ligne qui en contient une autre passe
  au-dessus ; entre deux lignes qui se chevauchent, celle dont l'union est à
  l'extérieur passe au-dessus (son fil n'entre qu'une fois, à l'enfant collé).

Banc : sur l'arbre de 102 personnes, pastilles 2 → 0 et croisements 3 → 0 ;
famille réelle de 385, pastilles 4 → 2 ; témoins des archétypes relus.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-19 15:22:31 +02:00
IliesAllaliandClaude Fable 5.1 e4ee286920 Placement : un seul arbre, la famille d'un conjoint se colle à lui
Avant, la famille d'un conjoint (ses parents, sa fratrie, ses ancêtres)
était posée au premier créneau libre, souvent au bout de l'arbre, et la
filiation devenait une pastille « Voir ». L'arbre se lisait comme plusieurs
arbres reliés par des pastilles.

Maintenant chaque famille de conjoint est construite entière depuis son
ancêtre le plus haut et collée au conjoint, de son côté, en poussant les
voisins pour faire la place. Le conjoint est tourné vers l'extérieur de sa
fratrie ; sa propre fratrie est dessinée en miroir s'il faut qu'il finisse
au bout, contre le couple ; un enfant déjà dessiné ailleurs garde une place
fantôme dans l'ordre de sa fratrie pour le centrage. Les lignées de la
personne de référence sont dessinées d'abord (le sang gagne) ; une famille
reliée deux fois (cousins, deux grandes lignées) n'est dessinée qu'une fois,
l'autre filiation reste une pastille si son fil croiserait une famille ; une
famille qu'il faudrait pousser de plus d'un écran est posée à part. L'ordre
des lignées ne dépend plus des effectifs (rangée, année, identifiant).

Les lignes de fratrie s'emboîtent : la ligne d'une famille qui en entoure
une autre passe au-dessus (_railLift posé par le placement, plus de hachage),
plus aucune superposition.

Mesuré par le banc (tools/layout-bench) : pastilles 4 → 2 sur une famille
réelle de 385 personnes, 78 → 12 sur une descendance fictive de 1 454 ;
les 14 archétypes passent leurs invariants, Marc et Nadia et l'issue #67
sans aucune pastille ni croisement.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-19 14:47:32 +02:00
IliesAllaliandClaude Fable 5.1 1664fc0771 Témoins des archétypes toujours en LF (comparés octet pour octet par vitest)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-19 14:14:47 +02:00
IliesAllaliandClaude Fable 5.1 3be95b1a0f Banc du placement v2 : invariants, défauts sur le tracé réel, archétypes en texte, stabilité
Le banc mesure maintenant le code de production (computeBlockLayout), sans
écran, en quatre étages lus dans l'ordre et jamais additionnés :

- invariants qui doivent valoir zéro (chevauchement, couple non adjacent,
  fratrie coupée ou hors de l'ordre choisi, rangée différente de la génération
  de parenté recalculée ici, années de naissance qui redescendent) ;
- défauts comptés sur la géométrie vraiment dessinée (renvois, lignes de
  fratrie au-dessus d'un portrait étranger, croisements, lignes et foyers plus
  larges qu'un écran, proches de la référence visibles dans un écran) ;
- quatorze archétypes (ordre d'une fratrie, issue #67, branche courte, Marc et
  Nadia, petit conjoint, fratrie de douze, remariage, cousins, oncle et nièce,
  parent seul, adoption, deux familles, belle-famille, trois familles
  enchaînées) rendus en texte, témoins dans __snapshots__/archetypes, testés
  par vitest ; deux échecs connus (belle-famille, trois-belles-familles) en
  attendant la règle « le sang de la référence garde son enfant » ;
- stabilité : retirer une feuille ne devrait déplacer que son foyer.

Les modèles ELK écartés en septembre, elk-common et l'ancienne mesure sur
segments droits sont supprimés, avec la dépendance elkjs. Le générateur
d'arbres fictifs (gen-fr.mjs) entre dans le dépôt ; les arbres réels restent
dans fixtures/, ignoré par git.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-19 14:14:03 +02:00
IliesAllaliandClaude Fable 5.1 d1e87d0eec Placement : rangées = génération de parenté depuis la personne de référence
La génération était comptée depuis le haut de chaque branche (profondeur sous
l'ancêtre le plus ancien documenté). Une branche courte mariée dans une branche
longue n'était tirée vers le bas qu'à une seule personne : ses parents, frères et
cousins restaient en haut, 3 à 6 rangées au-dessus de leur vraie génération
(26 % des personnes sur un arbre de 385, 35 % sur une descendance fictive de
1 454). L'issue #67 en était le symptôme en petit.

Les conjoints sont soudés, puis chaque foyer est visité en largeur depuis le
foyer de la personne de référence (rootPersonId, sinon la personne la plus
entourée) : parents une rangée au-dessus, enfants une rangée en dessous. Une
filiation en conflit (cousins de degrés différents, oncle et nièce) est comptée
dans generationConflicts et dessinée telle quelle. Une famille non reliée est
calée par ses années de naissance, sinon posée en haut.

Vérifié sans écran : sur les arbres de test, rangée dessinée = génération de
parenté pour 100 % des personnes hors conflits, et les médianes des années de
naissance montent de 25 à 35 ans par rangée. Sur le tracé réel, croisements
51 → 22 (descendance fictive), 129 → 25 (pres2020), 362 → 115 (royal92).

Closes #67

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-19 14:01:27 +02:00
IliesAllali ae8b473a25 Merge remote-tracking branch 'origin/main' into fix/generation-parente 2026-10-19 13:50:56 +02:00
IliesAllali c3bf4d2f64 Merge pull request #106 from IliesAllali/feat/fiche-glisser-fermer
Fiche : fermer en glissant vers le bas sur téléphone
2026-10-19 13:12:32 +02:00
IliesAllali 5adc48ba27 Merge pull request #110 from IliesAllali/fix/recherche-sous-bandeau
Recherche : résultats touchables en mode modification sur téléphone
2026-10-19 12:53:39 +02:00
IliesAllaliandClaude Opus 5.5 1ceec79d4f fix(recherche): resultats touchables en mode modification sur telephone
Sur telephone, la banniere « Mettre a jour l'arbre » descend sous le champ de
recherche et passait devant ses resultats : on ne pouvait plus toucher une
personne trouvee. La recherche ouverte passe devant la banniere et le bouton
« Creer mon arbre », les panneaux d'ajout restent devant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 12:51:00 +02:00
IliesAllali a88d06c513 Merge remote-tracking branch 'origin/main' into feat/fiche-glisser-fermer 2026-10-19 12:44:06 +02:00
IliesAllali 4483c88185 Merge pull request #108 from IliesAllali/feat/recadrage-forme
Recadrage : choisir la forme du cadre
2026-10-19 12:43:28 +02:00
IliesAllali fcd8ed51af Merge pull request #109 from IliesAllali/feat/plus-photo-direct
feat(arbre): clic sur « + photo » lance directement l'ajout de la photo
2026-10-19 12:36:35 +02:00
IliesAllaliandClaude Haiku 5.5 a2ce3117da feat(arbre): clic sur « + photo » lance directement l'ajout de la photo
Sur l'arbre, un clic ou un tap sur le cadre « + photo » (portrait vide,
propriétaire) ouvre le sélecteur de fichiers tout de suite, dans le geste
de l'utilisateur, puis la fenêtre de recadrage. La fiche s'ouvre aussi.

- renderers : zones « + photo » enregistrées au dessin, comme les pastilles de renvoi
- hook de clic : la zone passe avant la sélection de la personne
- administrateur avec Immich configuré en modification : le menu des sources
  s'ouvre dans la fiche, comme le badge
- PersonCard : l'effet qui lit la config Immich ne dépend plus de l'identité
  de onImmichChange (recréée à chaque rendu, il refermait le menu)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-19 12:33:30 +02:00
IliesAllaliandClaude Opus 5.5 92c1f25efb feat(recadrage): choisir la forme du cadre (rond, carre, polaroid, vertical, horizontal)
Le cadre suivait le format de la photo : une photo couchee donnait toujours un
cadre couche. Le recadrage propose maintenant les cinq formes de l'arbre, en
dessin, et la forme choisie est gardee avec le cadrage (avatarCrop.shape, sans
migration). L'arbre la respecte ; sans choix, rien ne change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 12:28:39 +02:00
IliesAllali 6c787fb0b8 Merge pull request #107 from IliesAllali/fix/menu-compte-arbre-courant
Menu du compte : l'arbre en cours n'est plus grisé
2026-10-19 11:12:26 +02:00
IliesAllaliandClaude Haiku 5.5 eb3db5f7c5 Menu du compte : l'arbre en cours n'est plus grisé
Dans « Mes arbres », l'arbre en cours s'affichait grisé comme un élément désactivé. Il garde maintenant sa couleur normale, avec une coche terracotta, et reste non cliquable.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-19 11:09:04 +02:00
IliesAllaliandClaude Haiku 5.5 6faaefebee Fiche : fermer en glissant vers le bas sur téléphone
La feuille de la fiche (PersonCard) se ferme en glissant sa poignée ou son en-tête vers le bas, comme le menu de la barre. Le geste est factorisé : logique pure dans utils/sheetDrag.js, hook dans hooks/useSheetDrag.js, utilisé aussi par NavMenu (poignée, seuil de 70 px inchangé).

- Le geste part seulement de la poignée et de l'en-tête. Jamais d'un champ de saisie, d'un libellé ni pendant une sélection de texte. Le contenu des onglets défile comme avant.
- La feuille suit le doigt (transform), ne monte jamais au-delà de sa position de repos.
- Fermeture au-delà d'un tiers de la hauteur, ou sur un lâcher rapide vers le bas. Sinon retour court.
- La fermeture passe par le même chemin que la croix (enregistrement automatique compris). Si l'enregistrement est refusé, la fiche revient et affiche son erreur.
- Mode feuille seulement (PHONE_QUERY et largeur de la feuille CSS). Sur ordinateur, rien ne change.
- prefers-reduced-motion respecté.

Tests : logique du geste (zones, direction, seuil, vitesse) en vitest. Recette Playwright iPhone 13 émulé, gestes tactiles CDP : fermeture par la poignée, retour après un petit glisser, lâcher rapide, défilement du contenu sans fermer, pas de geste depuis un champ, enregistrement puis fermeture, ordinateur inchangé, croix.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-19 11:06:35 +02:00
IliesAllali 0d08b0f148 Merge pull request #100 from IliesAllali/feat/barre-c
Barre : une pilule à droite, menus Ajouter/Corriger et compte (variante C)
2026-10-19 10:43:10 +02:00
IliesAllali dfc73858af Merge remote-tracking branch 'origin/main' into feat/barre-c
# Conflicts:
#	frontend/src/components/navbar/ContextualNavbar.css
2026-10-19 10:27:03 +02:00
IliesAllali 86e1d959a7 Merge pull request #101 from IliesAllali/feat/enregistrement-auto
Fiche : enregistrement automatique en quittant
2026-10-19 10:23:06 +02:00
IliesAllaliandClaude Opus 5.5 a54bcf9f18 fix(placement): rangées stables, couples tournés vers leurs familles, fil libre malgré les conjoints de la fratrie
- Rangées : le calage des ancêtres sans parents tourne jusqu'à stabilité. Un enfant descendu par son
  conjoint fait descendre ses parents et sa fratrie, au lieu de pendre plusieurs rangées sous eux.
- Couples : quand les deux conjoints ont leurs parents dans l'arbre et sont chacun du côté de la famille
  de l'autre, on les intervertit (même place, l'union au milieu). Les deux fils ne se croisent plus.
- Fil : les conjoints des frères et sœurs ne comptent plus comme une autre famille dans le test du renvoi.
  Un petit conjoint collé à un frère faisait passer le fil en pastille « Voir les parents ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 10:21:23 +02:00
IliesAllali 29324252a7 Merge pull request #104 from IliesAllali/fix/barre-libelles-courts
Barre : plus de libellé en double sur ordinateur
2026-10-19 10:19:43 +02:00
IliesAllaliandClaude Opus 5.5 7fbf5beab0 fix(barre): le libellé court de la famille ne s'affiche plus sur ordinateur
Il n'était caché que dans le bloc téléphone : sur ordinateur on lisait « Ajouter un souvenir Souvenir ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 10:17:25 +02:00
Askrosm ae77105cd0 feat: import Immich memories with context and face search and large previews 2026-10-19 08:44:15 +02:00
IliesAllali 6fd76ff7a4 Merge pull request #102 from IliesAllali/fix/vie-sans-date
Onglet Vie : une union sans date à sa place logique
2026-10-19 01:24:34 +02:00
IliesAllaliandClaude Opus 5.5 68ac2fa8f8 fix(vie): une union sans date a sa place logique, plus apres le deces du conjoint
Un mariage sans date tombait tout en bas de l'onglet Vie, apres le deces du
conjoint, ce qui ne voulait rien dire. Il se place maintenant juste avant le
premier enfant du couple, sinon avant la fin de l'union ou le deces du
conjoint. Aucune annee n'est inventee : colonne vide et « date inconnue ».
Seul ce qui n'a aucun repere reste en bas, et tout moment sans date le dit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 01:22:07 +02:00
IliesAllaliandClaude Haiku 5.5 d4416ef2ee barre : partager, souvenir propriétaire, aide pour tous, nettoyage
- Rond du compte : « Partager l'arbre » ouvre la fenêtre « La famille
  participe » (propriétaire).
- « Propositions à relire » ouvre la même fenêtre et la fait défiler
  jusqu'à la section (ancre #contrib-propositions).
- « Un souvenir » disponible pour le propriétaire, même parcours que
  le membre (choix de la personne, puis souvenirs).
- Aide : HELP_URL passé à tous les rôles.
- Suppression de .pz-lang--nav (plus aucune référence).

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-19 01:11:05 +02:00
IliesAllaliandClaude Haiku 5.5 a25f263451 fiche: enregistrer automatiquement en quittant la fiche
Les modifications en cours d'une fiche ne sont plus perdues quand on
change de personne, ferme la fiche, sort du mode modification, ouvre
un souvenir, ajoute une personne ou envoie les contributions.

- La fiche enregistre par le même chemin que « Enregistrer » (onSave),
  pour la personne quittée. Un échec ou une validation la garde ouverte
  avec le message d'erreur.
- Invité en contribution : les changements vont dans le brouillon, puis
  partent avec « Envoyer ».
- Un petit message confirme l'enregistrement. Rien n'est enregistré
  quand rien n'a changé, ni en démo.
- Un second enregistrement pendant l'envoi reçoit la même promesse
  (utils/saveOnLeave.js), sans double appel.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-19 01:08:26 +02:00
IliesAllaliandClaude Haiku 5.5 7d123d66ef barre: une pilule à droite, menus « Ajouter / Corriger » et compte
Variante C validée : une seule pilule en bas à droite (+ et compte), le
zoom reste à gauche. Le « Plus » et le second crayon disparaissent.

- « + » : Ajouter (souvenir, personne, mot ou dessin) et Corriger
  (proposer des changements, modifier l'arbre, modifier la démo).
- Rond du compte : identité, propositions à relire (pastille), arbres,
  paramètres, aide et langue sur une ligne, déconnexion. Sans compte :
  se connecter, créer mon arbre, aide, langue.
- Téléphone (moins de 768 px ou pointeur grossier) : feuille qui monte
  du bas, voile, poignée glissable. Ordinateur : popover ancré à la pilule.
- Barre d'annotation mobile conservée ; « Ajouter » et « Plus » ne font
  plus qu'un bouton.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-19 01:06:15 +02:00
IliesAllali e98125b796 Merge pull request #99 from IliesAllali/fix/test-limite-connexion
Test : délai de 20 s pour la limite des connexions
2026-10-19 00:22:08 +02:00
IliesAllali bc5c31f594 Merge pull request #97 from IliesAllali/fix/audit-1-1
Audit du 18/10 : import GEDCOM, transactions, arbre au repos, délai réseau, visites, sauvegardes
2026-10-19 00:21:23 +02:00
IliesAllaliandClaude Opus 5.5 b1a7a2f489 test: délai de 20 s pour le test de limite des connexions
Onze connexions avec vérification bcrypt dépassaient parfois 5 s sur la CI : quatre échecs le 18/10 sans lien avec les changements.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:19:58 +02:00
IliesAllali d44aae933b Merge pull request #96 from IliesAllali/feat/crayon-invite
Crayon pour les invités : proposer des changements
2026-10-19 00:14:30 +02:00
IliesAllali 14b5f0e99f Merge pull request #98 from IliesAllali/fix/infos-annee-seule
Fiche : un champ vidé pendant la saisie reste affiché
2026-10-19 00:11:33 +02:00
IliesAllaliandClaude Opus 5.5 6fa86d6716 fix(ci): le controle de restauration cree ses propres donnees
Le test de fumee supprime son arbre a la fin : le controle partait d'une base
sans arbre. Il cree maintenant un compte, un arbre et une personne par l'API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:11:28 +02:00
IliesAllaliandClaude Opus 5.5 85edf9ce2a fix(fiche): un champ vidé pendant la saisie reste affiché jusqu'à l'enregistrement
Avec l'onglet Infos qui n'affiche que les champs remplis (#93), vider un champ pour le retaper le faisait disparaître sous les doigts, et la frappe partait dans le champ suivant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:09:33 +02:00
IliesAllaliandClaude Haiku 5.5 6c5cacab0f fix(invite): libellés courts de la barre famille sous 420 px
Sous 420 px, « Ajouter un souvenir » et « Ajouter une personne » passaient
sur deux lignes : « Souvenir » et « Personne » tiennent sur une ligne. Le
libellé complet reste en aria-label et en title.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-19 00:08:21 +02:00
IliesAllaliandClaude Opus 5.5 217d503e20 fix(sauvegardes): restauration testee en CI, copie de la base avant migration en tout-en-un
- La CI joue les commandes de sauvegarde et de restauration du README sur une
  vraie installation compose (base videe, medias effaces) et verifie comptes,
  arbres, personnes, migrations et fichiers (tools/docker-restore-check.sh).
- README : restauration avec --if-exists, sinon pg_restore echoue sur une
  base vide. Sauvegarde avant chaque mise a jour, dite clairement.
- Image tout-en-un : copie de la base dans /data/backups avant d'appliquer une
  migration (5 gardees), demarrage arrete si la copie echoue.
- backup.sh : l'empreinte d'une archive de medias gardee disparaissait avec la
  rotation des fichiers d'empreintes, elle est recalculee.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:06:30 +02:00
IliesAllaliandClaude Opus 5.5 1504a1e124 fix(gedcom): import sans union d'une personne avec elle-meme ni boucle parent-enfant
Un fichier abime pouvait marier une personne a elle-meme ou faire de
quelqu'un son propre ancetre, ce que l'API refuse partout ailleurs. Le
controle se fait en memoire pendant l'import (les validateurs relisent tout
l'arbre a chaque lien), et la reponse compte les relations ecartees
(relationsSkipped).

Premier test de la route d'import.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:06:30 +02:00
IliesAllaliandClaude Opus 5.5 9c84bd0efc fix(visites): duree de conservation d'un an tenue, comptes supprimes anonymises
La purge des visites de plus d'un an ne passait qu'a l'ouverture de l'onglet
Visites par un administrateur : un arbre jamais consulte gardait tout. Elle
passe aussi a l'enregistrement d'une visite, au plus une fois par heure, pour
tous les arbres.

A la suppression d'un compte, ses visites d'autres arbres gardaient son
identifiant dans la cle du visiteur : elles restent comptees, sous une cle
anonyme.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:06:30 +02:00
IliesAllaliandClaude Opus 5.5 51232d8150 fix(app): delai maximal sur les appels a l'API
Un reseau mobile qui decroche laissait l'app en chargement pour toujours.
30 s par appel, 5 min pour un envoi de fichier (corps de plus de 100 Ko),
3 min pour un import GEDCOM. Le message dit que le serveur ne repond pas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:06:29 +02:00
IliesAllaliandClaude Opus 5.5 93307e41ab perf(arbre): la boucle d'animation ne recalcule plus rien au repos
Positions des noeuds et ensembles de recherche ou de chemin etaient recalcules
60 fois par seconde meme quand rien ne bougeait. Ils ne le sont plus que pour
une image a redessiner (ensemble de recherche construit a son changement).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:06:29 +02:00
IliesAllaliandClaude Opus 5.5 f20b58be32 fix(api): ecritures en plusieurs temps dans une transaction
Une erreur au milieu laissait un etat partiel, et un nouvel essai du client
recreait des doublons.

- Lot d'annotations : creations, modifications, suppressions et trace.
- Moments : session de contribution, moment, souvenirs rattaches et trace ;
  modification et suppression d'un moment avec ses souvenirs.
- Reordonnancement des souvenirs d'une personne.
- La famille ne rattache a son moment que des souvenirs libres : elle ne
  detache plus un souvenir du moment d'un administrateur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:06:29 +02:00
IliesAllaliandClaude Opus 5.5 82f888b1dc perf(gedcom): notes et sources partagees lues une fois par import
Un fichier dont chaque fiche cite une note ou une source enorme faisait relire
ce texte pour chaque personne : environ 35 min de calcul pour 20 000 fiches,
0,1 s maintenant (memo par noeud, pour la duree de l'import).

- La lecture des medias ne descend que dans les branches qui menent a un
  fichier, avec un budget par personne et une profondeur bornee.
- Les niveaux au-dela de 99 (limite de la norme) sont ignores : une chaine de
  sources faisait deborder la pile.
- Les notes ne sont calculees que pour une personne creee, pas reutilisee.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:06:29 +02:00
IliesAllali 721c8111f2 Merge origin/main dans feat/crayon-invite 2026-10-19 00:05:31 +02:00
IliesAllali edbbf7b163 Merge pull request #95 from IliesAllali/feat/recadrage-portrait
Recadrage du portrait, photo entière conservée
2026-10-19 00:02:56 +02:00
IliesAllaliandClaude Opus 5.5 3d5d9fea21 Merge main dans feat/recadrage-portrait (après #92, #93, #94)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:01:14 +02:00
IliesAllali b013f0f40c Merge pull request #92 from IliesAllali/fix/fiche-entete-partage
Fiche, partage, landing et bannière de création
2026-10-18 23:57:45 +02:00
IliesAllaliandClaude Opus 5.5 53e4dc326a fix(fiche): portrait en modification à taille fixe, même avec une vraie photo
La hauteur venait du contenu : une photo réelle a une taille propre et le portrait grandissait jusqu'à écraser les champs nom. Taille calculée depuis la hauteur des deux champs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:56:01 +02:00
IliesAllaliandClaude Haiku 5.5 ac3cd6aec2 feat(invite): crayon pour proposer des changements
L'invité a le crayon du propriétaire dans la barre du bas, avant « Plus ».
Le bandeau du mode contribution dit « Vous proposez » (« Vous proposez des
changements » sur ordinateur), sur une seule ligne, avec « Envoyer ».
Le propriétaire ne voit aucun changement.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-18 23:55:29 +02:00
IliesAllali c6f62dc907 Merge remote-tracking branch 'origin/main' into fix/fiche-entete-partage 2026-10-18 23:52:23 +02:00
IliesAllali 6a9034cf98 Merge pull request #93 from IliesAllali/feat/fiche-pastilles-adoption
Fiche : pastilles, lieu de décès, adoption
2026-10-18 23:50:10 +02:00
IliesAllali c24be211e1 Merge pull request #94 from IliesAllali/feat/modifier-souvenir
Modifier un souvenir (administrateur)
2026-10-18 23:47:50 +02:00
IliesAllali 178a932f0d Merge pull request #89 from IliesAllali/fix/nom-facultatif
Nom de famille facultatif, case du prénom inconnu
2026-10-18 23:44:31 +02:00
IliesAllaliandClaude Opus 5.5 942310d30f Merge main dans fix/nom-facultatif (après #91)
Conflit dans PersonCard : hasChanges vient maintenant de l'état de saisie rattaché à la personne (#91), lastNameMissing est gardé.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:42:28 +02:00
IliesAllali 4d03864824 Merge pull request #91 from IliesAllali/fix/fiche-saisie-par-personne
Fiche : une date en cours ne passe plus sur la personne suivante
2026-10-18 23:37:49 +02:00
IliesAllaliandClaude Haiku 5.5 9dce288678 fix: recadrage, sans sur-titre en capitales
Le titre « Recadrer la photo » se suffit à lui-même : on retire le sur-titre « PORTRAIT ».

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-18 23:34:59 +02:00
IliesAllaliandClaude Haiku 5.5 63f5b63f12 feat: recadrage du portrait, photo entière conservée
Le cadrage (centre et zoom) est stocké dans Person.avatarCrop (JSON, nullable),
l'original reste sur disque et le cadrage s'applique à l'affichage (canevas et fiche).
Route PUT /trees/:id/persons/:personId/avatar/crop, réservée aux administrateurs.
La suppression et le nouvel envoi remettent le cadrage à zéro.

Migration additive (colonne nullable, sans défaut), testée sur base vide et sur base avec données.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-18 23:28:34 +02:00
IliesAllaliandClaude Haiku 5.5 a36b287a32 fiche : aperçu du choix d'adoption en attente, titres de section en casse normale
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-18 22:43:29 +02:00
IliesAllaliandClaude Haiku 5.5 e75ff5e040 fix: portrait à la hauteur des champs en modification, symbole du logo sur téléphone
- Fiche en modification : le portrait prend la hauteur des champs Prénom et Nom (écart compris), carré (align-self stretch + aspect-ratio 1)
- Landing, téléphone (≤ 820 px) : symbole du logo seul, nom accessible « Parenthèse, accueil » conservé

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-18 22:43:19 +02:00
IliesAllaliandClaude Haiku 5.5 b09f915662 feat(souvenir): modifier le titre, le moment et la citation
Un administrateur, en mode modification, peut modifier un souvenir depuis la visionneuse : crayon à droite du titre (visible au survol sur ordinateur, toujours visible sur écran tactile), formulaire Titre et Moment de sa vie, texte de la citation réécrit dans caption et dans le fichier citation.txt.

Route PATCH /trees/:id/persons/:personId/media/:mediaId, réservée aux administrateurs. Pas de migration : la date vient du moment de vie rattaché. La légende séparée n'existe pas en base et n'est pas ajoutée.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-18 22:36:21 +02:00
IliesAllaliandClaude Haiku 5.5 8e1b244a4a fix: en-tête de la fiche en modification et bouton de connexion de la landing
- Fiche : pastille Édition retirée ; case « Je ne connais pas son prénom » sur une ligne pleine largeur ; badge caméra « Changer la photo » sur le portrait (même choix de source)
- Landing : « Se connecter » en bouton blanc sans contour, icône de profil ; sur téléphone, icône ronde seule avec aria-label

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-18 22:33:19 +02:00
IliesAllaliandClaude Haiku 5.5 cde9c3d44d fiche : infos en pastilles, lieu de décès, adoption (arbre, liens, ajout)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-18 22:31:15 +02:00
IliesAllali 0f6ce774e7 Merge origin/fix/nom-facultatif into fix/fiche-entete-partage 2026-10-18 22:26:48 +02:00
IliesAllaliandClaude Haiku 5.5 97b92e3f84 fix: fiche, partage, landing et bannière de création
- Fiche : nom, puis une ligne « surnom · repères de vie · âge » (point médian, sans pastille d'âge)
- Portrait vide : icône seule, avec aria-label et title « Ajouter une photo »
- Partage : message replié (copie directe, « Voir ou modifier le message »), sur-titre retiré
- Landing : « Se connecter » en bouton secondaire à gauche de « Créer mon arbre », masqué sur téléphone
- Bannière « Votre arbre est créé. » dans la DA (carte pz, Newsreader, bouton pilule)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-18 22:20:20 +02:00
IliesAllaliandClaude Opus 5.5 c2ad11df2e fix(fiche): une date en cours de saisie ne passe plus sur la personne suivante
En changeant de fiche sans enregistrer, le premier rendu de la nouvelle fiche voyait encore les
modifications de l'ancienne. DateField initialise son texte à ce moment-là et le gardait : la date
tapée pour une personne s'affichait sur l'autre, et partait avec elle si on enregistrait.
Les modifications en cours sont maintenant rattachées à la personne qu'elles concernent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:26:48 +02:00
IliesAllali f0196eec54 Merge pull request #90 from IliesAllali/fix/aplomb-sans-decrochage
Fil : plus de marche pour un enfant presque sous l'union
2026-10-18 21:18:15 +02:00
IliesAllaliandClaude Opus 5.5 c43fdbff4f fix(fil): enfant presque à l'aplomb, un S doux depuis la barre au lieu d'une marche
Sous deux rayons d'écart, deux coins de 24 px ne tenaient pas : le fil faisait une petite marche.
Le tronc descend jusqu'à la barre et un S vertical au départ et à l'arrivée rejoint l'enfant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:16:05 +02:00
IliesAllaliandClaude Haiku 5.5 ab6564af41 fix: nom de famille facultatif, case du prénom inconnu sous le champ
- Nom de famille facultatif : une personne garde au moins un prénom ou un nom
  (API, contributions, fiche, formulaire). Contributions : seule une personne
  sans aucun nom est ignorée.
- Case « Je ne connais pas son prénom » visible seulement si le prénom est vide
  (ou cochée), placée sous le champ Prénom.
- Panneau d'ajout : sans sur-titres en capitales.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-18 21:10:17 +02:00
IliesAllali efca97d9cd Merge pull request #87 from IliesAllali/fix/ajout-prenom-inconnu
Prénom inconnu, et fermer l'ajout au clic sur une personne
2026-10-18 20:54:11 +02:00
IliesAllali 1cf3dd0bc3 Merge pull request #88 from IliesAllali/feat/entree-accueil
Accueil à deux choix, porte unique et prénom du compte
2026-10-18 20:54:06 +02:00
IliesAllali 6bcbcb1dfd Merge pull request #83 from IliesAllali/fix/renvoi-fil-libre
Filiations : fil plein quand il ne croise pas une autre famille, un seul tronc par fratrie
2026-10-18 20:54:02 +02:00
IliesAllali af61f91511 Merge pull request #86 from IliesAllali/fix/entree-connexion
Connexion et arbre partagé, parcours plus clair
2026-10-18 20:53:58 +02:00
IliesAllali d0be3e9165 Merge pull request #85 from IliesAllali/feat/surnoms
Surnoms sous le nom, dans l'arbre et la fiche
2026-10-18 20:53:55 +02:00
IliesAllaliandClaude Opus 5.5 d234171abb docs: le lien de démo du README ouvre la démo, plus l'accueil
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:48:48 +02:00
IliesAllaliandClaude Haiku 5.5 92ceea6565 feat(entree): accueil à deux choix, porte unique, prénom du compte
- Backend : prénom du compte facultatif à l'inscription (colonne users.firstName,
  migration additive), renvoyé à l'inscription, à la connexion et sur GET /auth/me
- Accueil sans session : deux choix (J'ai reçu un lien, Se connecter) et
  Créer mon arbre en lien, à la place de la démo (variante B validée)
- Porte : mot de passe puis prénom facultatif pour un nouveau venu ; compte
  connecté non rattaché : bandeau avec son prénom, sans champ prénom, et
  « Ce n'est pas mon compte » (déconnexion, retour à la porte du même arbre)
- Sur-titre « Arbre partagé » retiré de la porte
- Compte déjà rattaché : inchangé, la racine ouvre l'arbre directement

Vérifié : vitest front 154/154, back 228/228 (sans .env local, le test CORS
attend localhost:5173), tsc back, eslint front, vite build, landing test et build.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-18 20:45:58 +02:00
IliesAllaliandClaude Haiku 5.5 7752b96e3d fix: prénom inconnu, et fermer l'ajout quand on clique une personne
- Prénom inconnu : case « Je ne connais pas son prénom » à l'ajout et en
  modification. Enregistré comme prénom vide, affiché « Prénom inconnu »
  en italique (arbre, fiche, recherche). Pas de migration.
- Export GEDCOM : « 1 NAME /Nom/ » sans prénom. Import : prénom absent
  = vide (au lieu de « ? »).
- Cliquer une personne pendant un ajout ferme le panneau d'ajout ; sa
  saisie reste en mémoire comme à la fermeture par la croix.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-18 20:41:37 +02:00
IliesAllaliandClaude Haiku 5.5 9d9e375f58 Filiations : accolade franche (forme A)
Remplace le virage quadratique par une accolade à coins arrondis de rayon fixe
(24 px) : un tronc depuis l'union, une barre de fratrie, puis une descente
vers chaque enfant. Un enfant à l'aplomb de l'union descend droit. Le chemin
surligné (sélection, survol) passe par la même géométrie que la fratrie.

Tests unitaires de traceSibship (un chemin et un stroke par fratrie, même
géométrie pour un enfant seul, pas de virage à l'aplomb).

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-18 20:26:34 +02:00
IliesAllaliandClaude Haiku 5.5 f1ecb3951c fix(entree): connexion visible, porte qui reconnaît le compte, libellés
- Démo : le bouton « Se connecter » ouvre la connexion, plus l'inscription
- Porte de l'arbre : quand la personne est connectée, un bandeau dit quel
  compte est ouvert et qu'il faut taper une fois le mot de passe reçu avec
  le lien. Il reste affiché au retour d'une connexion faite depuis la porte.
- Landing : lien « Se connecter » dans la navigation et le pied de page
  (vers ?account=login, sans source, le rendu au build n'a pas de window)
- Libellés de la porte et de l'inscription, en français et en anglais

Vérifié : vitest 154/154, eslint frontend, vite build, landing (test et
build). La landing n'a pas de configuration eslint dans le dépôt.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-18 20:05:18 +02:00
IliesAllaliandClaude Opus 5.5 adb2b930dd fix(surnoms): garder le surnom d'un arbre chargé depuis l'API
Le surnom était enregistré en base mais perdu au chargement de l'arbre :
mapRemotePersonToLocal ne le recopiait pas, donc ni l'arbre, ni la fiche,
ni la recherche ne le voyaient.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 19:51:19 +02:00
IliesAllaliandClaude Haiku 5.5 d58b7b779c Filiations : une seule trajectoire par fratrie
Chaque enfant avait son propre chemin depuis l'union, avec un rayon de virage
propre : sous une union nombreuse, 3 ou 4 brins se séparaient juste sous le
point d'union. Le tracé est maintenant un tronc unique, puis une barre de
fratrie qui distribue vers chaque enfant ; le rayon des virages est commun à
chaque côté, et tout part dans un seul stroke (pas de brins doublés en alpha).
Un enfant presque sous l'union descend de la barre, sans second brin depuis
l'union. Le chemin en surbrillance (path highlight) reste tracé enfant par enfant.

Vérifié à l'écran sur un arbre de test à 6 enfants (avant/après dans
_bmad-output/parenthese/renvoi/faisceau-*.png).

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-18 19:45:20 +02:00
IliesAllaliandClaude Haiku 5.5 ac5997659b feat(surnoms): surnom facultatif sous le nom, dans l'arbre et la fiche
- API : nickname accepté à la création, à la modification et dans les
  propositions de contribution (trim, vide = null, 120 caractères max)
- Mode direct et circuit des contributions : même validation, avant/après
- Édition et ajout : champ « Surnom » facultatif
- Arbre : surnom en italique sous le nom, coupé avec … au-delà de 150 px
- Recherche : trouve une personne par son surnom (libellé « Surnom »)
- Démo : surnoms sur Marie et Robert

Vérifié : lint, tests (front 159, back 224), builds (front, back tsc, landing).

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-18 19:34:09 +02:00
IliesAllaliandClaude Haiku 5.5 3d709ec015 Renvoi seulement si le fil croise une autre famille
Une filiation vers un enfant placé sous l'autre lignée devenait un renvoi
(pastilles) dès que l'écart union → enfant dépassait 360 px, même quand le fil
ne croisait personne. Le critère est maintenant géométrique : le fil file sur la
ligne de la fratrie entre l'aplomb de l'union et l'enfant ; il ne devient renvoi
que si une personne d'une autre famille se trouve sur cette ligne. Frères et
sœurs et conjoint(e)s de l'enfant ne comptent pas.

Tests : cas à fratrie voisine (fil conservé), et filCroiseAutreFamille sur des
nœuds synthétiques (autre famille, fratrie seule, conjoint, autre génération).

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-18 19:33:38 +02:00
IliesAllaliandClaude Opus 5.5 f8d4b4113d Mobile : parents séparés en pointillés, barre d'édition allégée, dates au clavier numérique (#82)
- Deux parents d'un même enfant sans couple enregistré sont reliés en pointillés au lieu
  d'avoir chacun un « Inconnu » (couple propre au dessin, rien en base) ; le tri de la
  fratrie le reconnaît.
- Barre d'édition mobile : X, « Ajouter » et « Plus » (dessin, texte, sticker, photo).
  Les outils s'ouvrent depuis « Plus » avec un retour, la corbeille n'apparaît que s'il y a
  quelque chose à effacer, l'outil actif en aplat. La barre ne passe plus sous le zoom.
- Bandeau d'édition : sur téléphone, seul le bouton reste, le texte se coupait.
- Dates : au-delà de 4 chiffres les barres se posent seules (12031950 → 12/03/1950), le
  message n'apparaît qu'en quittant le champ.
- Ajouter une personne : plus d'erreur « prénom » en quittant le champ vide, le bouton
  reste actif et le dit à l'appui.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 14:08:07 +02:00
IliesAllaliandClaude Opus 5.5 2dec945fa3 docs: CHANGELOG de la 1.1.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:56:35 +02:00
IliesAllali 9ccd5a2e30 Merge pull request #80 from IliesAllali/deces-sans-date
Dates : une année seule, et décédé(e) sans date (#77)
2026-10-17 13:30:02 +02:00
IliesAllaliandClaude Opus 5.5 1e8f8f923a feat(dates): une annee seule, et decede(e) sans date (issue #77)
- Champ de date de la fiche et du formulaire « Ajouter quelqu'un » : une annee seule
  (1950) ou une date complete (12/03/1950), une aide si le texte n'en est pas une.
  L'annee seule s'affiche « 1950 » et non plus « 1 janvier 1950 ».
- Statut « Decede(e) » sans date : persons.isDeceased (migration additive), « 1908–? »
  sous le portrait, « Date inconnue » dans la fiche, en bas de la frise. Import de
  « 1 DEAT Y », export en « 1 DEAT Y ». Une date de deces retire le drapeau.
- Parents : plus de « (null) » quand l'union n'a pas de type.

Closes #77

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:27:53 +02:00
IliesAllali 991a9625a2 Merge pull request #79 from IliesAllali/import-zip
Import d'un zip : le GEDCOM et ses photos en une fois
2026-10-17 12:54:50 +02:00
IliesAllali c881863fc1 Merge pull request #78 from IliesAllali/moments-contributeurs
Moments : fichiers de tout type, naissance et mariage, propositions de la famille
2026-10-17 12:52:34 +02:00
IliesAllaliandClaude Opus 5.5 5447965243 feat(import): importer un zip qui contient le GEDCOM et ses photos
Le zip est lu dans le navigateur, sans bibliotheque (table des matieres puis
DecompressionStream, fichier par fichier) : le .ged le plus haut s'importe, GEDZIP
compris, et les photos et actes passent directement a l'etape des visages, rapproches
comme un dossier et decompresses seulement a l'envoi. Messages clairs pour un zip sans
.ged, protege par mot de passe ou trop gros. Corrige aussi « citait 1 autres fichiers ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:40:32 +02:00
IliesAllaliandClaude Opus 5.5 8f866d1bd3 feat(vie): la famille propose des moments, et un moment porte photos, videos et actes
- Un moment peut porter plusieurs souvenirs de tout type (photo, video, enregistrement,
  acte en PDF) : media_items.lifeEventId, reprise de l'ancienne colonne mediaId.
- Naissance, mariage et deces se completent depuis la frise : documents, source, note
  (la date et le lieu restent dans Infos).
- La famille propose un moment par le lien de partage : il attend la relecture, avec
  ses fichiers dans la meme proposition, et apparait une fois accepte.
- Fiche : Ajouter un fichier, Choisir parmi ses souvenirs, prenom du contributeur,
  textes qui expliquent ce qui se passe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:29:23 +02:00
IliesAllali 5740582cde Merge pull request #76 from IliesAllali/frise-de-vie
Onglet Vie : la frise de vie, et ajouter un moment
2026-10-17 11:54:51 +02:00
IliesAllaliandClaude Opus 5.5 9d34796d11 feat(vie): ajouter, modifier et supprimer un moment depuis l'onglet Vie
Un bouton « Ajouter un moment » pour les administrateurs, une fiche courte (quoi, quand,
environ, ou, une photo), date exacte, fin, source et note sous « Plus de details ».
La photo devient un souvenir de la personne, rattache au moment (life_events.mediaId,
migration additive). Toucher un moment l'ouvre pour le modifier ou le supprimer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:38:35 +02:00
IliesAllaliandClaude Opus 5.5 cd147e9387 fix(vie): le fil s'arrete au dernier evenement
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:23:35 +02:00
IliesAllaliandClaude Opus 5.5 557956fedd feat(fiche): onglet Vie, la frise de vie d'une personne
Naissance, unions et deces en grands reperes (direction A2 choisie par Patch le 16/10),
evenements importes plus petits, famille proche tiree de l'arbre en retrait, sources
repliees. Evenements charges a l'ouverture de l'onglet via GET /persons/:id/life,
donnees propres a la demo pour Robert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:10:34 +02:00
IliesAllali 8372d3f29b Merge pull request #75 from IliesAllali/evenements-de-vie
Événements de vie : modèle, import et export GEDCOM
2026-10-16 22:33:25 +02:00
IliesAllaliandClaude Opus 5.5 f47ef8ecac fix(dates): une date corrigee a la main perd la precision importee
Sinon « vers 1879 » restait colle a la date saisie ensuite, et l'export l'aurait ecrite.
Personne, union, et voie contribution.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 22:29:21 +02:00
IliesAllaliandClaude Opus 5.5 f6affa81d6 feat(gedcom): export des evenements de vie, precisions de date, sources et adoption
L'export rend ce que l'import lit : dates qualifiees (ABT, EST, BET ... AND, FROM ... TO)
ou texte d'origine, lieu de deces, surnom, tous les evenements de la personne et du
couple avec lieu, notes et sources, lieu du mariage, PEDI adopted. Test aller-retour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 22:27:51 +02:00
IliesAllaliandClaude Opus 5.5 87c564fd3c API : frise de vie d'une personne
GET /trees/:id/persons/:personId/life rend la précision des dates, le lieu
de décès, le surnom, les unions avec leur lieu de mariage, et les
événements de la personne et de ses couples. Chargée à l'ouverture de la
fiche, pas dans le graphe. Les droits de lecture du graphe sont factorisés
(resolveReadAccess) et servent aux deux. Supprimer une personne supprime
aussi ses événements et ceux de ses unions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 22:20:30 +02:00
IliesAllaliandClaude Opus 5.5 e24c8c86d6 Événements de vie : table, migration, import GEDCOM
Nouvelle table life_events (baptême, résidence, métiers, études, service
militaire, inhumation, divorce, événements libres…) avec date, précision,
lieu, description, notes et sources. Personne : précision et borne des
dates de naissance et de décès, lieu de décès, surnom. Union : lieu et
précision du mariage. Naissance, décès et mariage gardent leur date et leur
lieu sur la personne ou l'union ; leur ligne d'événement ne porte que les
sources et les notes. Migration additive.

L'import lit tous ces événements (balises de la norme, variantes Gramps,
Heredis, RootsMagic), les adresses complètes, et les adoptions déclarées
par PEDI. Une personne ou une union déjà présente ne reçoit rien en double.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 22:16:45 +02:00
IliesAllali 4e900f1c30 Merge pull request #74 from IliesAllali/import-fiable
Import GEDCOM : encodage, vraies dates, toutes les notes
2026-10-16 22:14:06 +02:00
IliesAllaliandClaude Opus 5.5 dd738c483a Import GEDCOM : vraies dates, toutes les notes
Dates : « vers », « avant », « après », « entre … et … », dates juliennes,
calendrier républicain converti en grégorien, doubles années (1700/01).
« BET 1794 AND 1796 » ou « @#DFRENCH R@ 18 BRUM 8 » étaient perdues, « ABT
1900 » devient au moins 1900. La précision est déjà lue pour la suite.

Notes : notes partagées (« 1 NOTE @N1@ »), plusieurs notes, RTF nettoyé. Le
lecteur garde la valeur brute de chaque ligne : une note coupée en CONC se
recolle avec ses espaces (« pendant trente-cinq ans », plus « pendanttrente »).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 22:12:16 +02:00
IliesAllaliandClaude Opus 5.5 2d35bddf27 Import GEDCOM : le fichier est lu avec son vrai encodage
Le navigateur lisait tout en UTF-8. Les fichiers de Heredis PC (ANSI,
Windows-1252), des anciens logiciels (ANSEL) ou de Heredis Mac (MACINTOSH)
arrivaient avec des accents cassés (« é »). L'encodage est maintenant
détecté : marque d'octets, UTF-8 valide, puis la ligne « 1 CHAR », sinon
Windows-1252. Décodeur ANSEL inclus (diacritiques placés avant la lettre).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 22:08:10 +02:00
IliesAllali 7e7641dc19 Merge pull request #73 from IliesAllali/dependabot/npm_and_yarn/frontend/frontend-minor-patch-c705a229c1
Bump the frontend-minor-patch group across 1 directory with 6 updates
2026-10-16 20:38:08 +02:00
IliesAllali 6d78aa5d10 Merge pull request #72 from IliesAllali/dependabot/npm_and_yarn/backend/backend-minor-patch-e930112c77
Bump the backend-minor-patch group across 1 directory with 5 updates
2026-10-16 20:37:56 +02:00
IliesAllali 7488254cc9 Merge pull request #71 from IliesAllali/dependabot/npm_and_yarn/landing/landing-minor-patch-90bd4e03b8
Bump the landing-minor-patch group across 1 directory with 2 updates
2026-10-16 20:37:44 +02:00
IliesAllali 2db048aad6 Merge pull request #64 from IliesAllali/dependabot/github_actions/actions/upload-artifact-7.0.2
Bump actions/upload-artifact from 4.6.2 to 7.0.2
2026-10-16 20:36:18 +02:00
IliesAllali 69598ce116 Merge pull request #66 from IliesAllali/dependabot/github_actions/actions/setup-node-7.1.0
Bump actions/setup-node from 5.0.0 to 7.1.0
2026-10-16 20:34:09 +02:00
dependabot[bot] f36d59fcee Bump the frontend-minor-patch group across 1 directory with 6 updates
Bumps the frontend-minor-patch group with 6 updates in the /frontend directory:

| Package | From | To |
| --- | --- | --- |
| [@lucide/icons](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide) | `1.52.0` | `1.56.0` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.52.0` | `1.56.0` |
| [posthog-js](https://github.com/PostHog/posthog-js) | `1.438.0` | `1.438.9` |
| [postcss](https://github.com/postcss/postcss) | `8.5.29` | `8.5.30` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.3.3` | `8.3.4` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `5.0.3` | `5.0.4` |



Updates `@lucide/icons` from 1.52.0 to 1.56.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.56.0/packages/lucide)

Updates `lucide-react` from 1.52.0 to 1.56.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.56.0/packages/lucide-react)

Updates `posthog-js` from 1.438.0 to 1.438.9
- [Release notes](https://github.com/PostHog/posthog-js/releases)
- [Changelog](https://github.com/PostHog/posthog-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/PostHog/posthog-js/compare/posthog-js@1.438.0...posthog-js@1.438.9)

Updates `postcss` from 8.5.29 to 8.5.30
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.29...8.5.30)

Updates `vite` from 8.3.3 to 8.3.4
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.3.4/packages/vite)

Updates `vitest` from 5.0.3 to 5.0.4
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v5.0.4/packages/vitest)

---
updated-dependencies:
- dependency-name: "@lucide/icons"
  dependency-version: 1.56.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-minor-patch
- dependency-name: lucide-react
  dependency-version: 1.56.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-minor-patch
- dependency-name: posthog-js
  dependency-version: 1.438.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-minor-patch
- dependency-name: postcss
  dependency-version: 8.5.30
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-minor-patch
- dependency-name: vite
  dependency-version: 8.3.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-minor-patch
- dependency-name: vitest
  dependency-version: 5.0.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-16 18:32:55 +00:00
dependabot[bot] bd8ba8ffaa Bump the backend-minor-patch group across 1 directory with 5 updates
Bumps the backend-minor-patch group with 5 updates in the /backend directory:

| Package | From | To |
| --- | --- | --- |
| [dotenv](https://github.com/motdotla/dotenv) | `18.0.6` | `18.0.7` |
| [fastify](https://github.com/fastify/fastify) | `5.12.5` | `5.12.7` |
| [zod](https://github.com/colinhacks/zod) | `4.6.5` | `4.6.7` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `24.19.1` | `24.19.2` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `5.0.3` | `5.0.4` |



Updates `dotenv` from 18.0.6 to 18.0.7
- [Changelog](https://github.com/motdotla/dotenv/blob/master/CHANGELOG.md)
- [Commits](https://github.com/motdotla/dotenv/compare/v18.0.6...v18.0.7)

Updates `fastify` from 5.12.5 to 5.12.7
- [Release notes](https://github.com/fastify/fastify/releases)
- [Commits](https://github.com/fastify/fastify/compare/v5.12.5...v5.12.7)

Updates `zod` from 4.6.5 to 4.6.7
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v4.6.5...v4.6.7)

Updates `@types/node` from 24.19.1 to 24.19.2
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `vitest` from 5.0.3 to 5.0.4
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v5.0.4/packages/vitest)

---
updated-dependencies:
- dependency-name: dotenv
  dependency-version: 18.0.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: backend-minor-patch
- dependency-name: fastify
  dependency-version: 5.12.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: backend-minor-patch
- dependency-name: zod
  dependency-version: 4.6.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: backend-minor-patch
- dependency-name: "@types/node"
  dependency-version: 24.19.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: backend-minor-patch
- dependency-name: vitest
  dependency-version: 5.0.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: backend-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-16 18:32:50 +00:00
dependabot[bot] 265ae1d9f3 Bump the landing-minor-patch group across 1 directory with 2 updates
Bumps the landing-minor-patch group with 2 updates in the /landing directory: [posthog-js](https://github.com/PostHog/posthog-js) and [postcss](https://github.com/postcss/postcss).


Updates `posthog-js` from 1.438.0 to 1.438.9
- [Release notes](https://github.com/PostHog/posthog-js/releases)
- [Changelog](https://github.com/PostHog/posthog-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/PostHog/posthog-js/compare/posthog-js@1.438.0...posthog-js@1.438.9)

Updates `postcss` from 8.5.29 to 8.5.30
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.29...8.5.30)

---
updated-dependencies:
- dependency-name: posthog-js
  dependency-version: 1.438.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: landing-minor-patch
- dependency-name: postcss
  dependency-version: 8.5.30
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: landing-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-16 18:32:24 +00:00
dependabot[bot] 6a366416ef Bump actions/upload-artifact from 4.6.2 to 7.0.2
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.6.2 to 7.0.2.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/ea165f8d65b6e75b540449e92b4886f43607fa02...cf430e030ddbb5b0abf93d22962f4752f3646cd9)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: 7.0.2
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-16 18:31:54 +00:00
dependabot[bot] 62727658dc Bump actions/setup-node from 5.0.0 to 7.1.0
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 5.0.0 to 7.1.0.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/a0853c24544627f65ddf259abe73b1d18a591444...949feb2413d6458794dcd2491c4babbbce0c15c1)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: 7.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-16 18:31:51 +00:00
IliesAllali 1c065a8db4 Merge pull request #70 from IliesAllali/lieux-affichage
Fiche : lieu lisible pour les arbres déjà importés
2026-10-16 20:31:45 +02:00
IliesAllaliandClaude Opus 5.5 63d997b8cf Fiche : lieu lisible à l'affichage pour les arbres déjà importés
Les arbres importés de Heredis avant le nettoyage à l'import gardent des
lieux bruts (« Ville,code INSEE,département,… »). La fiche les affiche avec
la même règle qu'à l'import, sans toucher à la valeur enregistrée ni au
champ d'édition.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:29:58 +02:00
IliesAllali 5454549ab0 Merge pull request #69 from IliesAllali/gedcom-lieux
Import GEDCOM : lieu lisible, sans colonnes vides ni codes
2026-10-16 20:22:04 +02:00
IliesAllaliandClaude Opus 5.5 bca0543803 Import GEDCOM : lieu lisible, sans colonnes vides ni codes
Heredis remplit toutes les colonnes de son format de lieu, même vides, avec
le code INSEE (« La Roche-sur-Yon,85191,Vendée,Pays de la Loire,France, »).
Le lieu de naissance importé garde les noms seulement, séparés par « , »,
sans case vide, code INSEE ou postal, ni répétition. Un lieu déjà propre
ne change pas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:20:17 +02:00
IliesAllali 0e96ffc020 Merge pull request #68 from IliesAllali/gedcom-souvenirs
Import GEDCOM : les fichiers cités deviennent des souvenirs (#59)
2026-10-16 20:13:39 +02:00
IliesAllaliandClaude Opus 5.5 2fccf287b3 Premières photos : compter chaque souvenir par son chemin d'origine
Les correspondances gardent le chemin cité par le logiciel (sourcePath) :
plusieurs fichiers d'une même personne ne se confondent plus, le décompte
des fichiers ajoutés et restants est juste, et le pied du panneau parle de
fichiers quand des documents ont été ajoutés.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:06:03 +02:00
IliesAllaliandClaude Opus 5.5 3097768af5 Premières photos : les autres fichiers cités deviennent des souvenirs
À l'étape du dossier de photos, les fichiers cités par le logiciel en plus
des portraits (actes, registres, photos de famille) sont retrouvés dans le
même dossier et ajoutés aux souvenirs de chaque personne, avec le titre et
la source venus du GEDCOM. Un seul bouton « Tout ajouter » ; les fichiers
restants sont gardés pour rouvrir l'étape depuis les paramètres. Pour #59.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:00:40 +02:00
IliesAllaliandClaude Opus 5.5 897ada2665 Import GEDCOM : tous les fichiers cités deviennent des souvenirs
En plus du portrait, l'import renvoie mediaRefs : les fichiers cités pour
chaque personne créée, où qu'ils soient accrochés (sous la personne, un
événement, une source citée, la fiche de la source, ou la famille pour les
actes de mariage), avec leur titre et leur source. Comme Gramps : adresses
web écartées, un fichier cité deux fois gardé une fois. Particularités
gérées : titre dans la note (Heredis, RTF compris), _PHOTO pour le portrait
(Family Tree Maker), chemins file:// (GEDCOM 7). Pour #59.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:55:56 +02:00
IliesAllali c556e84610 Merge pull request #60 from py5932/py5932-drag-and-drop
Permettre le réagencement des personnes dans l’arbre
2026-10-16 19:31:32 +02:00
IliesAllali 12ccb520f5 Merge remote-tracking branch 'origin/main' into pr60-fix
# Conflicts:
#	frontend/src/components/PersonCard.jsx
#	frontend/src/i18n/en/fiche.js
2026-10-16 19:27:11 +02:00
IliesAllali 768fdf1b0b Merge pull request #58 from coutadeurf/feat/immich-face-integration-clean
Integrates Immich face recognition with Parenthèse to associate Immich people with family-tree members and synchronize their portraits.
2026-10-16 19:24:51 +02:00
IliesAllaliandClaude Opus 5.5 e3bfe9c2a6 Fiche : place dans la fratrie compacte, sur la ligne de « Édition »
‹ 2/4 › à côté de la pastille Édition au lieu d'une ligne à part ; la
place complète reste annoncée aux lecteurs d'écran.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:13:10 +02:00
e327cc223d Arbre : réordonner une fratrie au glisser ou aux flèches de la fiche
En mode édition, un administrateur glisse une personne à l'horizontale
parmi ses frères et soeurs pour changer leur ordre. Pour chaque place visée,
le vrai placement est recalculé : la personne soulevée suit le pointeur,
les autres branches glissent vers leur place avec un ressort, et rien ne
saute au lâcher. Appui long au doigt, Échap ou un deuxième doigt pour
annuler, mouvement réduit respecté. La fiche propose aussi deux flèches
(Déplacer à gauche, Déplacer à droite) avec la même animation. L'ordre est
appliqué tout de suite puis enregistré ; en cas d'échec l'arbre revient
en arrière et un message le dit.

Idée de départ : PR #60 de py5932 (glisser-déposer des personnes).

Co-Authored-By: py5932 <py.dequidt@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:08:15 +02:00
IliesAllaliandClaude Opus 5.5 8556d32bce API : l'ordre d'une fratrie s'applique aux deux liens de l'union
L'arbre ne montre qu'un lien par enfant et par union ; le lien vers l'autre
parent de la même union reçoit le même ordre, pour que les deux restent
d'accord.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:08:05 +02:00
IliesAllaliandClaude Opus 5.5 5f968a9f22 Placement : la fratrie suit l'ordre choisi (displayOrder)
Le placement par blocs rangeait les enfants dans l'ordre des identifiants,
displayOrder n'avait donc aucun effet. Les enfants d'une union sont
maintenant triés sur displayOrder, avec un tri stable : un arbre jamais
réordonné garde exactement son placement. Dans l'arbre de démo, Sophie
(ordre 1) repasse devant Thomas (ordre 2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:55:01 +02:00
IliesAllaliandClaude Opus 5.5 588ac0c4e9 API : un nouvel enfant se place après ses frères et soeurs
Sans ordre fourni, un lien parent-enfant créé (route, contribution, import
GEDCOM) prend l'ordre suivant celui de la fratrie, pour qu'un enfant ajouté
après un réordonnancement ne passe pas devant les autres.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:53:23 +02:00
IliesAllaliandClaude Opus 5.5 1caa090efd API : réordonner une fratrie en une transaction
PUT /trees/:id/links/order réécrit le displayOrder des liens parent-enfant
d'une fratrie, dans l'ordre reçu. Administrateurs seulement ; chaque lien
doit appartenir à l'arbre et à son enfant, et les enfants partager au moins
un parent. Journalisé (children_reordered).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:51:37 +02:00
IliesAllaliandClaude Opus 5.5 8574dfed9e Retour au placement automatique : abandon du placement libre
Le mainteneur préfère un réordonnancement des frères et soeurs au placement
libre (coordonnées enregistrées). Ce commit annule le glisser libre, ses
corrections, la migration des coordonnées et le bouton de remise à zéro ;
le réordonnancement arrive dans les commits suivants.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:46:28 +02:00
IliesAllaliandClaude Opus 5.5 c819027f9b Immich : connexion déplacée dans l'onglet Avancé, affichée seulement si une instance est autorisée
L'onglet Immich des paramètres de l'arbre est supprimé. Sa section passe dans
l'onglet Avancé, au-dessus de la zone de suppression, et n'apparaît que si le
serveur déclare au moins une instance. La config Immich n'est lue que pour les
administrateurs, et le panneau attend cette réponse avant de s'afficher pour
que la zone de suppression ne bouge pas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:44:45 +02:00
IliesAllaliandClaude Opus 5.5 d93710a00d Tests Immich : la suppression d'une personne efface aussi son lien Immich
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:16:16 +02:00
IliesAllaliandClaude Opus 5.5 6b1f4c067e Immich : vraies miniatures dans le sélecteur, aperçu seulement après un choix
- Les visages affichent leur miniature ronde au lieu de l'icône. Chargées
  quand elles entrent dans la liste (IntersectionObserver), trois requêtes
  au plus en parallèle, une requête par visage, cache le temps du panneau.
  Les URL blob sont révoquées au démontage. En attente ou sans vignette : un
  rond neutre.
- La personne déjà associée à la fiche est marquée « Associée » (lecture
  réservée aux administrateurs, via la nouvelle route de lien).
- Le cadre pointillé vide disparaît : l'aperçu n'apparaît qu'une fois un
  visage choisi, avec la photo carrée arrondie des fiches. Le bouton principal
  reste désactivé tant que rien n'est choisi.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:16:16 +02:00
IliesAllaliandClaude Opus 5.5 8fa2808b5d fix(galaxy): un deuxième doigt annule le glisser d'une personne
Au passage à deux doigts (ou en cas d'interruption tactile), le glisser en
cours est annulé et la personne revient à sa place : plus d'enregistrement
pendant un pincement. Un doigt qui reste après un pincement ne saisit plus
de personne, il ne fait que déplacer l'arbre.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:13:52 +02:00
IliesAllaliandClaude Opus 5.5 b27b5e9737 feat(arbre): rétablir la disposition automatique depuis les paramètres
Onglet Avancé des paramètres de l'arbre (admin) : une action « Rétablir la
disposition automatique », visible seulement s'il existe des positions
manuelles, avec confirmation. Elle appelle la route admin
/trees/:id/layout/reset puis recharge le graphe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:13:52 +02:00
IliesAllaliandClaude Opus 5.5 6ff99337d2 fix(arbre): place envoyée visible jusqu'à la relecture, erreurs d'enregistrement affichées
- Contributeur : après l'envoi, la place proposée reste à l'écran comme un
  nom modifié, au lieu de revenir à l'ancienne place jusqu'à l'approbation.
- Admin : un échec d'enregistrement d'une personne est signalé par un toast
  d'erreur, et le brouillon est conservé au lieu d'être vidé.
- Gestionnaire handleResetTreeLayout (le bouton arrive dans le commit suivant).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:13:46 +02:00
IliesAllaliandClaude Opus 5.5 7f159d5111 Immich : route admin pour lire le lien d'une fiche
Le sélecteur doit marquer la personne Immich déjà associée. L'identifiant ne
figure pas dans le graphe public : GET /trees/:id/immich/links/:personId le
renvoie aux seuls administrateurs de l'arbre.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:13:26 +02:00
IliesAllaliandClaude Opus 5.5 697c0e3897 feat(arbre): « Position dans l'arbre » en relecture des contributions
Une contribution qui déplace une personne affiche désormais la ligne
« Position dans l'arbre » (Déplacée, Replacée automatiquement), sans
coordonnées brutes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:13:23 +02:00
IliesAllaliandClaude Opus 5.5 de897070db fix(arbre): les positions du brouillon restent toutes à l'écran
La liste des positions manuelles était mise en cache sur un hasDraft et
une fonction qui ne changent plus après le premier glisser : une seule
personne (la première) restait placée. Le brouillon expose maintenant un
draftRevision incrémenté à chaque écriture, et le calcul des positions
passe par buildManualPositionOverrides (testé).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:13:18 +02:00
IliesAllaliandClaude Opus 5.5 ca23dfa318 Immich : la pastille d'état garde la largeur de son texte
Dans la grille de la ligne, la pastille « Configurée » s'étirait sur toute la
largeur. Elle s'aligne désormais au début, comme les autres pastilles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:12:55 +02:00
IliesAllaliandClaude Opus 5.5 f2ca980950 Immich : le choix « Depuis Immich » n'est proposé qu'aux administrateurs de l'arbre
Un contributeur qui modifiait une fiche avec un portrait venu d'Immich voyait
le menu de source, alors que l'association et le retrait sont réservés aux
administrateurs côté serveur. La connexion Immich n'est aussi interrogée que
pour un administrateur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:12:49 +02:00
IliesAllaliandClaude Opus 5.5 f37fc329ba Immich : lectures bornées, erreurs de corps en 503, lien retiré avec le portrait
- Vignette lue en flux : au-delà de 5 Mo (ou sans taille annoncée), la lecture
  s'arrête sans charger le reste.
- Vignette servie seulement si les octets sont une image, avec
  X-Content-Type-Options: nosniff (plus de repli sur image/jpeg).
- Liste des personnes : délai global de 30 s sur toutes les pages.
- Corps JSON illisible ou flux interrompu : 503 immich_unreachable, au lieu
  d'une erreur serveur.
- Retirer le portrait à la main (DELETE avatar) ou supprimer une personne
  efface aussi son identifiant Immich, sinon l'association reste invisible.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:12:21 +02:00
IliesAllaliandClaude Opus 5.5 c8df8df937 Immich : une clé illisible n'est plus signalée comme configurée
Le résumé ne lisait que l'adresse : après un changement de JWT_SECRET, l'onglet
affichait « Configurée » alors que chaque appel échouait. Le résumé déchiffre
désormais la clé (configured: false, keyNeedsReentry: true, instance gardée pour
préremplir le menu). L'onglet Immich affiche « Clé à ressaisir », garde le
bouton de suppression, et un PUT sans clé est refusé tant que la clé en place
ne se lit pas. Tests : résumé avec clé illisible, refus de conserver la clé.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:11:17 +02:00
IliesAllaliandClaude Opus 5.5 d17cee20df feat(arbre): rétablir la disposition automatique
Route admin POST /trees/:id/layout/reset : remet layoutX/layoutY à null
pour toutes les personnes de l'arbre, dans une transaction, avec une
entrée de journal (tree_layout_reset). Documentée dans l'OpenAPI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:09:11 +02:00
IliesAllaliandClaude Opus 5.5 265b3b30a0 Immich : les instances se choisissent par leur nom, jamais par leur adresse
L'API ne renvoie plus les adresses brutes d'Immich à un administrateur
d'arbre : la liste IMMICH_ALLOWED_API_URLS devient des instances nommées
(« Libellé=https://adresse », libellé facultatif, nom d'hôte par défaut).
Chaque instance a un identifiant opaque dérivé de son adresse, donc stable
même si l'opérateur réordonne la liste. Le PUT accepte cet identifiant et
stocke l'adresse canonique de la liste. Deux instances au même nom d'hôte
sans libellé sont refusées au démarrage avec un avertissement.

Les variables d'exemple documentent le format, la liste reste compatible
avec les adresses seules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:08:54 +02:00
IliesAllaliandClaude Opus 5.5 420bda5e38 fix(arbre): bornes des positions et conflit de relecture
- Les coordonnées de disposition sont bornées à ±1 000 000 (PATCH personne,
  modification en session de contribution, OpenAPI).
- Un déplacement par le propriétaire ne met plus en conflit les
  contributions en attente sur la même personne : layoutX/layoutY sont
  exclus de la comparaison de conflit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:08:50 +02:00
IliesAllaliandClaude Opus 5.5 fbcac3afd4 Tests Immich : liste d'instances vide explicite, indépendante du .env local
Le test « aucune instance autorisée » héritait de IMMICH_ALLOWED_API_URLS du
backend/.env de développement et échouait sur une machine qui le renseigne.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:05:51 +02:00
IliesAllaliandClaude Opus 5.5 63b9761ec4 Immich : garder le chemin de l'instance, comparer origine et chemin
new URL(chemin, base) effaçait le sous-chemin d'une instance derrière un
proxy (https://hote/immich devenait https://hote/api/...). L'adresse se
concatène désormais. Une adresse avec identifiants, requête ou fragment est
refusée, et le client reçoit l'adresse autorisée normalisée, jamais la
valeur brute de la base. Tests : sous-chemin, domaine préfixe, userinfo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:05:50 +02:00
IliesAllaliandClaude Opus 5.5 44dd203069 Immich : ne jamais suivre une redirection avec la clé d'API
fetch suivait les redirections par défaut : une instance autorisée qui
répond 3xx pouvait recevoir x-api-key sur une autre adresse. Les appels
passent en redirect: 'error' (remontée en immich_unreachable), avec un test
qui le prouve.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:04:50 +02:00
py5932 43441f31e1 Ajout de la fonctionnalité drag and drop 2026-10-14 23:25:11 +02:00
Mistral Vibe cf946be617 Immich : un portrait déposé à la main remplace aussi le lien Immich
POST /trees/:id/persons/:personId/avatar lit immichPersonId de la personne et le
remet à null quand une association existe ; l'entrée d'audit person_avatar_updated
trace le retrait via payloadJson immichLinkRemoved. Sans cela, l'association
survivait invisible en base après remplacement du portrait par un fichier.

Test : l'envoi d'un fichier sur un portrait Immich efface l'association (échouait
avant ce fix : immichPersonId absent de l'update) ; sans association, le champ
n'est pas touché.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-10-13 19:08:52 +02:00
Mistral Vibe 8803463ce4 Immich : le choix de la source passe par un menu sur « Changer » ; « Retirer » désassigne les visages Immich
- PersonCard : le lien « Depuis Immich » empilé sous « Changer »/« Retirer » disparaît ;
  « Changer »/« Ajouter une photo » ouvre un menu contextuel (portail, pz-menu) proposant
  « Depuis un fichier » ou « Depuis Immich », seulement quand l'arbre a une connexion
  Immich ou que la personne garde un portrait venu d'Immich (sinon sélecteur direct).
  La connexion se lit via GET /trees/:id/immich/config à l'ouverture en édition.
- « Retirer » transmet l'origine du portrait ; App route vers unassignImmichPerson quand
  il vient d'Immich (portrait + association retirés ensemble) au lieu de la suppression
  seule qui laissait une association pendante. Erreurs du chemin Immich mappées
  par getImmichErrorMessage.
- i18n en : « Depuis un fichier », « Source du portrait ».

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-10-13 18:43:16 +02:00
IliesAllaliandClaude Haiku 5.5 3a5fa42670 docs(readme): nom de l'auteur relié au portfolio iliesallali.design
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-13 18:41:19 +02:00
Mistral Vibe 310f2fb597 Immich : publier l'origine du portrait dans le graphe, pas l'identifiant
- avatarFromImmich (fichier préfixé « immich- ») remplace immichPersonId
  côté front : le panneau ne propose le retrait que si le portrait vient
  d'Immich, et le bouton de la fiche retrouve son libellé distinctif
- helper isImmichAvatarPath partagé entre le payload du graphe et la route
  unassign, qui testaient le préfixe chacun de son côté
- tests : origine du portrait dans le payload (vrai/faux/null)

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-10-13 17:47:37 +02:00
Mistral Vibe 8d36a08b11 Immich : retirer l'identifiant Immich du graphe envoyé aux visiteurs
- buildGraphPayload ne mappe plus immichPersonId ; test qu'il n'apparaît pas
- le front ne propage plus le champ (graphAdapter, mockData et son test)
- le panneau Immich propose le retrait d'association en permanence, sans lire
  le champ ; unassign sans association ne fait rien : pas d'écriture, pas
  d'entrée d'audit (test du no-op), et le bouton de la fiche garde un
  libellé fixe

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-10-13 17:39:23 +02:00
Mistral Vibe 0bda88920d Immich : demander confirmation avant de remplacer un portrait existant
L'association remplaçait et supprimait le portrait en place sans rien
demander. Le serveur refuse maintenant le remplacement d'un portrait
existant (409 avatar_exists), quelle que soit son origine, tant que la
requête ne porte pas replaceAvatar ; le panneau Immich transforme ce
refus en demande de confirmation avant de rejouer la requête. Sans
vignette Immich, l'association ne touche pas au portrait et aucune
confirmation n'est demandée.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-10-13 15:58:32 +02:00
Mistral Vibe a3386824d3 Immich par arbre : connexion propre à chaque arbre, instances autorisées par l'opérateur
- Nouvelle table tree_immich_configs (adresse + clé d'API chiffrée AES-256-GCM,
  clé dérivée de JWT_SECRET) et routes GET/PUT/DELETE /trees/:id/immich/config
  réservées aux administrateurs de l'arbre ; journal d'audit sans secret
  (adresse et « clé changée » uniquement).
- Fin des variables d'env IMMICH_ENABLED / IMMICH_API_URL / IMMICH_API_KEY :
  l'adresse se choisit parmi IMMICH_ALLOWED_API_URLS, la liste établie par
  l'opérateur du serveur. Elle filtre l'enregistrement ET chaque requête :
  retirer une adresse coupe aussitôt les connexions qui l'utilisent.
- Changer d'instance exige une nouvelle clé : l'ancienne est écrasée et ne
  voyage jamais vers le nouveau serveur.
- Le désassignement reste possible sans connexion : purement local, il nettoie
  association et portrait stocké même après suppression de la connexion ; le
  panneau affiche « Retirer l'association » dans tous les états.
- Onglet Immich dans Paramètres de l'arbre : menu des instances autorisées,
  test de connexion, suppression avec confirmation ; i18n anglais des nouveaux
  messages.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-10-13 14:04:45 +02:00
Mistral Vibe 50873a4f9e Fix Immich modal positioning with portal to always see the Associate button 2026-10-12 16:48:48 +02:00
Mistral Vibe c703a34165 Immich : vérifier l'existence d'une personne avant l'association
Le 404 de la vignette ne distingue pas une personne inexistante d'une
vignette manquante : l'association pouvait donc être enregistrée pour
une personne Immich qui n'existe pas.

- ImmichClient.getPerson() : GET /api/people/{id} (200 | 404), qui
  établit l'existence de la personne et annonce sa vignette.
- La route d'association rejette une personne inexistante (404
  immich_person_not_found) ; un refus d'authentification (502) ou une
  erreur serveur (503) n'est jamais pris pour une absence.
- La vignette n'est demandée que si Immich en annonce une ; une vignette
  manquante chez une personne existante associe toujours sans portrait.
- Tests : personne inexistante refusée sans toucher base, audit ni
  fichiers ; vignette disparue entre les deux appels ; échec
  d'authentification pendant l'association sans changement en base.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-10-12 13:40:38 +02:00
Mistral Vibe 2f64ceb41d .env.example : documenter les variables Immich pour l'auto-hébergement
Le .env.example racine (docker compose, env_file de l'API) ne
mentionnait pas IMMICH_ENABLED, IMMICH_API_URL et IMMICH_API_KEY ;
seul le .env.example du backend les documentait.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-10-12 12:21:52 +02:00
Mistral Vibe 47083ec274 Merge upstream/main : premières photos, Docker tout-en-un, dépendances
Reprise de upstream (IliesAllali/main) dans la branche Immich. Seul
conflit : l'import des icônes dans PersonCard.jsx — les deux lignes
sont gardées (ImagePlus pour le portrait vide de « Premières photos »,
Sparkles pour le bouton « Depuis Immich »). Tout le reste fusionne
automatiquement ; l'intégration Immich (routes, exports de media.ts,
mapping immichPersonId, panneau) est inchangée.

Vérifié après la fusion : tests Immich (16), tests backend focalisés
gedcom-photos (4), build tsc, eslint et les 107 tests du front. Seul
« rate-limits login endpoint » dépasse son délai de 5 s, déjà le cas
sur main avant la fusion (bcrypt, machine lente).

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-10-12 12:18:41 +02:00
Mistral Vibe 378071bf13 Intégration Immich : associer un visage à une personne de l'arbre
- Colonne persons.immichPersonId (schéma + migration 20261012_001) et
  exposition dans le graphe pour l'afficher côté fiche.
- Client Immich (src/lib/immich.ts) sur le contrat OpenAPI v3.1.0 :
  ping, /api/people paginé, vignette par personne ; erreurs distinctes
  entre injoignable, clé refusée et introuvable, identifiants validés
  comme UUID v4.
- Routes /trees/:id/immich/* (status, people, thumbnail, assign,
  unassign), réservées aux administrateurs de l'arbre ; l'adresse et la
  clé d'Immich ne quittent jamais le backend.
- La vignette passe par le pipeline des portraits (type lu dans les
  octets, métadonnées retirées, quota de l'arbre) : le fichier est écrit
  avant la transaction (personne + journal immich_assign) et l'ancien
  portrait est supprimé après ; un échec du nettoyage est journalisé
  sans faire échouer la requête. Le désassignement retire le portrait
  seulement s'il vient d'Immich (préfixe immich-) et préserve un
  portrait envoyé à la main.
- Côté front : bouton « Depuis Immich » sur la fiche en édition, panneau
  de sélection avec aperçu et retrait de l'association, traductions
  anglaises, messages d'erreur distincts.
- Tests : 16 tests backend (succès, 400/401/403/404/413/502/503, quota,
  échec de transaction, préservation du portrait manuel, serveur
  désactivé) et le mapping immichPersonId côté front.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-10-12 11:53:12 +02:00
IliesAllali abee3c6e19 Merge pull request #57 from IliesAllali/docker/healthcheck-premier-lancement
fix(docker): 120 s de marge au healthcheck de l'API au premier lancement
2026-10-11 20:42:18 +02:00
IliesAllaliandClaude Opus 5.5 a91474f0e6 fix(docker): 120 s de marge au healthcheck de l'API au premier lancement
Les migrations Prisma passent avant que l'API réponde. Avec 30 s, le conteneur
web, qui attend une API saine, abandonnait au premier docker compose up
(signalé sur r/GenealogieFR le 11/10).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:38:04 +02:00
IliesAllali 8103154e02 Merge pull request #56 from IliesAllali/produit/premiere-photo
Premières photos : import dès la création, photos du logiciel retrouvées, premiers visages
2026-10-11 18:59:12 +02:00
IliesAllaliandClaude Opus 5.5 83443ac9dc Premières photos : import dès la création, photos du logiciel retrouvées, premiers visages
- Création d'arbre : « J'ai déjà un arbre » importe le .ged sans fiche « vous » en double.
- Import GEDCOM : lit les photos citées (OBJE / FILE, en ligne ou par renvoi, _PRIM d'abord)
  et renvoie leur chemin ; rien n'est gardé côté serveur.
- Fenêtre « Il manque les visages » après un import : dossier des photos apparié par nom de
  fichier, cinq visages à demander (famille de « Moi », sinon les plus jeunes).
- Portrait vide chez le propriétaire : cadre cliquable sur la fiche hors édition, « + photo »
  au survol sur l'arbre.
- Photos de plus de 5 Mo réduites à 2000 px avant l'envoi ; portraits à 120 envois par minute.
- PostHog : gedcom_imported, portraits_bulk_placed, portrait_added (effectifs arrondis).
- Corrigé : une union à un seul parent connu relie ses enfants à ce parent, sous un seul
  « Inconnu », au lieu de les laisser sans lien.
- Corrigé : réimporter un GEDCOM reprend les personnes, unions et liens déjà présents au lieu
  de tout doubler.

Recette locale (vraie API, vraie base) : 29/29 sur le parcours photos, 47/47 sur la recette
de non-régression des parcours. Tests 123 serveur, 107 app, lint et build propres.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:54:56 +02:00
IliesAllali 2a510bf474 Merge pull request #55 from IliesAllali/docker/api-upstream
Docker : le front lit l'adresse de l'API dans API_UPSTREAM
2026-10-10 16:23:10 +02:00
IliesAllaliandClaude Opus 5.5 7becb821a3 CHANGELOG : 1.0.3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:18:02 +02:00
IliesAllaliandClaude Opus 5.5 333a11e84b Docker : le front lit l'adresse de l'API dans API_UPSTREAM
Le conteneur web relayait l'API vers `api:4000`, écrit en dur. Sur un réseau
partagé entre plusieurs applications (Umbrel), ce nom peut appartenir à une autre.
L'adresse vient maintenant de API_UPSTREAM, `api:4000` par défaut : rien ne
change pour les installations existantes. L'image tout-en-un garde sa propre
configuration. La CI lance un second front qui joint l'API par le nom de son
conteneur et repasse le test de fumée.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:15:46 +02:00
IliesAllali b774b1dff8 Merge pull request #53 from IliesAllali/docker/aio
Docker : image tout-en-un pour Unraid et les NAS
2026-10-09 17:53:04 +02:00
IliesAllaliandClaude Opus 5.5 30d015f083 Docker tout-en-un : journal sur stderr, il polluait le mot de passe lu par $(secret ...)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:51:29 +02:00
IliesAllaliandClaude Opus 5.5 cea8d1db8f Docker tout-en-un : journal PostgreSQL dans le dossier de la base (écriture refusée à la racine de /data)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:46:58 +02:00
IliesAllaliandClaude Opus 5.5 91abe47614 Docker : image tout-en-un pour Unraid et les NAS
Une seule image (PostgreSQL 15, API, nginx) qui crée sa base et ses secrets dans /data au premier
démarrage. Publiée par images.yml sous ghcr.io/iliesallali/parenthese-aio, testée en CI (démarrage
à vide, redémarrage, test de fumée).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:42:14 +02:00
IliesAllali 5a11d00af9 Merge pull request #38 from IliesAllali/dependabot/npm_and_yarn/landing/landing-minor-patch-0c4d63a201
Bump posthog-js from 1.435.8 to 1.438.0 in /landing in the landing-minor-patch group across 1 directory
2026-10-09 17:04:54 +02:00
IliesAllali c564ebeb05 Merge pull request #52 from IliesAllali/dependabot/npm_and_yarn/frontend/frontend-minor-patch-8178106ae4
Bump the frontend-minor-patch group across 1 directory with 2 updates
2026-10-09 17:04:51 +02:00
dependabot[bot] ada823ab47 Bump the frontend-minor-patch group across 1 directory with 2 updates
Bumps the frontend-minor-patch group with 2 updates in the /frontend directory: [posthog-js](https://github.com/PostHog/posthog-js) and [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `posthog-js` from 1.436.0 to 1.438.0
- [Release notes](https://github.com/PostHog/posthog-js/releases)
- [Changelog](https://github.com/PostHog/posthog-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/PostHog/posthog-js/compare/posthog-js@1.436.0...posthog-js@1.438.0)

Updates `vite` from 8.3.2 to 8.3.3
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.3.3/packages/vite)

---
updated-dependencies:
- dependency-name: posthog-js
  dependency-version: 1.438.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-minor-patch
- dependency-name: vite
  dependency-version: 8.3.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-09 13:23:56 +00:00
dependabot[bot] 2e638d8a43 Bump posthog-js
Bumps the landing-minor-patch group with 1 update in the /landing directory: [posthog-js](https://github.com/PostHog/posthog-js).


Updates `posthog-js` from 1.435.8 to 1.438.0
- [Release notes](https://github.com/PostHog/posthog-js/releases)
- [Changelog](https://github.com/PostHog/posthog-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/PostHog/posthog-js/compare/posthog-js@1.435.8...posthog-js@1.438.0)

---
updated-dependencies:
- dependency-name: posthog-js
  dependency-version: 1.435.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: landing-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-09 13:23:34 +00:00
403 changed files with 36188 additions and 7721 deletions

No files matched your search

+45
View File
@@ -34,6 +34,51 @@ CORS_ORIGIN=http://localhost
# MEDIA_QUOTA_PER_TREE_MB=5120
# MEDIA_PENDING_QUOTA_PER_TREE_MB=500
# ── Comptes ───────────────────────────────────────────────────────────────────
# À false, plus personne ne peut créer de compte : les comptes existants se connectent et
# créent leurs arbres, la famille ouvre toujours les arbres partagés avec le lien et le mot
# de passe. Une instance sans aucun compte accepte quand même le premier (le vôtre) :
# créez-le juste après le premier démarrage, avant de donner l'adresse.
# ALLOW_REGISTRATION=true
# À true, l'app invite à créer son propre arbre : « Faire le mien » dans le menu Ajouter de la
# famille, l'inscription proposée dans les messages de la démo, un message d'invitation pour Noël
# (du 1er décembre au 6 janvier). Éteint par défaut, allumé sur app.parenthese.io.
# SIGNUP_PROMPTS=false
# ── Chercher quelqu'un dans des sources publiques ────────────────────────────
# Sources que le serveur interroge pour le panneau « Quelqu'un ailleurs » (menu +) :
# deces (décès en France depuis 1970, fichier de l'INSEE par matchID), wikitree.
# Vide : aucun nom ne quitte l'instance, le panneau ne propose que des liens ouverts
# par le navigateur. Seuls le prénom, le nom et l'année de naissance sont envoyés.
# SEARCH_SOURCES=deces,wikitree
# Clé gratuite de deces.matchid.io (valable 30 jours) : sans elle, les recherches
# anonymes s'arrêtent après une centaine.
# MATCHID_TOKEN=
# ── Connexion par un fournisseur d'identité (OIDC : Authentik, Authelia, Keycloak…) ──
# Éteinte sans OIDC_ISSUER. Chez le fournisseur, adresse de retour = adresse publique + /auth/oidc/callback.
# Détails dans le README, section « Connexion par votre fournisseur d'identité ».
# OIDC_ISSUER=https://auth.votre-domaine.fr/application/o/parenthese/
# OIDC_CLIENT_ID=parenthese
# OIDC_CLIENT_SECRET=
# OIDC_REDIRECT_URI=https://arbre.votre-domaine.fr/auth/oidc/callback
# OIDC_PROVIDER_NAME=Authentik
# Email accepté seulement s'il est vérifié chez le fournisseur (email_verified).
# OIDC_REQUIRE_VERIFIED_EMAIL=true
# À false, plus de mot de passe Parenthèse : connexion par le fournisseur seulement.
# PASSWORD_LOGIN=true
# ── Immich (reconnaissance des visages, désactivé par défaut) ────────────────
# Instances Immich autorisées sur ce serveur, séparées par des virgules. Chaque entrée
# peut recevoir un libellé, « Libellé=https://adresse » ; sans libellé, c'est le nom
# d'hôte qui sert (un même nom d'hôte ne peut pas servir à deux instances : donnez-leur
# un libellé). Les administrateurs de chaque arbre choisissent l'instance par son nom
# et donnent leur propre clé d'API (créée dans Immich, Paramètres > Clés d'API) ; la
# clé est chiffrée en base. L'adresse n'est jamais montrée aux administrateurs : seul
# le libellé l'est. Sans cette liste, la fonction reste éteinte, et une adresse retirée
# de la liste coupe aussitôt les arbres qui l'utilisent.
# IMMICH_ALLOWED_API_URLS=Maison=http://immich.maison.lan:2283,https://immich.example.org
# ── Images ────────────────────────────────────────────────────────────────────
# Version des images ghcr.io/iliesallali/parenthese-api et parenthese-web : latest (dernière
# version de main), ou une version figée, par exemple 1.0.
+2
View File
@@ -2,3 +2,5 @@
* text=auto
# Scripts lancés sous Linux (serveur, conteneurs, CI) : fins de ligne LF même sur un clone Windows
*.sh text eol=lf
# Témoins texte du placement : comparés octet pour octet par vitest, toujours en LF
frontend/src/components/galaxy/__snapshots__/** text eol=lf
+70 -5
View File
@@ -29,7 +29,7 @@ jobs:
with:
persist-credentials: false
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 24
cache: npm
@@ -63,7 +63,7 @@ jobs:
with:
persist-credentials: false
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 24
cache: npm
@@ -118,7 +118,7 @@ jobs:
with:
persist-credentials: false
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 24
cache: npm
@@ -141,7 +141,7 @@ jobs:
run: npx prisma migrate deploy
- name: Schéma Prisma aligné sur les migrations
run: npx prisma migrate diff --from-url "$DATABASE_URL" --to-schema-datamodel prisma/schema.prisma --exit-code
run: npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --exit-code
- name: Tests
run: npm test
@@ -182,12 +182,77 @@ jobs:
- name: Test de fumée par le port web
run: bash tools/docker-smoke.sh http://localhost:8080
# Les commandes de sauvegarde et de restauration du README, jouées pour de vrai
- name: Sauvegarde puis restauration
run: |
set -euo pipefail
bash tools/docker-restore-check.sh http://localhost:8080
bash tools/docker-smoke.sh http://localhost:8080
# Un second front qui joint l'API par le nom de son conteneur (API_UPSTREAM), comme sur Umbrel.
- name: Front avec une autre adresse d'API
run: |
WEB_IMAGE="$(docker compose config --images | grep parenthese-web)"
API_CONTAINER="$(docker compose ps -q api | xargs docker inspect -f '{{.Name}}' | tr -d /)"
docker run -d --name web-upstream --network parenthese_default -p 8081:80 \
-e API_UPSTREAM="$API_CONTAINER:4000" "$WEB_IMAGE"
sleep 3
docker exec web-upstream grep -q "http://$API_CONTAINER:4000" /etc/nginx/conf.d/default.conf
bash tools/docker-smoke.sh http://localhost:8081
- name: État et journaux en cas d'échec
if: failure()
run: |
docker compose ps -a
docker compose logs --no-color --timestamps
docker logs web-upstream || true
- name: Arrêt et suppression des volumes
if: always()
run: docker compose down -v --remove-orphans
run: |
docker rm -f web-upstream || true
docker compose down -v --remove-orphans
docker-aio:
name: Docker tout-en-un (Unraid, NAS)
runs-on: ubuntu-latest
timeout-minutes: 30
env:
SMOKE_WAIT_SECONDS: 180
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with:
persist-credentials: false
- name: Construction
run: DOCKER_BUILDKIT=1 docker build -f Dockerfile.aio -t parenthese-aio:test .
- name: Démarrage, sans aucune variable
run: docker run -d --name aio -p 8080:80 -v aio-data:/data parenthese-aio:test
- name: Test de fumée par le port web
run: bash tools/docker-smoke.sh http://localhost:8080
# Les secrets et la base de /data doivent servir tels quels au démarrage suivant (mise à jour, reboot).
- name: Redémarrage puis nouveau test
run: |
set -euo pipefail
docker restart aio
bash tools/docker-smoke.sh http://localhost:8080
docker logs aio 2>&1 | grep -c "première installation" | grep -qx 1
# Base à jour au redémarrage : pas de sauvegarde avant migration, mais pg_dump marche dans l'image
[ -z "$(docker exec aio ls -A /data/backups 2>/dev/null)" ]
[ "$(docker exec aio runuser -u postgres -- pg_dump -Fc parenthese | wc -c)" -gt 1000 ]
- name: Journaux en cas d'échec
if: failure()
run: |
docker ps -a
docker logs --timestamps aio || true
docker run --rm -v aio-data:/data busybox cat /data/postgres/postgres.log || true
- name: Arrêt et suppression du volume
if: always()
run: |
docker rm -f aio || true
docker volume rm aio-data || true
+1 -1
View File
@@ -58,7 +58,7 @@ jobs:
- name: Upload screenshots
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: fresh-install-shots
path: parenthese/shots
+14 -4
View File
@@ -53,22 +53,31 @@ jobs:
- image: parenthese-web
context: .
file: Dockerfile.web
# Tout-en-un (base, API, front) pour Unraid et les NAS
- image: parenthese-aio
context: .
file: Dockerfile.aio
steps:
# Sur workflow_run, le commit exact que la CI a validé, pas la tête de main au moment du run.
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with:
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
persist-credentials: false
# Tout l'historique et les tags, pour lire la version (git describe)
fetch-depth: 0
- name: Short commit
# Version lisible dans le label OCI (#207) : 1.5.9 sur un tag, 1.5.9-11-gd5f9da6 sur main après la 1.5.9
- name: Short commit and version
id: commit
run: echo "sha=$(git rev-parse --short=7 HEAD)" >> "$GITHUB_OUTPUT"
run: |
echo "sha=$(git rev-parse --short=7 HEAD)" >> "$GITHUB_OUTPUT"
echo "version=$(git describe --tags --match 'v*' | sed 's/^v//')" >> "$GITHUB_OUTPUT"
- uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -85,6 +94,7 @@ jobs:
type=raw,value=latest,enable=${{ github.event_name == 'workflow_run' }}
type=raw,value=sha-${{ steps.commit.outputs.sha }}
labels: |
org.opencontainers.image.version=${{ steps.commit.outputs.version }}
org.opencontainers.image.revision=${{ github.event.workflow_run.head_sha || github.sha }}
org.opencontainers.image.licenses=PolyForm-Noncommercial-1.0.0
@@ -116,7 +126,7 @@ jobs:
persist-credentials: false
# Tant que les paquets ne sont pas publics, le pull demande une connexion.
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
+388
View File
@@ -2,6 +2,394 @@
Notable changes to Parenthèse. Dates are in YYYY-MM-DD format.
## 1.6.0 (2026-11-04)
### Adding people
- Several first names in the First name field, separated by commas, add several children or siblings at once. "Paul, Marie, Jean" creates three people with the same links, last name and dates. Empty names and duplicates are skipped, up to 20 names. For a parent or a partner the field still takes one name. On a phone no profile opens after a group add, and the view frames the whole family (#203).
### Life tab
- Photos of a marriage show for both partners. The marriage was already in both Life tabs, but its photos only showed on the profile they were added from. A partner's photo opens in their name (#209).
### Words
- English relationship names go as far as the tree does. Great-great-grandfather, great-great-uncle, grandnephew, second cousin, first cousin twice removed, 4th cousin. "Partner" replaces "Spouse" everywhere, on the profile and the tree too, and "Nibling" is the neutral form of nephew and niece. Thanks to Arc676 (#204).
### Photos
- Portrait thumbnails follow the saved crop everywhere, not only on the tree and the profile. That covers the viewer, "In this photo", search, the profile links, adding a relative, the account and memory picking (#205).
### Shared links
- A link to the app pasted in WhatsApp, Messenger or iMessage shows a title, a sentence and an image. They are the same for every address, so no name or photo from a tree reaches the preview bots (#201).
- A link with `?source=` and no session opens the demo again, not the welcome screen (#211).
### Hosted instance
- New `SIGNUP_PROMPTS` setting for the API, off by default. When it's on and registration is open, the app invites people to make their own tree in places that already exist: the Add menu of a shared tree, the demo messages, a "Create my tree" button in the demo bar, and a Christmas wording in the family invitation from December 1 to January 6. A self-hosted instance sees none of this unless it turns it on (#202, #212).
- In the demo, the dialog that closes editing no longer says changes stay on this device. The demo saves nothing and the sentence now says so (#202).
### Docker
- `org.opencontainers.image.version` carries the real version: `1.6.0` on a release tag, and for example `1.5.9-11-gd5f9da6` on `latest` between releases. Container watchers like What's Up Docker can show it instead of a digest (#207).
## 1.5.9 (2026-10-29)
### Tree
- A child added to someone who has a single partner becomes the couple's child, next to their brothers and sisters, instead of hanging under one parent and an "Unknown" one. With no partner, several partners, or an adoption, the child stays with the parent chosen (#200).
### Phone
- No more zoom slider: it opened by itself and covered a corner of the tree. Pinch to zoom, the magnifier zooms in one step. On a computer the slider still shows on hover (#199).
### Profile
- "– 1995" no longer breaks after the dash at the end of a line. The magnifier button is labelled "Zoom in" for screen readers.
## 1.5.8 (2026-10-29)
### Family tabs
- A tab is named after the surname most of its members carry (birth name, otherwise current name), not after the person it starts from. When two tabs would share a name, the smaller one takes its next most common surname (#192).
- Switching tabs keeps the person who joins the two families at the same place and zoom, only the families around change. Hovering a tab lights that person up in the tree (#192).
- The tab bar follows the shape of the tree, grouped by branch, with a line between a tab and the one it comes from. The way-back badge sits where the way-in badge was (#192).
### Profile
- The age counts the birthday: with a full birth date, a person born in December is not a year older from January. Same for the age at death and the ages in the Life tab. With only a year, the age stays the difference between the years (#198).
### Dependencies
- `@node-rs/bcrypt` 2.0: the cost is passed as an option. Stored passwords still verify, new hashes are `$2b$` with the same cost (#197).
- Minor updates for the frontend, the landing, the backend and the CI (#193, #194, #195, #196).
## 1.5.7 (2026-10-28)
### Phone
- Opening a tree no longer opens the start person's profile, which covered the bottom of the screen and the start person with it. The start person now sits at 40% of the height instead of 56% (#189, closes #188).
- Tapping someone in the tree moves them into the top third, above their profile (#189).
### Life tab
- A couple reads "Together with X", and "Married X" only when it is said: a date, a place, marriage documents, or the box ticked. Before, every couple read "Marries X", because a couple is stored as a marriage as soon as a child has two parents (#190).
- The date and place of a couple can be set from the Life tab: tap the couple's line, then "They were married", "When?" (year, "about", exact date) and "Where?". `PATCH /trees/:id/unions/:unionId` accepts `startPlace` and `startDatePrecision` (#190).
- The names of a partner, a child, a brother or sister, or a parent open that person's profile and move the tree to them (#190).
## 1.5.6 (2026-10-28)
### Tree
- Optional last name under the first name: the maiden name when it is known, otherwise the current last name. In the tree settings, under Display. Saved in this browser (#186, closes #168).
- Opening a tree: the camera now stops close enough to read the names, and on a phone the tree is centred in the space beside the zoom slider (#187).
### Undo bar
- The message agrees with the person's sex in French ("supprimée", "supprimé"), and "supprimé·e" only when it is unknown (#185).
- On phones and tablets the bar no longer covers the zoom slider. On a computer the zoom button is hidden while a profile is open (#185).
### Shortcuts
- Ctrl+K (⌘K on Mac) opens the person search from anywhere. The search tooltip and the Edit menu show their shortcuts (#184).
## 1.5.5 (2026-10-27)
### Undo instead of confirm
- Deleting a person no longer asks for confirmation. A bar with "Undo" stays for 6 seconds, and Ctrl+Z works too (#169).
- Same for a memory and for a moment in the Life tab. Two fixes to #169 on the way: an open profile no longer closes when the deletion starts, and the tree's internal lists are up to date after an undo (#172).
### Motion
- Profile card: it appears at once on a computer, with no fade or blur. On a phone it still rises as a sheet in 240 ms, resists when pulled up, and has continuous corners where the browser supports `corner-shape` (#170).
- A memory grows from its thumbnail when it opens (280 ms, transform only). The viewer now respects reduced motion (#171).
- On a phone, dragging a memory's photo down closes it, like the profile card (#176).
- Opening a tree for the first time, the camera starts on the start person and pulls back to the family in 1.1 s while the tree draws. After an edit or a tab change, the tree shows without animation. With reduced motion, a plain fade (#181).
### Faces on photos
- Face markers are hidden by default. They show on hover with a mouse, when a name is hovered in the list, in tagging mode, or after a tap on the photo. The circles are thinner (#173).
### Phone
- Edit mode: the family tabs move to the top so the "You suggest" bar no longer covers them. The exit button reads "Done", at the height of the "+", and the bar's own close button is gone (#175).
- The family bar only moves aside when the profile card reaches it (#180).
- Tap targets of at least 44 px on touch screens, with no visible change (#182).
### Loading
- Big trees open faster with the same placement, and the list of family tabs is computed in a worker. A 3,010-person tree is usable in 1.6 s instead of 8.7 s. Tools in `tools/layout-bench` (`layout-time.mjs`, `load-probe.mjs`) (#179).
- Blurred previews (BlurHash) of photos and portraits while they load. Migration `20261027_001_apercus_flous` adds two optional columns, and a background task fills them for existing photos when the API starts (#177).
- Grey blocks while loading, instead of "Loading…" or a false empty state: the Life tab of a person with no known date, and the tree's panels (#178).
### Reliability
- Uploads from the family no longer create duplicates on a weak connection, through an `Idempotency-Key` header (#174).
### Landing
- Pages are prefetched when a link is hovered (`speculationrules`). The landing CSP in `deploy/nginx.conf` gets a second script hash for it (#183).
## 1.5.4 (2026-10-26)
### A link per family tab (#167)
- `?famille=<hinge person id>` opens the tree on that family's tab. The tab opens once the tree and its tabs are known, and again if the start person arrives later (as it does for a visitor), unless the person already picked a tab. With tabs turned off in that browser, the whole tree opens framed on the hinge person. An unknown key is ignored.
- The page address follows the open tab. In the share panel, the link to send and the invitation message follow it too, and the title names the family ("The link to send to the Garnier family").
- Same password and same access: the link picks the landing tab and restricts nothing.
## 1.5.3 (2026-10-26)
### Videos in Safari (#162)
- Media files are streamed and answer byte-range requests (`Accept-Ranges: bytes`, `206` with `Content-Range`, `416` outside the file). Safari on iOS, iPadOS and macOS needs this to play a video. Before, the whole file was read into memory and sent in one block.
### Accounts
- A password account on an OIDC instance can link its provider identity from its account ("Also sign in with …"), through a two-minute, single-use ticket (`POST /auth/oidc/link`, then `/auth/oidc/start?link=`). The identity joins that account whatever its email, and an identity already linked to another account is never moved. This is the way to switch an instance to `PASSWORD_LOGIN=false` without locking out password accounts. `/auth/me` returns `oidcLinked` (#161).
- Signing out, or deleting the account, now survives an API restart: closed sessions are kept in the `revoked_sessions` table until their token expires, and the API refuses to start if it can't load them. Migration `20261026_001_deconnexions` (#163).
### Security
- A media waiting for review is readable only by a tree admin or by the account that uploaded it. Anyone else gets the same 404 as a missing media (#160).
- Passwords are hashed with native bcrypt (`@node-rs/bcrypt`) off the event loop. Existing hashes keep working, no migration (#160).
- Frontend lockfile: zod 4.6.7, a dev dependency (#166).
### Under the hood
- Prisma 7 with the pg driver adapter. The client is generated in `backend/src/generated` and `backend/prisma.config.ts` holds the database URL for the CLI. Without Docker, Node must be ^20.19, ^22.12 or 24+, and `deploy/deploy.sh` stops early on an older Node (#164).
- Tailwind 4 for the app and the landing, through the Vite plugin. The v3 base styles the app relied on are kept, and nothing changes on screen (#165).
## 1.5.2 (2026-10-26)
### Dates follow the browser's region (#152)
- Dates follow the browser's regional variant of the interface language (en-US, en-AU, fr-CA…), falling back to fr-FR or en-GB. Before, English always used the British format. This covers every date and time shown in the app, e.g. `10/26/2026, 10:57 AM` in the review panel on an en-US browser.
- Typed dates follow the region's order and separator: `06/12/1924` in en-US, `1924-06-12` in fr-CA, `12/06/1924` in France and the UK. A year-first date is read everywhere.
- The date field also accepts a written month, in French or English: `12 juin 1924`, `1er août 1950`, `June 12, 1924`, `3 sept. 1901`. An ambiguous month (`jui`) is refused.
### Names in non-Latin scripts
- Initials for scripts without capital letters (Chinese, Japanese, Korean, Arabic, Hebrew…) are a single character: the family name for Chinese, Japanese and Korean, the first name for Arabic and Hebrew. Latin and Cyrillic keep two initials. This applies to profiles, suggestion cards, the first-photos panel and the tree.
- A birth place or a nickname in a right-to-left script no longer flips the order of the line under the name.
### Suggestion cards
- The action label (New, Edit, Delete) sits on the top edge of the card, so the name keeps the full width and both buttons stay inside the card on a phone.
- A change whose profile changed after the suggestion was sent shows "Needs checking" in place of "Edit". Any other action keeps its word ("Delete, needs checking").
### Fixes
- The quick buttons to add a parent, partner, child or sibling in a profile's Info tab are dashed chips, like "+ Nickname" in edit mode (#154).
- The notice after adding someone or sending changes takes the width it needs on a phone, instead of four lines in a pill, and appears without animation (#156).
- The counter of pending suggestions on the account picture is round for a single digit.
- Backend dependencies: ip-address 10.7.4 (#144) and fast-uri (#155), for known security issues.
### For self-hosters
- No database change.
## 1.5.1 (2026-10-25)
### Suggestions show on the tree
- A suggestion waiting for review now shows on the tree, where it will go: a suggested person is drawn faded, with a dotted circle and a "Suggested" tag, and their links are dotted. Words, drawings and photos placed on the tree show see-through. A person with a suggested change gets a small dot.
- Whoever manages the tree opens a suggested person to see who suggested it and what else the suggestion holds, and accepts or rejects it right there. A person with a suggested change shows the fields in their profile, with the same buttons. "See details" opens the review panel to sort change by change.
- The family sees what they are adding as a "Draft" before sending it, then as "Waiting" until it is reviewed. This is kept in their browser only, and cleared once the suggestion is reviewed.
- After adding a person, the send window no longer opens by itself. The family can add several people, then send once with Send.
### Photos placed on the tree by the family
- A family member can now place a photo on the tree. Before, the upload was refused without a word and the photo never showed. The photo waits for review: it is kept if accepted, deleted if rejected, and an upload never sent is deleted after 7 days. At most 50 photos wait on a tree at once.
- Accepted words, drawings and photos are now placed by the server when the suggestion is accepted. Before, the reviewer's browser placed them afterwards and could lose them, and none appeared when the family's changes apply directly.
### Adding relatives from a profile
- "Someone elsewhere": relatives can add a person found in public records too. It goes into their changes and the tree manager reviews it, like any other proposal. Places, the "needs checking" mark and the source link now travel with a proposed new person.
- In a profile's Links tab, searching for a parent, child or partner who is not in the tree yet now offers "Create …", which opens the add panel already filled in.
- Profiles have a Siblings section, with "Add a sibling".
- The Info tab has quick buttons to add a parent, partner, child or sibling without going into edit mode first.
- The add panel opened from a profile comes with the link already set, and suggests the family name for a child or a sibling.
- Adding someone whose first and last name match a person already in the tree shows a warning, with a button to open that person's profile.
### Fixes
- The "needs checking" mark sits on the corner of the portrait, half outside, so it no longer covers the face.
- "Update the tree" no longer clears your changes when part of them fails to save: what failed stays in your edits, with a message.
- A suggestion already reviewed elsewhere (another tab, a co-manager) now says so, instead of a message about emails, and the tree updates.
- After the family sends their changes, the tree reloads: a photo just sent no longer looks accepted.
- Zooming with the mouse wheel no longer logs an error in the browser console.
### For self-hosters
- New table `annotation_photo_uploads` (migration `20261025_003`, additive).
- New route `GET /trees/:id/contributions/sessions/:sessionId/status`, which returns only the status of a suggestion to someone who can read the tree.
- Anonymous counters for editing (person added and from where, suggestion sent or reviewed, edits saved), with no content from the tree. As before, nothing is sent without `VITE_POSTHOG_KEY`.
## 1.5.0 (2026-10-25)
### Several managers for one tree
- The owner can invite co-managers from the tree settings, in a new Managers tab. Parenthèse sends no email: the owner gets a link to pass on. The link works once, lasts 14 days, and can be cancelled. Copying it again creates a new link and the old one stops working (#145, asked by @milan616).
- A co-manager edits the tree and reviews the family's suggestions like the owner. Only the owner invites or removes a co-manager and deletes the tree. A co-manager can leave from the Advanced tab.
- Immich stays with the owner. Its key opens the owner's whole library, so co-managers do not see it or change it.
- With a password account, the link is needed and the account must use the invited address. With OIDC, a co-manager whose provider marks the invited address as verified is added at sign-in, without the link. An unverified address is never matched.
- An invitation opens an account even when `ALLOW_REGISTRATION=false`, at the invited address only.
- Removing a co-manager takes effect on their next request, and also drops any access they had saved with the sharing password. The settings then offer to change that password, since they may have seen it.
- Deleting your account now names the co-managers of the trees you own, which are deleted for them too.
- In the tree menu, "Admin" is now "Co-manager".
### Search for someone elsewhere
- "Someone elsewhere" in the bottom-right menu opens a panel on the right of the tree: type a name, birth year and place, and search public records. Links to Gallica (old newspapers), FranceArchives (military registers), Mémoire des hommes, Léonore, Geneanet, Open Archives, WikiTree, FreeBMD and FamilySearch open in the browser with the name typed in when the site allows it.
- The server can query deaths in France since 1970 (INSEE records via matchID) and WikiTree, once listed in `SEARCH_SOURCES`. Each source says what it covers and what is sent. The owner adds a result to a person in the tree: empty fields only, marked as needing checking, source kept in the note.
### Trees
- A tree can have an icon, picked from fifteen emoji in Settings > Your tree. It replaces the first letter in the tree menu, on the account page and on the invitation page.
- For whoever manages the tree, the button at the bottom right now reads Edit, with a pencil. The family keeps Add.
- A tree name starting with an emoji no longer shows a broken letter in the menus.
### Fixes
- An admin can switch a person's profile to edit mode from a new Edit button on the profile.
- The "?" of "needs checking" shows whole on round portraits. It was cut by the frame.
- On iPad, the tree no longer stops short of the bottom of the screen. The canvas follows its real size instead of the window height read at load.
## 1.4.0 (2026-10-23)
### Family tabs
- In another family's tab, the person you marked as "That's me" stays on screen, linked to that family by their direct line (the shortest kinship path, both parents at each step, no siblings around), drawn faded. Without a choice, the tree's starting person is used (#138).
- Visitors without an account can say who they are in the tree from their menu. The choice is kept in their browser for that tree (#138).
### Notes on the tree
- A note is attached to the nearest person when it is placed. It only shows in the tabs where that person is drawn, and stays next to them when the layout changes. Notes placed before this version are attached the first time the tree opens (#141).
- A placed text can be edited again. With the select tool, click it twice (#141).
### People
- A person can be marked as needing checking, for a date or a link you are not sure of. A "?" shows on the portrait and a pill on the profile, the family can propose it like any other field, and searching for "à vérifier" lists them (French only for now) (#140).
### Fixes and maintenance
- Memory thumbnails around a portrait are cropped instead of stretched to a square (#142).
- DOMPurify 3.4.17, pulled in by posthog-js, for a critical advisory published on 2026-10-23 (GHSA-cg2g-xh64-v4q7) (#139).
- Minor dependency updates for the app, the API and the landing page (#133, #134, #136).
## 1.3.0 (2026-10-21)
### Memories from Immich
- Admins with Immich configured can add photos and videos from their library, from a person's memories or when adding a moment. Search by context, filter by photos or videos or by face, open a larger preview and pick several at once (#103 by @Askrosm, #128).
- Photos come in as Immich's preview, so iPhone HEIC pictures work, and downloads stop as soon as they pass the size limit (#103).
- Several memories picked at once are added in one go, each titled from its file name (#128).
## 1.2.1 (2026-10-21)
### Fixes
- Clicking a person without a photo opens their profile again. The file picker only opens from "+ photo" in edit mode (#124, reported by @coutadeurf, #125).
- GEDCOM export stays valid 5.5.1. Sex "other" is written as `SEX U`, and `SEX X` is still read on import (#126).
### Self-hosting
- With `PASSWORD_LOGIN=false`, an account that had a password and was linked to OIDC can delete itself by retyping its email after a recent sign-in (#126).
- An email already linked to an OIDC identity can't be claimed by a different subject at the provider (#126).
- HEIC and AVIF files are recognised as images instead of video, and are refused cleanly until they can be converted (#126).
## 1.2.0 (2026-10-20)
### Tree layout
- Rows follow the kinship generation from the tree's starting person: spouses on the same row, parents one row up, children one row down. A shorter branch married into a deeper one no longer sits several rows too high (#67, reported by @SpaceOptimist, #111).
- One tree, never islands. An in-law family attaches to its child at the end of the sibling row, never between two siblings, and each union of a remarriage sits above its own children (#105, #116).
- In-law families wider than a screen open in their own tab, the same tree drawn from the spouse. A pill above the spouse leads there and back, and tabs with the same name show the couple's first names. Search opens the tab holding the person. Tree settings can turn tabs off (#116, #81 reported by @coutadeurf).
- Lines between families are full lines when they cross no other family, with one trunk per sibling row (#83, #90).
### People
- Sex can be set to female, male or other, and is unspecified by default. Relationship names follow it in French and English, with neutral or double forms when unknown. GEDCOM `SEX X` is read and written (#114, suggested by @Arc676, #118).
- Nicknames show under the name in the tree and the profile (#85). The last name is optional and an unknown first name can be ticked (#87, #89).
- Profiles: pills for facts, place of death, adoption, autosave when leaving, a cleared field stays cleared, close by swiping down on phones (#91, #93, #98, #101, #106).
- Portraits can be cropped with the full photo kept, in a round or square frame, and "+ photo" opens the picker right away (#95, #108, #109). Admins can edit a memory (#94).
### Sharing and family
- Guests can propose changes with the pencil, and the owner sees pending proposals on their avatar as soon as the tree opens (#96, #121).
- A simpler start page with two choices, one door for shared trees, and a clearer login (#86, #88, #92).
- The bottom bar is one pill with Add or correct and the account menu (#100, #104, #107). Search results can be tapped in edit mode on phones (#110).
### Self-hosting
- `ALLOW_REGISTRATION=false` closes sign-up. Existing accounts log in and the first account of an empty instance is still allowed (#113, suggested by @milan616, #117).
- OIDC login (experimental) with `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` and `OIDC_REDIRECT_URI`. An existing account is linked by verified email only, and never an account that has a password (sign-up does not verify the address). `PASSWORD_LOGIN=false` turns passwords off (#115, suggested by @Arc676, #122).
### Fixes and maintenance
- GEDCOM import in transactions and much faster on large files, backups restore tested in CI, and other points from the 18/10 audit (#97). A union without a date sits at its logical place in the Life tab (#102).
- Mobile: separated parents drawn as a dotted line, lighter edit bar, dates on the number pad (#82).
- Eleven unused interface modules removed (#119).
## 1.1.0 (2026-10-17)
### Life tab
- Each profile has a new Life tab, a timeline of the person's life. Birth, marriages and death are the milestones, other events sit between them with their place and the person's age, and the close family the tree already knows (children and siblings born, parents, partner or a child dying) shows in grey. A profile with no recorded event still gets a timeline. Sources stay folded until clicked.
- Admins add a moment from the bottom of the tab with what, when (a year, "about"), where, and files. Tapping a moment edits or deletes it.
- A moment can carry several files of any kind, photos, videos, recordings or documents such as a birth record as a PDF. New files join the person's memories, and existing memories can be picked instead.
- Birth, marriage and death accept their own documents, source and note.
- Family members with the sharing link can propose a moment. It waits for the owner's review with its files, in the same proposal, and shows once accepted.
### Dates
- Date fields take a year alone (1950) or a full date, and a year alone reads "1950" instead of "1 January 1950" (#77, reported by @Akasiek).
- A person can be marked as deceased without a date. The tree shows "1908–?" and the profile "Date unknown" (#77).
- Dates imported as "about", "before", "between" or "from … to" keep their wording, and editing a date by hand drops it.
### GEDCOM import and export
- A zip holding the .ged file and its photos (or a GEDZIP .gdz) imports in one go. Portraits and other files are matched with the paths the GEDCOM cites, with no folder to pick.
- Life events are imported and exported (baptism, occupations, residences, military service, custom events, divorce…), with place, notes and sources. Republican calendar dates, adoptions (`PEDI adopted`), nicknames and `1 DEAT Y` are read too. An import then an export keeps them.
- Files cited by the GEDCOM, under a person, an event, a source or a family, become memories with their title and source (#59, reported by @zycker). Heredis places ("Town,INSEE code,département,…") are cleaned up, at import and for trees already imported.
- All notes are kept, shared notes included, and dates are read in full instead of by year.
### Family
- Immich face recognition can fill portraits from an Immich server, set up in Settings > Advanced (#58 by @coutadeurf).
- Siblings can be reordered by dragging one past another in edit mode, or with the arrows on the profile (#60 by @py5932). A new child goes after its siblings.
### First photos
- Tree creation offers "I already have a tree": the .ged file is imported right away, without creating a "you" entry that would end up duplicated.
- GEDCOM import reads the photos each person had in the original software (`OBJE` / `FILE`, inline or by reference, primary photo first). After an import, the owner picks the folder where those photos live and Parenthèse places each portrait by file name. Paths stay on the device, nothing is stored on the server.
- After an import, a window suggests five faces to start with: the close family of the person marked "Me", otherwise the most recent generations.
- For the owner, an empty portrait invites a photo: the empty frame on a profile is a button outside edit mode, and hovering an empty portrait in the tree shows "+ photo".
- Phone photos over 5 MB are scaled down to 2000 px before upload instead of being refused.
- Owner portrait uploads are limited to 120 per minute (was 30), still within the tree's storage quota.
- Anonymous events: `gedcom_imported`, `portraits_bulk_placed`, `portrait_added` (rounded counts, no names, paths or tree address).
### Fixes
- Docker: on a first `docker compose up`, the API now gets 120 s to run its migrations before its health check counts (was 30 s). The web container waits for a healthy API and used to give up, so it had to be started again by hand.
- Children of a family with only one known parent (common in GEDCOM files) are linked to that parent, under a single "Unknown" partner, instead of floating in the tree without a link.
- Importing a GEDCOM file into a tree that already holds it no longer duplicates everyone: people with the same first name, last name, birth and death dates are reused, and existing unions and parent links are kept.
- Parents no longer show "(null)" next to their names when the union has no type.
### Dependencies
- Grouped minor and patch updates for the app, the API and the landing, and GitHub Actions upload-artifact 7 and setup-node 7.
## 1.0.3 (2026-10-10)
### Self-hosting
- Single-container image `ghcr.io/iliesallali/parenthese-aio` (PostgreSQL, API and front end), for Unraid and NAS systems that install one container per app. It needs no configuration: the database, its password and the session key are created in `/data` on first start. Tested in CI by a fresh start, a restart and the smoke test.
- The `parenthese-web` image reads the API address from `API_UPSTREAM` (`api:4000` by default, unchanged for existing installs). Set it when the name `api` is already taken on the Docker network, for example on Umbrel where apps share one network.
## 1.0.2 (2026-10-09)
### Self-hosting
+94
View File
@@ -0,0 +1,94 @@
# syntax=docker/dockerfile:1
# Parenthèse en un seul conteneur : PostgreSQL, API et front servi par nginx. Contexte de build : la racine du dépôt.
# Pour les systèmes qui installent une application par conteneur (Unraid, NAS). docker-compose.yml reste
# l'installation de référence, avec un conteneur par service.
#
# docker run -d --name parenthese -p 8080:80 -v parenthese-data:/data ghcr.io/iliesallali/parenthese-aio
#
# Tout ce qui doit survivre à une mise à jour vit dans /data : base (postgres/), photos et vidéos (media/),
# secrets générés au premier démarrage (secrets/). Voir deploy/aio/entrypoint.sh.
# Étape 1 : API (mêmes étapes que backend/Dockerfile).
FROM node:24-bookworm-slim AS api-build
RUN apt-get update \
&& apt-get install -y --no-install-recommends openssl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY backend/package.json backend/package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY backend/prisma ./prisma
COPY backend/prisma.config.ts backend/tsconfig.json ./
COPY backend/src ./src
RUN npx prisma generate
RUN npm run build
# Étape 2 : front (mêmes étapes que Dockerfile.web).
FROM node:24-alpine AS web-build
WORKDIR /app
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
ENV VITE_API_BASE_URL=""
RUN npm run build
# Étape 3 : image finale.
FROM node:24-bookworm-slim
# postgresql-common d'abord, pour qu'il ne crée pas de base par défaut à l'installation de PostgreSQL :
# la base vit dans /data et naît au premier démarrage.
RUN apt-get update \
&& apt-get install -y --no-install-recommends postgresql-common \
&& sed -i 's/^#\?\s*create_main_cluster.*/create_main_cluster = false/' /etc/postgresql-common/createcluster.conf \
&& apt-get install -y --no-install-recommends postgresql-15 nginx openssl ca-certificates tini \
&& rm -rf /var/lib/apt/lists/* \
&& rm -f /etc/nginx/sites-enabled/default \
&& mkdir -p /run/postgresql && chown postgres:postgres /run/postgresql
WORKDIR /app
COPY backend/package.json backend/package-lock.json ./
RUN npm ci --omit=dev --no-audit --no-fund \
&& npm cache clean --force
# CLI Prisma à part, à la version du package-lock (voir backend/Dockerfile).
RUN PRISMA_VERSION="$(node -p "require('./package-lock.json').packages['node_modules/prisma'].version")" \
&& mkdir -p /opt/prisma \
&& cd /opt/prisma \
&& echo '{"private":true}' > package.json \
&& npm install --no-audit --no-fund "prisma@${PRISMA_VERSION}" \
&& npm cache clean --force
COPY backend/prisma ./prisma
COPY backend/prisma.config.ts ./
COPY --from=api-build /app/dist ./dist
# nginx : la configuration du conteneur web de compose, l'API étant ici sur la même machine.
COPY deploy/nginx.docker.conf /etc/nginx/conf.d/default.conf
RUN sed -i 's|http://api:4000|http://127.0.0.1:4000|g' /etc/nginx/conf.d/default.conf \
&& ! grep -q 'api:4000' /etc/nginx/conf.d/default.conf
COPY --from=web-build /app/dist /usr/share/nginx/html
COPY deploy/aio/entrypoint.sh /usr/local/bin/parenthese-aio
RUN chmod 755 /usr/local/bin/parenthese-aio
ENV NODE_ENV=production \
CHECKPOINT_DISABLE=1 \
PATH=/opt/prisma/node_modules/.bin:/usr/lib/postgresql/15/bin:$PATH
VOLUME /data
EXPOSE 80
HEALTHCHECK --interval=15s --timeout=5s --start-period=60s --retries=5 \
CMD node -e "fetch('http://127.0.0.1/health').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
ENTRYPOINT ["tini", "--", "/usr/local/bin/parenthese-aio"]
+21
View File
@@ -0,0 +1,21 @@
# Contexte de build de Dockerfile.aio (BuildKit lit ce fichier à la place de .dockerignore) :
# l'API, le front, la configuration nginx et le script de démarrage
*
!backend
!frontend
!deploy/nginx.docker.conf
!deploy/aio
backend/node_modules
backend/dist
backend/src/generated
backend/.local
backend/storage
backend/test
backend/openapi
backend/.env
backend/.env.*
frontend/node_modules
frontend/dist
frontend/.env
frontend/.env.*
**/*.log
+8 -1
View File
@@ -19,7 +19,14 @@ RUN npm run build
FROM nginx:1.30-alpine
COPY deploy/nginx.docker.conf /etc/nginx/conf.d/default.conf
# Adresse de l'API lue au démarrage du conteneur, `api:4000` par défaut (le service de docker-compose.yml).
# À changer quand le nom `api` est déjà pris sur le réseau, comme sur Umbrel où les apps partagent un réseau.
# L'image nginx passe les fichiers de /etc/nginx/templates par envsubst et n'y remplace que les variables
# d'environnement définies : $host, $uri et les autres variables de nginx restent telles quelles.
ENV API_UPSTREAM=api:4000
COPY deploy/nginx.docker.conf /etc/nginx/templates/default.conf.template
RUN sed -i 's|http://api:4000|http://${API_UPSTREAM}|g' /etc/nginx/templates/default.conf.template \
&& ! grep -q 'api:4000' /etc/nginx/templates/default.conf.template
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
+52 -4
View File
@@ -53,10 +53,48 @@ docker compose up -d
Le premier démarrage télécharge les images (ou les construit depuis les sources si elles sont introuvables), applique les migrations de la base, puis sert l'application sur [http://localhost](http://localhost). Créez votre compte depuis l'écran d'accueil : le premier compte est un compte comme les autres, il n'y a pas d'administrateur global.
Trois conteneurs tournent : `db` (PostgreSQL 16), `api` (l'API Node) et `web` (nginx qui sert le front et relaie l'API). Seul `web` est exposé, sur le port choisi par `WEB_PORT` (80 par défaut). Si ce port est déjà pris, changez `WEB_PORT` dans `.env` et adaptez `CORS_ORIGIN` en conséquence.
Trois conteneurs tournent : `db` (PostgreSQL 16), `api` (l'API Node) et `web` (nginx qui sert le front et relaie l'API). Seul `web` est exposé, sur le port choisi par `WEB_PORT` (80 par défaut). Si ce port est déjà pris, changez `WEB_PORT` dans `.env` et adaptez `CORS_ORIGIN` en conséquence. Le conteneur `web` trouve l'API à `api:4000` ; si vous lancez les images sous d'autres noms de service, donnez `API_UPSTREAM` à `web` (par exemple `API_UPSTREAM=monapi:4000`).
Pour ouvrir l'instance à la famille depuis Internet, placez un reverse proxy HTTPS devant le port `web` (Caddy, Traefik, nginx) et mettez l'adresse publique dans `CORS_ORIGIN`, par exemple `https://arbre.votre-domaine.fr`.
Pour qu'une personne qui tombe sur l'adresse ne puisse pas s'inscrire et y créer ses arbres, mettez `ALLOW_REGISTRATION=false` dans `.env`, puis lancez `docker compose up -d`. Les comptes existants se connectent et créent leurs arbres comme avant, la famille ouvre toujours les arbres partagés avec le lien et le mot de passe, et l'application ne propose plus de créer de compte. Une instance sans aucun compte accepte quand même le premier, la variable peut donc être posée avant le tout premier démarrage : créez votre compte tout de suite, avant de donner l'adresse.
Votre instance ne pousse personne à y créer un compte. Sur app.parenthese.io, l'instance hébergée, l'application invite à créer son propre arbre : la famille qui ouvre un arbre partagé trouve « Faire le mien » dans le menu Ajouter, les messages de la démo proposent l'inscription, et du 1er décembre au 6 janvier le message d'invitation parle de Noël. Ces invitations sont éteintes par défaut. Pour les allumer, mettez `SIGNUP_PROMPTS=true` dans `.env` (elles restent coupées si l'inscription est fermée).
#### Chercher quelqu'un dans des sources publiques
Le menu « + » propose « Quelqu'un ailleurs » : un panneau à droite de l'arbre où l'on tape un nom pour voir ce que les sources publiques disent de cette personne. Les liens vers des sites sans recherche interrogeable d'ici (presse ancienne sur Gallica, registres matricules sur FranceArchives, Geneanet, FamilySearch et quelques autres, la France d'abord) marchent toujours : votre navigateur ouvre le site avec le nom déjà tapé, et rien ne passe par le serveur.
Le serveur peut aussi interroger lui-même deux sources et afficher leurs résultats dans le panneau. Elles restent éteintes tant que vous ne les nommez pas dans `.env` :
```
# deces : décès en France depuis 1970 (fichier de l'INSEE, cherché par matchID)
# wikitree : profils publics de WikiTree
SEARCH_SOURCES=deces,wikitree
# Facultatif, gratuit sur deces.matchid.io : sans clé, les recherches s'arrêtent après une centaine
MATCHID_TOKEN=
```
Seuls le prénom, le nom et l'année de naissance quittent le serveur, et le panneau l'écrit à côté de chaque source. Un résultat ajouté à l'arbre ne remplit que les champs vides, passe la personne « à vérifier » et garde le lien de la source dans sa note. Le propriétaire de l'arbre ajoute directement ; la famille voit les résultats et les liens. Une clé matchID vaut 30 jours : renouvelez-la depuis votre compte matchID.
#### Connexion par votre fournisseur d'identité (OIDC)
*Expérimental dans la 1.2.0 : testé contre un fournisseur OpenID certifié, vos retours sur de vraies installations sont les bienvenus dans les issues.*
Parenthèse peut confier la connexion à un fournisseur OpenID Connect que vous faites déjà tourner (Authentik, Authelia, Keycloak, Pocket ID, Zitadel…) : la famille garde un seul compte et sa double authentification. Créez chez lui une application OIDC (client confidentiel, flux « authorization code ») dont l'adresse de retour est l'adresse publique de Parenthèse suivie de `/auth/oidc/callback`, puis renseignez dans `.env` :
```bash
OIDC_ISSUER=https://auth.votre-domaine.fr/application/o/parenthese/ # exactement comme le fournisseur l'annonce
OIDC_CLIENT_ID=parenthese
OIDC_CLIENT_SECRET=... # vide pour un client public (PKCE seul)
OIDC_REDIRECT_URI=https://arbre.votre-domaine.fr/auth/oidc/callback
OIDC_PROVIDER_NAME=Authentik # nom affiché sur le bouton
```
L'écran de connexion affiche alors « Se connecter avec Authentik ». À la première connexion, Parenthèse relie le compte du fournisseur au compte Parenthèse qui a le même email, ou en crée un si `ALLOW_REGISTRATION` le permet. Inscription fermée, le fournisseur ne connecte que les personnes qui ont déjà un compte (le premier compte d'une instance vide reste accepté). Un email n'est pris en compte que si le fournisseur le dit vérifié (`email_verified`) ; `OIDC_REQUIRE_VERIFIED_EMAIL=false` lève ce contrôle, pour un fournisseur qui ne l'envoie pas et où personne ne choisit lui-même son adresse. Une fois relié, un compte suit l'identifiant de l'utilisateur chez le fournisseur, pas son email.
`PASSWORD_LOGIN=false` retire les mots de passe Parenthèse : la connexion passe par le fournisseur seulement (l'API refuse de démarrer sans `OIDC_ISSUER`). Un compte créé par le fournisseur n'a pas de mot de passe ; pour le supprimer, on retape son email. `OIDC_SCOPES` (par défaut `openid email profile`) et `OIDC_APP_URL` (où l'app s'ouvre après la connexion, par défaut l'origine de `OIDC_REDIRECT_URI`) se changent rarement.
Une instance auto-hébergée n'envoie aucune mesure d'audience : le suivi d'usage de parenthese.io ne s'active que si une clé PostHog est fournie au build, ce que cette configuration ne fait pas.
Les liens d'aide et de vie privée de l'application mènent à parenthese.io. Pour pointer vers vos propres pages, renseignez `VITE_HELP_URL` et `VITE_PRIVACY_URL` quand vous construisez vous-même le front (voir `frontend/.env.example`).
@@ -69,7 +107,17 @@ docker compose ps # état des conteneurs
docker compose down # arrêt, les données sont conservées
```
Pour vérifier qu'une installation fonctionne de bout en bout, lancez `bash tools/docker-smoke.sh http://localhost` (avec le port si `WEB_PORT` n'est pas 80). Le script a besoin de `curl` et `jq`. Il passe par le port web comme un navigateur : page d'accueil, santé de l'API, création d'un compte, connexion, création puis suppression d'un arbre de test. Le compte de test (`smoke-...@example.com`) reste en base.
Pour vérifier qu'une installation fonctionne de bout en bout, lancez `bash tools/docker-smoke.sh http://localhost` (avec le port si `WEB_PORT` n'est pas 80). Le script a besoin de `curl` et `jq`. Il passe par le port web comme un navigateur : page d'accueil, santé de l'API, création d'un compte, connexion, création puis suppression d'un arbre de test. Le compte de test (`smoke-...@example.com`) reste en base. Avec `ALLOW_REGISTRATION=false`, le script s'arrête à la création du compte, c'est le refus attendu.
### Un seul conteneur (Unraid, NAS)
Pour les systèmes qui installent un conteneur par application, `ghcr.io/iliesallali/parenthese-aio` fait tourner la base, l'API et le front ensemble :
```bash
docker run -d --name parenthese --restart unless-stopped -p 8080:80 -v /chemin/vers/appdata/parenthese:/data ghcr.io/iliesallali/parenthese-aio
```
Rien à remplir. Au premier démarrage, le conteneur crée dans `/data` la base, son mot de passe et la clé des sessions, puis les réutilise. Les photos et vidéos vont dans `/data/media`. Pour fermer la création de compte, ajoutez `-e ALLOW_REGISTRATION=false` (sur Unraid, « Allow registration » à `false`). Sauvegardez tout le dossier `/data` conteneur arrêté, ou exportez la base pendant qu'il tourne avec `docker exec parenthese runuser -u postgres -- pg_dump parenthese > parenthese.sql`. La base est ici PostgreSQL 15, l'installation compose utilise la 16.
### Où sont les données
@@ -89,7 +137,7 @@ docker run --rm -v parenthese_media:/data -v "$PWD":/backup alpine tar -czf /bac
La première commande sauvegarde la base. Elle contient les personnes, leurs liens, les comptes et les contributions, et elle sait à qui appartient chaque photo, vidéo ou enregistrement. La seconde sauvegarde les fichiers eux-mêmes. Il faut les deux. Restaurez la base seule et les souvenirs pointent vers des fichiers disparus. Restaurez l'archive seule et vous avez un dossier de fichiers rattachés à personne. L'export GEDCOM n'est pas une sauvegarde non plus. Il sert à emmener l'arbre vers un autre logiciel de généalogie et laisse de côté les médias et les annotations.
Adaptez `-U parenthese` et le nom de base si vous avez changé `POSTGRES_USER` ou `POSTGRES_DB`. Pour restaurer la base : `docker compose exec -T db pg_restore -U parenthese -d parenthese --clean < parenthese-db-AAAA-MM-JJ.dump`, puis décompressez l'archive des médias dans le volume avec la même commande `docker run`, `tar -xzf` à la place de `-czf`.
Adaptez `-U parenthese` et le nom de base si vous avez changé `POSTGRES_USER` ou `POSTGRES_DB`. Pour restaurer la base : `docker compose exec -T db pg_restore -U parenthese -d parenthese --clean --if-exists < parenthese-db-AAAA-MM-JJ.dump`, puis décompressez l'archive des médias dans le volume avec la même commande `docker run`, `tar -xzf` à la place de `-czf`.
Le script `deploy/backup.sh` fait la même chose (dump PostgreSQL, archive des médias, sommes de contrôle, rotation, envoi S3 optionnel) pour une installation sans Docker : il lit `backend/.env` et a besoin de `pg_dump` sur la machine. Ses réglages passent par les variables `BACKUP_ROOT`, `BACKUP_RETENTION_DAYS` et `BACKUP_S3_BUCKET`.
@@ -101,7 +149,7 @@ docker compose pull && docker compose up -d
`latest` suit la branche main. Pour rester sur une version, renseignez `PARENTHESE_VERSION` dans `.env` (par exemple `1.0`). Depuis un clone, `git pull && docker compose up -d --build` reconstruit depuis les sources.
Les migrations de base de données s'appliquent toutes seules au démarrage de l'API. Faites une sauvegarde avant, par habitude.
Les migrations de base de données s'appliquent toutes seules au démarrage de l'API, et une migration ne se défait pas. Lancez les deux commandes de sauvegarde ci-dessus avant chaque mise à jour. L'image tout-en-un le fait seule : avant d'appliquer une migration, elle copie la base dans `/data/backups` et garde les cinq dernières copies.
## Développer
+56 -6
View File
@@ -4,12 +4,12 @@
A family tree you explore like a map, with the photos, videos and voices of each person. Share it with one link and a password: relatives look around without an account and add what they know.
Website: [parenthese.io/en](https://parenthese.io/en/) · Demo, no account needed: [app.parenthese.io](https://app.parenthese.io/?lang=en) · [Version française](README.fr.md)
Website: [parenthese.io/en](https://parenthese.io/en/) · Demo, no account needed: [app.parenthese.io](https://app.parenthese.io/?demo&lang=en) · [Version française](README.fr.md)
![The demo tree, with Jean Dupont's profile open and his photos](docs/demo-tree.png)
- **License:** source-available under [PolyForm Noncommercial 1.0.0](LICENSE). Free to read, modify and self-host for non-commercial use. Not an OSI open source license, see [License](#license).
- **How it was made:** designed and directed by Ilies Allali. Most of the code was written with Claude Code (Anthropic), then reviewed and tested before being published.
- **How it was made:** designed and directed by [Ilies Allali](https://iliesallali.design/). Most of the code was written with Claude Code (Anthropic), then reviewed and tested before being published.
## What it does
@@ -59,10 +59,50 @@ docker compose up -d
The first start downloads the images (or builds them from the source when they can't be pulled), applies the database migrations, then serves the app on [http://localhost](http://localhost). Create your account from the home screen. The first account is an account like any other, there is no global administrator.
Three containers run: `db` (PostgreSQL 16), `api` (the Node API) and `web` (nginx, which serves the front end and proxies the API). Only `web` is exposed, on the port set by `WEB_PORT` (80 by default). If that port is taken, change `WEB_PORT` in `.env` and update `CORS_ORIGIN` to match.
Three containers run: `db` (PostgreSQL 16), `api` (the Node API) and `web` (nginx, which serves the front end and proxies the API). Only `web` is exposed, on the port set by `WEB_PORT` (80 by default). If that port is taken, change `WEB_PORT` in `.env` and update `CORS_ORIGIN` to match. The `web` container finds the API at `api:4000`; if you run the images under other service names, set `API_UPSTREAM` on `web` (for example `API_UPSTREAM=myapi:4000`).
To open the instance to your family over the Internet, put an HTTPS reverse proxy in front of the `web` port (Caddy, Traefik, nginx) and set the public address in `CORS_ORIGIN`, for example `https://tree.your-domain.com`.
So that people who come across the address can't sign up and start their own trees, set `ALLOW_REGISTRATION=false` in `.env`, then run `docker compose up -d`. Existing accounts keep signing in and creating trees, relatives still open shared trees with the link and password, and the app no longer offers to create an account. An instance with no account at all still accepts the first one, so the variable can be set before the very first start: create your own account right away, before you give out the address.
Your instance doesn't push anyone to create an account. On app.parenthese.io, the hosted instance, the app suggests starting your own tree: relatives who open a shared tree find « Faire le mien » ("Make my own") in the Add menu, the demo's messages offer to sign up, and from December 1 to January 6 the invitation message mentions Christmas. These prompts are off by default. To turn them on, set `SIGNUP_PROMPTS=true` in `.env` (they stay off while registration is closed).
#### Search for someone in public records
The "+" menu offers "Someone elsewhere": a panel on the right of the tree where you type a name and see what public records hold about that person. Links to sites without a search we can query from here (old newspapers on Gallica, military registers on FranceArchives, Geneanet, FamilySearch and a few others, France first) always work: your browser opens the site with the name already typed in, and nothing goes through the server.
The server can also query two sources itself and show the results in the panel. They stay off until you list them in `.env`:
```
# deces: deaths in France since 1970 (INSEE death records, searched through matchID)
# wikitree: WikiTree public profiles
SEARCH_SOURCES=deces,wikitree
# Optional, free from deces.matchid.io: anonymous searches stop after about a hundred
MATCHID_TOKEN=
```
Only the first name, last name and birth year leave the server, and the panel says so next to each source. A result added to the tree only fills empty fields, marks the person as needing checking and keeps the source link in their note. The tree owner adds directly; relatives see the results and the links. A matchID key is valid for 30 days: renew it from your matchID account.
#### Sign in with your identity provider (OIDC)
*Used with Pocket ID on a real setup (#153).*
Parenthèse can hand sign-in to an OpenID Connect provider you already run (Authentik, Authelia, Keycloak, Pocket ID, Zitadel…), so your family reuses one account and its two-factor settings. Create an OIDC application (confidential client, authorization code flow) whose redirect URI is the public address of Parenthèse followed by `/auth/oidc/callback`, then set in `.env`:
```bash
OIDC_ISSUER=https://auth.your-domain.com/application/o/parenthese/ # exactly as the provider announces it
OIDC_CLIENT_ID=parenthese
OIDC_CLIENT_SECRET=... # empty for a public client (PKCE only)
OIDC_REDIRECT_URI=https://tree.your-domain.com/auth/oidc/callback
OIDC_PROVIDER_NAME=Authentik # shown on the button
```
The `api` container talks to the provider itself, to read its configuration and exchange the sign-in code. If your Docker network is marked `internal`, give `api` a network that can reach the provider, not just `web`.
The sign-in screen then shows "Sign in with Authentik". On a first sign-in, Parenthèse links the provider account to the Parenthèse account with the same email, or creates one when `ALLOW_REGISTRATION` allows it. With registration closed, the provider only signs in people who already have an account (the first account of an empty instance is still accepted). Emails are only trusted when the provider marks them verified (`email_verified`); `OIDC_REQUIRE_VERIFIED_EMAIL=false` lifts that check, for a provider that doesn't send the claim and where nobody picks their own address. Once linked, an account stays tied to the provider's user id, not to the email.
`PASSWORD_LOGIN=false` removes Parenthèse passwords altogether: sign-in goes through the provider only (the API refuses to start without `OIDC_ISSUER`). Accounts created through the provider have no password; to delete one, its owner types their email instead. Accounts that already have a password are not linked to the provider by email: before switching, each owner opens their account and clicks « Se connecter aussi avec … » ("Also sign in with …"), then signs in with the provider once. `OIDC_SCOPES` (default `openid email profile`) and `OIDC_APP_URL` (where the app opens after sign-in, by default the origin of `OIDC_REDIRECT_URI`) rarely need changing.
A self-hosted instance sends no analytics. Usage tracking on parenthese.io only turns on when a PostHog key is provided at build time, which this setup does not do.
The help and privacy links in the app point to parenthese.io. To use your own pages, set `VITE_HELP_URL` and `VITE_PRIVACY_URL` when you build the front end yourself (see `frontend/.env.example`).
@@ -75,7 +115,17 @@ docker compose ps # container status
docker compose down # stop, data is kept
```
To check that an installation works end to end, run `bash tools/docker-smoke.sh http://localhost` (add the port if `WEB_PORT` is not 80). The script needs `curl` and `jq`. It goes through the web port like a browser: home page, API health, account creation, sign-in, creating then deleting a test tree. The test account (`smoke-...@example.com`) stays in the database.
To check that an installation works end to end, run `bash tools/docker-smoke.sh http://localhost` (add the port if `WEB_PORT` is not 80). The script needs `curl` and `jq`. It goes through the web port like a browser: home page, API health, account creation, sign-in, creating then deleting a test tree. The test account (`smoke-...@example.com`) stays in the database. With `ALLOW_REGISTRATION=false` the script stops at account creation, which is the expected refusal.
### Single container (Unraid, NAS)
For systems that install one container per app, `ghcr.io/iliesallali/parenthese-aio` runs the database, the API and the front end together:
```bash
docker run -d --name parenthese --restart unless-stopped -p 8080:80 -v /path/to/appdata/parenthese:/data ghcr.io/iliesallali/parenthese-aio
```
There is nothing to fill in. On first start the container creates the database, its password and the session key in `/data`, and reuses them afterwards. Photos and videos go to `/data/media`. To close account creation, add `-e ALLOW_REGISTRATION=false` (on Unraid, set "Allow registration" to `false`). Back up the whole `/data` folder with the container stopped, or dump the database while it runs with `docker exec parenthese runuser -u postgres -- pg_dump parenthese > parenthese.sql`. The database is PostgreSQL 15 here, the compose setup uses 16.
### Where the data lives
@@ -95,7 +145,7 @@ docker run --rm -v parenthese_media:/data -v "$PWD":/backup alpine tar -czf /bac
The first command saves the database. It holds the people, their links, the accounts and the contributions, plus the record of which photo, video or recording belongs to whom. The second saves the files themselves. You need both. Restore only the database and the memories point to files that are gone. Restore only the archive and you get a folder of files with no names attached. The GEDCOM export is not a backup either. It moves the tree to another genealogy program and leaves the media and the annotations behind.
Adjust `-U parenthese` and the database name if you changed `POSTGRES_USER` or `POSTGRES_DB`. To restore the database, run `docker compose exec -T db pg_restore -U parenthese -d parenthese --clean < parenthese-db-YYYY-MM-DD.dump`, then extract the media archive into the volume with the same `docker run` command, using `tar -xzf` instead of `-czf`.
Adjust `-U parenthese` and the database name if you changed `POSTGRES_USER` or `POSTGRES_DB`. To restore the database, run `docker compose exec -T db pg_restore -U parenthese -d parenthese --clean --if-exists < parenthese-db-YYYY-MM-DD.dump`, then extract the media archive into the volume with the same `docker run` command, using `tar -xzf` instead of `-czf`.
The `deploy/backup.sh` script does the same (PostgreSQL dump, media archive, checksums, rotation, optional S3 upload) for an installation without Docker. It reads `backend/.env` and needs `pg_dump` on the machine. It is configured through the `BACKUP_ROOT`, `BACKUP_RETENTION_DAYS` and `BACKUP_S3_BUCKET` variables.
@@ -107,7 +157,7 @@ docker compose pull && docker compose up -d
`latest` follows the main branch. To stay on a version, set `PARENTHESE_VERSION` in `.env` (for example `1.0`). From a clone, `git pull && docker compose up -d --build` builds from the source instead.
Database migrations apply on their own when the API starts. Make a backup first, as a habit.
Database migrations apply on their own when the API starts, and a migration cannot be undone. Run the two backup commands above before each update. The all-in-one image does it by itself: before applying a migration, it copies the database to `/data/backups` and keeps the last five copies.
## Development
+1
View File
@@ -1,5 +1,6 @@
node_modules
dist
src/generated
.local
storage
test
+20
View File
@@ -6,3 +6,23 @@ JWT_EXPIRES_IN="7d"
JWT_TREE_ACCESS_EXPIRES_IN="24h"
CORS_ORIGIN="http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:5176"
MEDIA_STORAGE_PATH="./storage/media"
# Reconnaissance des visages : instances Immich autorisées sur ce serveur, séparées par des
# virgules. Entrée « Libellé=https://adresse » ou « https://adresse » (nom d'hôte comme libellé,
# donc un libellé si deux instances partagent un nom). Les administrateurs de chaque arbre choisissent
# l'instance par son nom et donnent leur propre clé d'API. Sans cette liste, la fonction reste éteinte.
IMMICH_ALLOWED_API_URLS=""
# Création de compte. À false, seuls les comptes existants se connectent ; une instance sans aucun
# compte accepte quand même le premier, celui du propriétaire.
ALLOW_REGISTRATION=true
# Invitations à créer son propre arbre (famille, démo, message de Noël). Éteintes par défaut, allumées sur
# l'instance hébergée.
SIGNUP_PROMPTS=false
# Connexion par un fournisseur OIDC, éteinte sans OIDC_ISSUER (voir le README). PASSWORD_LOGIN=false retire
# les mots de passe Parenthèse.
OIDC_ISSUER=""
OIDC_CLIENT_ID=""
OIDC_CLIENT_SECRET=""
OIDC_REDIRECT_URI=""
OIDC_PROVIDER_NAME="SSO"
OIDC_REQUIRE_VERIFIED_EMAIL=true
PASSWORD_LOGIN=true
+24
View File
@@ -23,3 +23,27 @@ CORS_ORIGIN="https://arbre.example.org"
# Dossier des médias envoyés. Un chemin relatif part du dossier backend/.
MEDIA_STORAGE_PATH="/var/lib/parenthese/media"
# Reconnaissance des visages : instances Immich autorisées sur ce serveur, séparées par des
# virgules. Entrée « Libellé=https://adresse » ou « https://adresse » (nom d'hôte comme libellé,
# donc un libellé si deux instances partagent un nom). Les administrateurs de chaque arbre choisissent
# l'instance par son nom et donnent leur propre clé d'API. Sans cette liste, la fonction reste éteinte.
IMMICH_ALLOWED_API_URLS=""
# Panneau « Quelqu'un ailleurs » : sources interrogées par le serveur (deces, wikitree), vide = aucune.
# MATCHID_TOKEN : clé gratuite de deces.matchid.io, indispensable au-delà d'une centaine de recherches.
SEARCH_SOURCES=""
MATCHID_TOKEN=""
# Création de compte. À false, seuls les comptes existants se connectent ; une instance sans aucun
# compte accepte quand même le premier, celui du propriétaire.
ALLOW_REGISTRATION=true
# Connexion par un fournisseur OIDC, éteinte sans OIDC_ISSUER (voir le README). PASSWORD_LOGIN=false retire
# les mots de passe Parenthèse.
OIDC_ISSUER=""
OIDC_CLIENT_ID=""
OIDC_CLIENT_SECRET=""
OIDC_REDIRECT_URI=""
OIDC_PROVIDER_NAME="SSO"
OIDC_REQUIRE_VERIFIED_EMAIL=true
PASSWORD_LOGIN=true
+3
View File
@@ -3,3 +3,6 @@ dist/
.env
.local/
*.log
# Client Prisma généré (prisma generate)
src/generated/
+7 -8
View File
@@ -1,12 +1,12 @@
# syntax=docker/dockerfile:1
# API Parenthèse (Fastify + Prisma). Contexte de build : le dossier backend/.
# Étape 1 : dépendances complètes, client Prisma, compilation TypeScript.
# Étape 1 : dépendances complètes, client Prisma (généré dans src/generated), compilation TypeScript.
# Étape 2 : image finale avec les seules dépendances de production, plus la CLI Prisma
# installée à part (nécessaire à `prisma migrate deploy` au démarrage).
FROM node:24-bookworm-slim AS build
# openssl : requis par les moteurs Prisma (cible debian-openssl-3.0.x).
# openssl : requis par le moteur de migration de Prisma (cible debian-openssl-3.0.x).
RUN apt-get update \
&& apt-get install -y --no-install-recommends openssl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
@@ -14,12 +14,12 @@ RUN apt-get update \
WORKDIR /app
COPY package.json package-lock.json ./
COPY prisma ./prisma
RUN npm ci --no-audit --no-fund
RUN npx prisma generate
COPY tsconfig.json ./
COPY prisma ./prisma
COPY prisma.config.ts tsconfig.json ./
COPY src ./src
RUN npx prisma generate
RUN npm run build
@@ -46,10 +46,9 @@ RUN PRISMA_VERSION="$(node -p "require('./package-lock.json').packages['node_mod
&& npm install --no-audit --no-fund "prisma@${PRISMA_VERSION}" \
&& npm cache clean --force
# Client Prisma généré à l'étape 1 (même image de base, même cible de moteur).
COPY --from=build /app/node_modules/.prisma ./node_modules/.prisma
# Schéma + migrations : indispensables à `prisma migrate deploy`.
# Schéma, migrations et configuration : indispensables à `prisma migrate deploy`. Le client Prisma est compilé dans dist.
COPY prisma ./prisma
COPY prisma.config.ts ./
COPY --from=build /app/dist ./dist
RUN mkdir -p /app/storage/media && chown -R node:node /app/storage
+161
View File
@@ -289,6 +289,27 @@ components:
displayOrder:
type: integer
required: [parentPersonId, childPersonId]
ChildrenOrderRequest:
type: object
required: [order]
properties:
order:
type: array
minItems: 2
maxItems: 200
description: Children in the wanted order, each with the links that attach it to its parents
items:
type: object
required: [childId, linkIds]
properties:
childId:
type: string
linkIds:
type: array
minItems: 1
maxItems: 4
items:
type: string
PatchParentChildLinkRequest:
type: object
properties:
@@ -517,6 +538,89 @@ components:
required: [decision]
paths:
/auth/options:
get:
tags: [Auth]
summary: What the sign-in screen can offer on this instance
responses:
'200':
description: Instance options
content:
application/json:
schema:
type: object
properties:
registration:
type: boolean
description: false when ALLOW_REGISTRATION=false and the instance already has an account
passwordLogin:
type: boolean
description: false when PASSWORD_LOGIN=false (sign-in through the OIDC provider only)
oidc:
type: object
nullable: true
description: OIDC provider shown on the sign-in screen, null when OIDC_ISSUER is not set
properties:
name:
type: string
signupPrompts:
type: boolean
description: true when SIGNUP_PROMPTS=true and accounts can be created. The app then suggests starting one's own tree to relatives and demo visitors
required: [registration, passwordLogin, oidc, signupPrompts]
/auth/oidc/start:
get:
tags: [Auth]
summary: Start an OIDC sign-in (browser navigation)
description: Sets a signed, httpOnly cookie holding state, nonce and PKCE verifier, then redirects to the provider. Only exists when OIDC_ISSUER is set.
parameters:
- name: returnTo
in: query
required: false
schema:
type: string
description: Either / or /arbre/<slug>, anything else falls back to /
responses:
'302':
description: Redirect to the provider, or back to the app with "#oidc_error=oidc_unavailable"
/auth/oidc/callback:
get:
tags: [Auth]
summary: Provider redirect target (browser navigation)
description: Checks the cookie, then lets openid-client exchange the code and verify the ID token (signature, issuer, audience, expiry, nonce, state, PKCE). Finds the account by provider subject, links a verified email, or creates an account when registration allows it.
responses:
'302':
description: Back to the app with "#oidc=<one-time handoff>" or "#oidc_error=oidc_failed|oidc_email_unverified|oidc_email_missing|registration_closed"
/auth/oidc/exchange:
post:
tags: [Auth]
summary: Trade the one-time handoff for a session
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
code:
type: string
required: [code]
responses:
'200':
description: Session
content:
application/json:
schema:
$ref: '#/components/schemas/AuthResponse'
'401':
description: Handoff invalid, expired or already used (invalid_handoff)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/auth/register:
post:
tags: [Auth]
@@ -540,6 +644,12 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'403':
description: Registration closed on this instance (registration_closed), or PASSWORD_LOGIN=false (password_login_disabled)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'409':
description: Email already exists
content:
@@ -576,6 +686,12 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'403':
description: PASSWORD_LOGIN=false (password_login_disabled)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'401':
description: Invalid credentials
content:
@@ -1541,6 +1657,51 @@ paths:
schema:
$ref: '#/components/schemas/ErrorResponse'
/trees/{id}/links/order:
put:
tags: [Trees]
summary: Reorder siblings (rewrites displayOrder of their parent-child links)
security:
- BearerAuth: []
parameters:
- in: path
name: id
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ChildrenOrderRequest'
responses:
'200':
description: Order saved
'400':
description: Invalid payload, links that do not belong to their child, or children without a shared parent
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'401':
description: Authentication required
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'403':
description: Forbidden
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'404':
description: Link not found
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/trees/{id}/links/{linkId}:
patch:
tags: [Trees]
+1880 -294
View File
File diff suppressed because it is too large. Load diff
+15 -9
View File
@@ -17,21 +17,27 @@
"prisma:deploy": "prisma migrate deploy"
},
"dependencies": {
"@fastify/cookie": "^11.1.5",
"@fastify/cors": "^11.3.0",
"@fastify/jwt": "^10.2.2",
"@fastify/jwt": "^10.2.6",
"@fastify/rate-limit": "^11.2.0",
"@prisma/client": "^6.15.0",
"bcryptjs": "^3.0.2",
"dotenv": "^18.0.5",
"fastify": "^5.6.1",
"@node-rs/bcrypt": "^2.0.0",
"@prisma/adapter-pg": "^7.10.1",
"@prisma/client": "^7.10.1",
"blurhash": "^2.0.5",
"dotenv": "^18.0.9",
"fastify": "^5.12.7",
"openid-client": "^6.8.8",
"pg": "^8.23.1",
"sharp": "^0.35.5",
"zod": "^4.6.5"
"zod": "^4.6.7"
},
"devDependencies": {
"@types/node": "^24.3.0",
"prisma": "^6.15.0",
"@types/node": "^24.19.2",
"@types/pg": "^8.23.1",
"prisma": "^7.10.1",
"tsx": "^4.23.15",
"typescript": "^7.0.2",
"vitest": "^5.0.3"
"vitest": "^5.0.5"
}
}
+13
View File
@@ -0,0 +1,13 @@
import { config } from 'dotenv'
import type { PrismaConfig } from 'prisma/config'
// Configuration du CLI Prisma (generate, migrate). Depuis Prisma 7, le CLI ne lit plus .env tout seul. Seul un import
// de type vient de prisma/config : dans les images, le CLI est installé à part (/opt/prisma) et ce fichier doit se
// charger sans lui. Sans DATABASE_URL (generate pendant un build), l'adresse reste vide.
config({ quiet: true })
export default {
schema: 'prisma/schema.prisma',
migrations: { path: 'prisma/migrations' },
datasource: { url: process.env.DATABASE_URL ?? '' },
} satisfies PrismaConfig
@@ -0,0 +1,2 @@
-- Association d'une personne à une personne Immich (reconnaissance des visages).
ALTER TABLE "persons" ADD COLUMN "immichPersonId" TEXT;
@@ -0,0 +1,13 @@
-- Connexion Immich propre à chaque arbre : adresse de l'instance et clé d'API chiffrée.
-- Aucune ligne n'est créée pour les arbres existants : ils restent sans accès Immich
-- jusqu'à ce que leurs administrateurs configurent la connexion.
CREATE TABLE "tree_immich_configs" (
"treeId" TEXT NOT NULL,
"apiUrl" TEXT NOT NULL,
"apiKeyEnc" TEXT NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "tree_immich_configs_pkey" PRIMARY KEY ("treeId")
);
ALTER TABLE "tree_immich_configs" ADD CONSTRAINT "tree_immich_configs_treeId_fkey"
FOREIGN KEY ("treeId") REFERENCES "trees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,47 @@
-- AlterTable
ALTER TABLE "persons" ADD COLUMN "birthDateEnd" TIMESTAMP(3),
ADD COLUMN "birthDatePrecision" TEXT,
ADD COLUMN "deathDateEnd" TIMESTAMP(3),
ADD COLUMN "deathDatePrecision" TEXT,
ADD COLUMN "deathPlace" TEXT,
ADD COLUMN "nickname" TEXT;
-- AlterTable
ALTER TABLE "unions" ADD COLUMN "startDatePrecision" TEXT,
ADD COLUMN "startPlace" TEXT;
-- CreateTable
CREATE TABLE "life_events" (
"id" TEXT NOT NULL,
"treeId" TEXT NOT NULL,
"personId" TEXT,
"unionId" TEXT,
"type" TEXT NOT NULL,
"label" TEXT,
"date" TIMESTAMP(3),
"dateEnd" TIMESTAMP(3),
"datePrecision" TEXT,
"dateText" TEXT,
"place" TEXT,
"description" TEXT,
"notes" TEXT,
"sources" TEXT,
"displayOrder" INTEGER NOT NULL DEFAULT 1,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"deletedAt" TIMESTAMP(3),
CONSTRAINT "life_events_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "life_events_treeId_idx" ON "life_events"("treeId");
-- CreateIndex
CREATE INDEX "life_events_treeId_personId_idx" ON "life_events"("treeId", "personId");
-- CreateIndex
CREATE INDEX "life_events_treeId_unionId_idx" ON "life_events"("treeId", "unionId");
-- AddForeignKey
ALTER TABLE "life_events" ADD CONSTRAINT "life_events_treeId_fkey" FOREIGN KEY ("treeId") REFERENCES "trees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- Photo rattachée à un événement de vie (souvenir de la personne), facultative
ALTER TABLE "life_events" ADD COLUMN "mediaId" TEXT;
@@ -0,0 +1,10 @@
-- Moments proposés par la famille (relus par l'administrateur) et plusieurs souvenirs par moment
ALTER TABLE "life_events" ADD COLUMN "status" TEXT NOT NULL DEFAULT 'approved';
ALTER TABLE "life_events" ADD COLUMN "contributionSessionId" TEXT;
ALTER TABLE "media_items" ADD COLUMN "lifeEventId" TEXT;
CREATE INDEX "media_items_lifeEventId_idx" ON "media_items"("lifeEventId");
-- Les photos déjà rattachées par life_events.mediaId passent sur le souvenir
UPDATE "media_items" AS m SET "lifeEventId" = e."id"
FROM "life_events" AS e
WHERE e."mediaId" = m."id" AND e."deletedAt" IS NULL;
@@ -0,0 +1,2 @@
-- Moment proposé par la famille, relu comme un souvenir
ALTER TYPE "ContributionEntityType" ADD VALUE 'life_event';
@@ -0,0 +1,2 @@
-- Personne décédée sans date connue (issue #77)
ALTER TABLE "persons" ADD COLUMN "isDeceased" BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,2 @@
-- Prénom du compte, facultatif (comptes existants : vide). Migration additive.
ALTER TABLE "users" ADD COLUMN "firstName" TEXT;
@@ -0,0 +1,3 @@
-- Cadrage du portrait (feat/recadrage-portrait). Migration additive : colonne nullable, sans défaut.
-- AlterTable
ALTER TABLE "persons" ADD COLUMN "avatarCrop" JSONB;
@@ -0,0 +1,25 @@
-- Connexion OIDC (#115) : un compte peut naître sans mot de passe, et ses identités chez un fournisseur sont gardées
-- à part (émetteur + sub). Migration additive : les comptes existants gardent leur mot de passe.
-- AlterTable
ALTER TABLE "users" ALTER COLUMN "passwordHash" DROP NOT NULL;
-- CreateTable
CREATE TABLE "user_identities" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"issuer" TEXT NOT NULL,
"subject" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "user_identities_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "user_identities_userId_idx" ON "user_identities"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "user_identities_issuer_subject_key" ON "user_identities"("issuer", "subject");
-- AddForeignKey
ALTER TABLE "user_identities" ADD CONSTRAINT "user_identities_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,30 @@
-- Personnes identifiées sur un souvenir (feat/souvenirs-personnes). Migration additive : nouvelle table, nouvelle valeur d'énumération.
-- AlterEnum
ALTER TYPE "ContributionEntityType" ADD VALUE 'media_people';
-- CreateTable
CREATE TABLE "media_person_tags" (
"id" TEXT NOT NULL,
"treeId" TEXT NOT NULL,
"mediaId" TEXT NOT NULL,
"personId" TEXT NOT NULL,
"x" DOUBLE PRECISION,
"y" DOUBLE PRECISION,
"createdBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "media_person_tags_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "media_person_tags_treeId_idx" ON "media_person_tags"("treeId");
-- CreateIndex
CREATE INDEX "media_person_tags_treeId_personId_idx" ON "media_person_tags"("treeId", "personId");
-- CreateIndex
CREATE UNIQUE INDEX "media_person_tags_mediaId_personId_key" ON "media_person_tags"("mediaId", "personId");
-- AddForeignKey
ALTER TABLE "media_person_tags" ADD CONSTRAINT "media_person_tags_treeId_fkey" FOREIGN KEY ("treeId") REFERENCES "trees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
-- « À vérifier » sur une personne (feat/a-verifier). Migration additive : booléen à faux par défaut.
-- AlterTable
ALTER TABLE "persons" ADD COLUMN "toVerify" BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,32 @@
-- Co-gestion d'un arbre (issue #145) : invitations du propriétaire. Migration additive, une table neuve.
-- CreateTable
CREATE TABLE "tree_invitations" (
"id" TEXT NOT NULL,
"treeId" TEXT NOT NULL,
"email" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"invitedByUserId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3) NOT NULL,
"acceptedAt" TIMESTAMP(3),
"acceptedUserId" TEXT,
"revokedAt" TIMESTAMP(3),
CONSTRAINT "tree_invitations_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "tree_invitations_tokenHash_key" ON "tree_invitations"("tokenHash");
-- CreateIndex
CREATE INDEX "tree_invitations_treeId_idx" ON "tree_invitations"("treeId");
-- CreateIndex
CREATE INDEX "tree_invitations_email_idx" ON "tree_invitations"("email");
-- AddForeignKey
ALTER TABLE "tree_invitations" ADD CONSTRAINT "tree_invitations_treeId_fkey" FOREIGN KEY ("treeId") REFERENCES "trees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tree_invitations" ADD CONSTRAINT "tree_invitations_invitedByUserId_fkey" FOREIGN KEY ("invitedByUserId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- Icône d'un arbre (un emoji), facultative. Migration additive : colonne vide par défaut.
ALTER TABLE "trees" ADD COLUMN "icon" TEXT;
@@ -0,0 +1,16 @@
-- Photos posées sur l'arbre par la famille, en attente de relecture (1.5.1). Migration additive, une table neuve.
-- CreateTable
CREATE TABLE "annotation_photo_uploads" (
"id" TEXT NOT NULL,
"treeId" TEXT NOT NULL,
"filePath" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "annotation_photo_uploads_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "annotation_photo_uploads_treeId_createdAt_idx" ON "annotation_photo_uploads"("treeId", "createdAt");
-- AddForeignKey
ALTER TABLE "annotation_photo_uploads" ADD CONSTRAINT "annotation_photo_uploads_treeId_fkey" FOREIGN KEY ("treeId") REFERENCES "trees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,12 @@
-- Sessions fermées (déconnexion, compte supprimé), gardées en base pour survivre au redémarrage de l'API.
-- Migration additive, une table neuve.
-- CreateTable
CREATE TABLE "revoked_sessions" (
"jti" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "revoked_sessions_pkey" PRIMARY KEY ("jti")
);
-- CreateIndex
CREATE INDEX "revoked_sessions_expiresAt_idx" ON "revoked_sessions"("expiresAt");
@@ -0,0 +1,7 @@
-- Aperçus flous (BlurHash) des portraits et des photos, montrés pendant le chargement.
-- Migration additive : deux colonnes facultatives, remplies ensuite par l'API (lib/blurhash.ts).
-- AlterTable
ALTER TABLE "persons" ADD COLUMN "avatarBlurhash" TEXT;
-- AlterTable
ALTER TABLE "media_items" ADD COLUMN "blurhash" TEXT;
+311 -139
View File
@@ -1,10 +1,14 @@
// Client généré dans src/generated (ignoré par git), compilé avec le reste dans dist : rien à copier depuis
// node_modules dans les images. L'adresse de la base vient de prisma.config.ts.
generator client {
provider = "prisma-client-js"
provider = "prisma-client"
output = "../src/generated/prisma"
moduleFormat = "esm"
importFileExtension = "js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum MembershipRole {
@@ -39,6 +43,9 @@ enum ContributionEntityType {
parent_child_link
annotation
media
life_event
// Personnes identifiées sur un souvenir (remplace la liste entière)
media_people
}
// Souvenir envoyé par la famille : en attente jusqu'à la relecture du propriétaire
@@ -78,51 +85,77 @@ enum SharedAccessRole {
}
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
lastOpenedTreeId String?
lastOpenedAccessMode String?
lastOpenedRole String?
lastOpenedAt DateTime?
createdAt DateTime @default(now())
ownedTrees Tree[] @relation("TreeOwner")
memberships TreeMembership[]
sharedTreeAccesses UserTreeAccess[]
id String @id @default(cuid())
email String @unique
firstName String?
// Vide pour un compte né d'une connexion OIDC : il n'a pas de mot de passe Parenthèse
passwordHash String?
lastOpenedTreeId String?
lastOpenedAccessMode String?
lastOpenedRole String?
lastOpenedAt DateTime?
createdAt DateTime @default(now())
ownedTrees Tree[] @relation("TreeOwner")
memberships TreeMembership[]
sharedTreeAccesses UserTreeAccess[]
submittedContributionSessions ContributionSession[] @relation("ContributionSubmittedBy")
reviewedContributionSessions ContributionSession[] @relation("ContributionReviewedBy")
treeVisits TreeVisit[]
lastOpenedTree Tree? @relation("UserLastOpenedTree", fields: [lastOpenedTreeId], references: [id], onDelete: SetNull)
reviewedContributionSessions ContributionSession[] @relation("ContributionReviewedBy")
treeVisits TreeVisit[]
identities UserIdentity[]
sentTreeInvitations TreeInvitation[] @relation("TreeInvitationSentBy")
lastOpenedTree Tree? @relation("UserLastOpenedTree", fields: [lastOpenedTreeId], references: [id], onDelete: SetNull)
@@index([lastOpenedAt])
@@map("users")
}
// Compte d'un fournisseur OIDC relié à un compte Parenthèse : l'émetteur et le « sub » de l'ID token, jamais l'email,
// qui peut changer chez le fournisseur.
model UserIdentity {
id String @id @default(cuid())
userId String
issuer String
subject String
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([issuer, subject])
@@index([userId])
@@map("user_identities")
}
model Tree {
id String @id @default(cuid())
ownerUserId String
slug String @unique
name String
description String?
rootPersonId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
owner User @relation("TreeOwner", fields: [ownerUserId], references: [id], onDelete: Restrict)
accessPasswords TreeAccessPasswords?
sharePassword TreeSharePassword?
memberships TreeMembership[]
sharedUserAccesses UserTreeAccess[]
settings TreeSettings?
persons Person[]
unions Union[]
parentChildLinks ParentChildLink[]
mediaItems MediaItem[]
contributionSessions ContributionSession[]
auditLogs AuditLog[]
annotations Annotation[]
visits TreeVisit[]
lastOpenedByUsers User[] @relation("UserLastOpenedTree")
id String @id @default(cuid())
ownerUserId String
slug String @unique
name String
description String?
// Icône choisie pour l'arbre (un emoji), à la place de la première lettre dans les menus ; null = la lettre
icon String?
rootPersonId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
owner User @relation("TreeOwner", fields: [ownerUserId], references: [id], onDelete: Restrict)
accessPasswords TreeAccessPasswords?
sharePassword TreeSharePassword?
memberships TreeMembership[]
sharedUserAccesses UserTreeAccess[]
settings TreeSettings?
immichConfig TreeImmichConfig?
persons Person[]
unions Union[]
parentChildLinks ParentChildLink[]
mediaItems MediaItem[]
mediaPersonTags MediaPersonTag[]
contributionSessions ContributionSession[]
auditLogs AuditLog[]
annotations Annotation[]
visits TreeVisit[]
lifeEvents LifeEvent[]
invitations TreeInvitation[]
annotationPhotoUploads AnnotationPhotoUpload[]
lastOpenedByUsers User[] @relation("UserLastOpenedTree")
@@index([ownerUserId])
@@map("trees")
@@ -150,29 +183,74 @@ model TreeSharePassword {
}
model TreeMembership {
id String @id @default(cuid())
id String @id @default(cuid())
treeId String
userId String
role MembershipRole
contributionModeOverride ContributionPolicy?
createdAt DateTime @default(now())
tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([treeId, userId])
@@index([userId])
@@map("tree_memberships")
}
// Invitation à co-gérer un arbre (issue #145) : le propriétaire donne une adresse, Parenthèse un lien à transmettre.
// Seule l'empreinte du jeton est gardée ; « Copier le lien » en tire un nouveau. Acceptée, elle donne le rôle admin
// (co-gestionnaire). Le lien sert une fois, jusqu'à expiresAt.
model TreeInvitation {
id String @id @default(cuid())
treeId String
email String
tokenHash String @unique
invitedByUserId String?
createdAt DateTime @default(now())
expiresAt DateTime
acceptedAt DateTime?
acceptedUserId String?
revokedAt DateTime?
tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade)
invitedBy User? @relation("TreeInvitationSentBy", fields: [invitedByUserId], references: [id], onDelete: SetNull)
@@index([treeId])
@@index([email])
@@map("tree_invitations")
}
// Sessions fermées (déconnexion, compte supprimé), gardées jusqu'à la fin de leur jeton
model RevokedSession {
jti String @id
expiresAt DateTime
@@index([expiresAt])
@@map("revoked_sessions")
}
// Photo posée sur l'arbre par la famille, en attente de relecture (1.5.1). La ligne vit tant que la photo n'est
// ni acceptée (ligne retirée, fichier gardé) ni refusée (fichier et ligne effacés). Un envoi jamais proposé est
// effacé après 7 jours, la durée d'un brouillon. Les photos des gestionnaires n'ont pas de ligne.
model AnnotationPhotoUpload {
id String @id
treeId String
filePath String
createdAt DateTime @default(now())
tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade)
@@index([treeId, createdAt])
@@map("annotation_photo_uploads")
}
model UserTreeAccess {
id String @id @default(cuid())
treeId String
userId String
role SharedAccessRole
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
id String @id @default(cuid())
treeId String
userId String
role SharedAccessRole
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([treeId, userId])
@@index([userId, updatedAt])
@@ -180,103 +258,162 @@ model UserTreeAccess {
}
model TreeSettings {
treeId String @id
memberContributionPolicy ContributionPolicy @default(pending)
visitorPolicy VisitorPolicy @default(read_only)
contributorPolicy ContributionPolicy @default(pending)
updatedAt DateTime @updatedAt
tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade)
treeId String @id
memberContributionPolicy ContributionPolicy @default(pending)
visitorPolicy VisitorPolicy @default(read_only)
contributorPolicy ContributionPolicy @default(pending)
updatedAt DateTime @updatedAt
tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade)
@@map("tree_settings")
}
// Connexion Immich propre à chaque arbre (reconnaissance des visages) : l'adresse et la clé de
// l'instance sont choisies par ses administrateurs, jamais héritées du serveur. La clé est
// chiffrée au repos ; une ligne absente signifie « aucun accès Immich » pour cet arbre.
model TreeImmichConfig {
treeId String @id
apiUrl String
apiKeyEnc String
updatedAt DateTime @updatedAt
tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade)
@@map("tree_immich_configs")
}
model Person {
id String @id @default(cuid())
treeId String
firstName String
lastName String
birthName String?
birthDate DateTime?
deathDate DateTime?
birthPlace String?
profession String?
region String?
nationality String?
avatarPath String?
avatarMimeType String?
sex String?
notes String?
createdBy String?
updatedBy String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade)
id String @id @default(cuid())
treeId String
firstName String
lastName String
birthName String?
birthDate DateTime?
deathDate DateTime?
// Décédé(e) sans date connue (issue #77) : vrai seulement quand deathDate est vide
isDeceased Boolean @default(false)
// « À vérifier » : la famille n'est pas sûre de cette personne et le signale (retour d'Anaïs, 23/10)
toVerify Boolean @default(false)
birthPlace String?
// Précision d'une date importée (« vers », « avant », « entre »…) et sa borne de fin pour « entre … et … »
birthDatePrecision String?
birthDateEnd DateTime?
deathDatePrecision String?
deathDateEnd DateTime?
deathPlace String?
nickname String?
profession String?
region String?
nationality String?
avatarPath String?
avatarMimeType String?
// Cadrage du portrait, appliqué à l'affichage sur l'original (jamais de fichier dérivé) :
// { "x": 0..1, "y": 0..1, "zoom": 1..4 }, centre du cadrage en proportion de l'image, zoom 1 = image entière
// recouvrant le cadre. Null = cadrage par défaut (centré, zoom 1).
avatarCrop Json?
// Aperçu flou du portrait (BlurHash), montré pendant le chargement. Null : pas encore calculé ; vide : illisible
avatarBlurhash String?
sex String?
notes String?
createdBy String?
updatedBy String?
// Personne Immich associée (reconnaissance des visages) : identifiant côté Immich, ou null.
immichPersonId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade)
@@index([treeId])
@@map("persons")
}
model Union {
id String @id @default(cuid())
treeId String
partner1PersonId String
partner2PersonId String?
unionType String?
startDate DateTime?
endDate DateTime?
displayOrder Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade)
id String @id @default(cuid())
treeId String
partner1PersonId String
partner2PersonId String?
unionType String?
startDate DateTime?
endDate DateTime?
displayOrder Int @default(1)
startPlace String?
startDatePrecision String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade)
@@index([treeId])
@@map("unions")
}
model ParentChildLink {
id String @id @default(cuid())
treeId String
id String @id @default(cuid())
treeId String
parentPersonId String
childPersonId String
viaUnionId String?
parentageType String?
displayOrder Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade)
childPersonId String
viaUnionId String?
parentageType String?
displayOrder Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade)
@@index([treeId])
@@map("parent_child_links")
}
model MediaItem {
id String @id @default(cuid())
treeId String
personId String
type String
mimeType String
filePath String
thumbPath String?
caption String?
source String?
sizeBytes Int
displayOrder Int @default(1)
isFeatured Boolean @default(false)
uploadedBy String?
status MediaStatus @default(approved)
id String @id @default(cuid())
treeId String
personId String
type String
mimeType String
filePath String
thumbPath String?
// Aperçu flou d'une photo (BlurHash), montré pendant le chargement. Null : pas encore calculé ; vide : illisible
blurhash String?
caption String?
source String?
sizeBytes Int
displayOrder Int @default(1)
isFeatured Boolean @default(false)
uploadedBy String?
status MediaStatus @default(approved)
contributionSessionId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade)
// Moment de l'onglet Vie auquel ce souvenir est rattaché (photo, vidéo, acte en PDF…)
lifeEventId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade)
@@index([treeId, personId, displayOrder])
@@index([lifeEventId])
@@map("media_items")
}
// Personne identifiée sur un souvenir. Le souvenir reste à la personne qui le porte (media_items.personId) ;
// il apparaît aussi sur la fiche de chaque personne identifiée. x et y : point sur le visage, en proportion
// de l'image (0 à 1), facultatifs.
model MediaPersonTag {
id String @id @default(cuid())
treeId String
mediaId String
personId String
x Float?
y Float?
createdBy String?
createdAt DateTime @default(now())
tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade)
@@unique([mediaId, personId])
@@index([treeId])
@@index([treeId, personId])
@@map("media_person_tags")
}
model ContributionSession {
id String @id @default(cuid())
treeId String
@@ -300,17 +437,17 @@ model ContributionSession {
}
model ContributionChange {
id String @id @default(cuid())
sessionId String
entityType ContributionEntityType
entityId String?
action ContributionAction
beforeJson Json?
afterJson Json?
conflictState ConflictState @default(none)
decision ContributionDecision?
createdAt DateTime @default(now())
session ContributionSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
id String @id @default(cuid())
sessionId String
entityType ContributionEntityType
entityId String?
action ContributionAction
beforeJson Json?
afterJson Json?
conflictState ConflictState @default(none)
decision ContributionDecision?
createdAt DateTime @default(now())
session ContributionSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
@@index([sessionId])
@@map("contribution_changes")
@@ -336,16 +473,16 @@ model Annotation {
}
model AuditLog {
id String @id @default(cuid())
treeId String
actorType String
actorId String?
action String
entityType String
entityId String
id String @id @default(cuid())
treeId String
actorType String
actorId String?
action String
entityType String
entityId String
payloadJson Json?
createdAt DateTime @default(now())
tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade)
@@index([treeId, createdAt])
@@map("audit_log")
@@ -379,3 +516,38 @@ model WaitlistEntry {
@@map("waitlist_entries")
}
// Événements de vie d'une personne (baptême, résidence, métier, études, service militaire, inhumation…) ou
// d'un couple (divorce…). Naissance, décès et mariage gardent leur date et leur lieu sur la personne ou
// l'union ; leur ligne ici ne porte que les sources et les notes (type birth, death, marriage).
model LifeEvent {
id String @id @default(cuid())
treeId String
personId String?
unionId String?
type String
label String?
date DateTime?
dateEnd DateTime?
datePrecision String?
dateText String?
place String?
description String?
notes String?
sources String?
// Ancien rattachement d'une seule photo (17/10), remplacé par media_items.lifeEventId
mediaId String?
// Moment proposé par la famille : « pending » jusqu'à la relecture de l'administrateur
status String @default("approved")
contributionSessionId String?
displayOrder Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade)
@@index([treeId])
@@index([treeId, personId])
@@index([treeId, unionId])
@@map("life_events")
}
+34 -5
View File
@@ -1,16 +1,24 @@
import { createHmac } from 'node:crypto'
import Fastify, { type FastifyError } from 'fastify'
import cookie from '@fastify/cookie'
import cors from '@fastify/cors'
import jwt from '@fastify/jwt'
import rateLimit from '@fastify/rate-limit'
import { Prisma } from '@prisma/client'
import { Prisma } from './generated/prisma/client.js'
import { env } from './config/env.js'
import { prisma } from './lib/prisma.js'
import { isUserTokenRevoked } from './lib/token-revocation.js'
import { isUserTokenRevoked, loadRevokedTokens } from './lib/token-revocation.js'
import { registerIdempotency } from './lib/idempotency.js'
import { startBlurhashFiller } from './lib/blurhash.js'
import { authRoutes } from './routes/auth.js'
import { contributionRoutes } from './routes/contributions.js'
import { graphRoutes } from './routes/graph.js'
import { mediaRoutes } from './routes/media.js'
import { immichRoutes } from './routes/immich.js'
import { managerRoutes } from './routes/managers.js'
import { searchSourceRoutes } from './routes/search-sources.js'
import { getStorageRoot, mediaRoutes } from './routes/media.js'
import { oidcRoutes } from './routes/oidc.js'
import { publicRoutes } from './routes/public.js'
import { annotationRoutes } from './routes/annotations.js'
import { gedcomRoutes } from './routes/gedcom.js'
@@ -18,9 +26,10 @@ import { treeRoutes } from './routes/trees.js'
import { visitRoutes } from './routes/visits.js'
import type { AnyJwtPayload } from './types/auth.js'
// Les adresses des médias portent un jeton (<img src> n'envoie pas d'en-tête) : jamais dans les journaux
// Les adresses des médias portent un jeton (<img src> n'envoie pas d'en-tête), le retour OIDC un code
// d'autorisation : jamais dans les journaux
export function redactUrl(url: string | undefined): string | undefined {
return url?.replace(/([?&]token=)[^&#]*/g, '$1[masqué]')
return url?.replace(/([?&](?:token|code)=)[^&#]*/g, '$1[masqué]')
}
export function createApp() {
@@ -51,6 +60,14 @@ export function createApp() {
})
app.decorate('prisma', prisma)
// Les déconnexions d'avant le redémarrage reviennent en mémoire avant la première requête (onReady passe avant
// listen). Sans elles, l'API ne démarre pas : chaque session fermée redeviendrait valable sans que personne le
// voie, et pm2 comme Docker relancent l'API.
app.addHook('onReady', async () => {
await loadRevokedTokens(app.prisma)
})
// Aperçus flous des photos et portraits qui n'en ont pas encore (lib/blurhash.ts) ; pas pendant les tests
if (env.NODE_ENV !== 'test') app.addHook('onReady', async () => startBlurhashFiller(app, getStorageRoot()))
app.decorateRequest('actor', null)
app.register(cors, {
@@ -67,6 +84,11 @@ export function createApp() {
secret: env.JWT_SECRET,
})
// Cookie signé du parcours OIDC seulement (state, nonce, PKCE entre /auth/oidc/start et le retour) : les sessions
// restent des jetons Bearer
// Clé propre au cookie, dérivée de JWT_SECRET : une signature de cookie ne vaut jamais signature de jeton
app.register(cookie, { secret: createHmac('sha256', env.JWT_SECRET).update('parenthese-oidc-cookie').digest('hex') })
app.addHook('preHandler', async (request) => {
request.actor = null
@@ -138,7 +160,11 @@ export function createApp() {
app.get('/health', async () => ({ ok: true }))
// Après l'identification : une clé d'idempotence ne vaut que pour la personne qui l'a envoyée
registerIdempotency(app)
app.register(authRoutes, { prefix: '/auth' })
app.register(oidcRoutes, { prefix: '/auth/oidc' })
app.register(publicRoutes)
app.register(treeRoutes)
app.register(mediaRoutes)
@@ -147,6 +173,9 @@ export function createApp() {
app.register(annotationRoutes)
app.register(gedcomRoutes)
app.register(visitRoutes)
app.register(immichRoutes)
app.register(managerRoutes)
app.register(searchSourceRoutes)
app.setErrorHandler((error: FastifyError | Error, request, reply) => {
if (error instanceof Prisma.PrismaClientInitializationError) {
+73
View File
@@ -28,6 +28,16 @@ function parseTrustProxy(value: string): boolean | string {
return value
}
// « true » ou « false », sans tenir compte de la casse. z.coerce.boolean lirait « false » comme vrai.
function parseBooleanFlag(name: string) {
return (value: string): boolean => {
const normalized = value.trim().toLowerCase()
if (normalized === 'true') return true
if (normalized === 'false') return false
throw new Error(`${name} must be "true" or "false"`)
}
}
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().positive().default(4000),
@@ -50,10 +60,73 @@ const envSchema = z.object({
// Place disque par arbre, et part réservée aux envois de la famille en attente de relecture
MEDIA_QUOTA_PER_TREE_MB: z.coerce.number().int().positive().default(5120),
MEDIA_PENDING_QUOTA_PER_TREE_MB: z.coerce.number().int().positive().default(500),
// Instances Immich autorisées sur ce serveur, séparées par des virgules. Les administrateurs
// d'un arbre choisissent leur connexion parmi cette liste : sans elle, la reconnaissance
// des visages reste éteinte et le serveur ne peut pas servir de proxy vers un service interne.
IMMICH_ALLOWED_API_URLS: z.string().default(''),
// Création de compte. À « false », seuls les comptes existants se connectent : une instance ouverte sur
// Internet ne laisse pas un inconnu qui trouve l'adresse créer ses arbres. Une instance sans aucun compte
// accepte quand même le premier, celui du propriétaire (voir lib/registration.ts).
ALLOW_REGISTRATION: z.string().default('true').transform(parseBooleanFlag('ALLOW_REGISTRATION')),
// Invitations à créer son propre arbre, ajoutées pour l'instance hébergée app.parenthese.io : « Faire le mien »
// dans le menu Ajouter de la famille, l'inscription proposée dans les messages de la démo, message d'invitation
// de Noël. Éteintes par défaut : une instance auto-hébergée n'a pas à pousser vers la création de compte.
// L'instance hébergée les allume dans ecosystem.config.cjs.
SIGNUP_PROMPTS: z.string().default('false').transform(parseBooleanFlag('SIGNUP_PROMPTS')),
// Connexion par un fournisseur d'identité OIDC (#115 ; Authentik, Authelia, Keycloak, Pocket ID…). Éteinte sans
// OIDC_ISSUER. Le fournisseur renvoie vers OIDC_REDIRECT_URI, l'adresse publique de /auth/oidc/callback.
OIDC_ISSUER: z.string().trim().default(''),
OIDC_CLIENT_ID: z.string().trim().default(''),
// Vide : client public, protégé par PKCE seul
OIDC_CLIENT_SECRET: z.string().default(''),
OIDC_REDIRECT_URI: z.string().trim().default(''),
// Où l'app s'ouvre après la connexion. Par défaut l'origine de OIDC_REDIRECT_URI (Docker : front et API sur la
// même adresse) ; à renseigner quand le front a sa propre adresse (développement).
OIDC_APP_URL: z.string().trim().default(''),
OIDC_SCOPES: z.string().default('openid email profile'),
// Nom du bouton « Se connecter avec … »
OIDC_PROVIDER_NAME: z.string().trim().max(40).default('SSO'),
// Un compte n'est créé ou relié par son email que si le fournisseur le dit vérifié (email_verified). À false
// seulement pour un fournisseur qui ne l'envoie pas et où personne ne choisit lui-même son adresse.
OIDC_REQUIRE_VERIFIED_EMAIL: z.string().default('true').transform(parseBooleanFlag('OIDC_REQUIRE_VERIFIED_EMAIL')),
// À false, plus de mot de passe Parenthèse : connexion par le fournisseur OIDC seulement
PASSWORD_LOGIN: z.string().default('true').transform(parseBooleanFlag('PASSWORD_LOGIN')),
// Sources publiques que le panneau « Chercher quelqu'un » interroge depuis le serveur, séparées par des
// virgules : deces (décès en France depuis 1970, INSEE par matchID), wikitree. Vide : aucun nom ne quitte
// l'instance, le panneau ne propose que des liens vers les sites, ouverts par le navigateur.
SEARCH_SOURCES: z.string().default(''),
// Facultatif : jeton matchID (gratuit, après inscription) au-delà d'une centaine de recherches
MATCHID_TOKEN: z.string().trim().default(''),
})
export const env = envSchema.parse(process.env)
function assertHttpUrl(name: string, value: string) {
let url: URL
try {
url = new URL(value)
} catch {
throw new Error(`${name} must be a full URL`)
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
throw new Error(`${name} must be an http(s) URL`)
}
}
if (env.OIDC_ISSUER) {
assertHttpUrl('OIDC_ISSUER', env.OIDC_ISSUER)
if (!env.OIDC_CLIENT_ID) throw new Error('OIDC_CLIENT_ID is required with OIDC_ISSUER')
if (!env.OIDC_REDIRECT_URI) throw new Error('OIDC_REDIRECT_URI is required with OIDC_ISSUER (public address of /auth/oidc/callback)')
assertHttpUrl('OIDC_REDIRECT_URI', env.OIDC_REDIRECT_URI)
if (env.OIDC_APP_URL) assertHttpUrl('OIDC_APP_URL', env.OIDC_APP_URL)
if (!env.OIDC_SCOPES.split(/\s+/).includes('openid')) throw new Error('OIDC_SCOPES must include openid')
}
// Sans mot de passe ni fournisseur, personne ne pourrait se connecter
if (!env.PASSWORD_LOGIN && !env.OIDC_ISSUER) {
throw new Error('PASSWORD_LOGIN=false needs OIDC_ISSUER: otherwise nobody can sign in')
}
// Corps maximal des routes qui reçoivent un fichier en base64 ou un gros envoi (le reste de l'API : 1 Mo)
export const UPLOAD_BODY_LIMIT = 12 * 1024 * 1024
+234
View File
@@ -0,0 +1,234 @@
import path from 'node:path'
import { Prisma, type PrismaClient } from '../generated/prisma/client.js'
import { z } from 'zod'
import { InvalidAnnotationContentError, sanitizeAnnotationContentForWrite } from './annotation-content.js'
// Annotations proposées par la famille (dessin, texte, sticker, photo posée sur l'arbre). Avant la 1.5.1, le
// serveur les laissait au navigateur du relecteur, qui les posait après coup : une proposition acceptée pouvait
// ne jamais apparaître (onglet fermé, erreur réseau), et rien n'apparaissait en mode « direct ». Elles sont
// maintenant posées dans la transaction qui accepte la proposition.
type Tx = Prisma.TransactionClient
type Db = PrismaClient | Tx
export const MAX_ANNOTATIONS_PER_TREE = 5000
// Un envoi de photo jamais proposé vit le temps d'un brouillon de la famille
export const PENDING_PHOTO_TTL_MS = 7 * 24 * 60 * 60 * 1000
// Une famille pose quelques photos à la fois ; au-delà, c'est un envoi en boucle
export const MAX_PENDING_PHOTOS_PER_TREE = 50
const MAX_STYLE_CHARS = 4000
const styleSchema = z.record(z.string(), z.unknown()).refine(
(value) => JSON.stringify(value).length <= MAX_STYLE_CHARS,
{ message: 'style_too_large' },
)
const annotationCreateSchema = z.object({
type: z.enum(['drawing', 'sticker', 'text', 'photo']),
x: z.number().finite(),
y: z.number().finite(),
content: z.string().min(1).max(50000),
style: styleSchema.optional(),
zIndex: z.number().int().optional(),
})
const annotationUpdateSchema = z.object({
x: z.number().finite().optional(),
y: z.number().finite().optional(),
content: z.string().min(1).max(50000).optional(),
style: styleSchema.optional(),
zIndex: z.number().int().optional(),
})
export class AnnotationChangeError extends Error {
constructor(public readonly code: string) {
super(code)
}
}
type AnnotationChange = { action: string; entityId?: string | null; after?: unknown }
// Contenu vérifié comme à l'écriture directe : une photo doit être une photo de cet arbre
function cleanContent(content: string, treeId: string): string {
try {
return sanitizeAnnotationContentForWrite(content, treeId)
} catch (error) {
if (error instanceof InvalidAnnotationContentError) throw new AnnotationChangeError('invalid_annotation_photo_path')
throw error
}
}
// Vérifie une proposition d'annotation dès l'envoi, pour qu'une relecture n'échoue pas sur un contenu refusé
export function validateAnnotationChange(change: AnnotationChange, treeId: string): void {
if (change.action === 'create') {
const parsed = annotationCreateSchema.safeParse(change.after)
if (!parsed.success) throw new AnnotationChangeError('invalid_annotation_change')
cleanContent(parsed.data.content, treeId)
return
}
if (change.action === 'update') {
if (!change.entityId) throw new AnnotationChangeError('invalid_annotation_change')
const parsed = annotationUpdateSchema.safeParse(change.after)
if (!parsed.success) throw new AnnotationChangeError('invalid_annotation_change')
if (parsed.data.content !== undefined) cleanContent(parsed.data.content, treeId)
return
}
if (change.action === 'delete') {
if (!change.entityId) throw new AnnotationChangeError('invalid_annotation_change')
return
}
throw new AnnotationChangeError('unsupported_change_action_for_annotation')
}
// L'« après » d'une proposition d'annotation tel qu'il est gardé : contenu nettoyé comme à l'écriture directe
// (photoPath canonique, sans adresse), pour qu'on le relise toujours de la même façon
export function normalizedAnnotationAfter(change: AnnotationChange, treeId: string): unknown {
const after = change.after
if (!after || typeof after !== 'object' || Array.isArray(after)) return after
const content = (after as { content?: unknown }).content
if (typeof content !== 'string') return after
return { ...(after as Record<string, unknown>), content: cleanContent(content, treeId) }
}
// Identifiant de la photo d'une annotation (contenu JSON { photoPath }), ou null
export function annotationPhotoId(after: unknown, treeId: string): string | null {
if (!after || typeof after !== 'object' || Array.isArray(after)) return null
const content = (after as { content?: unknown }).content
if (typeof content !== 'string' || !content.startsWith('{')) return null
try {
const parsed = JSON.parse(content) as { photoPath?: unknown }
const prefix = `/trees/${treeId}/annotation-photos/`
if (typeof parsed.photoPath !== 'string' || !parsed.photoPath.startsWith(prefix)) return null
const photoId = parsed.photoPath.slice(prefix.length)
return /^[A-Za-z0-9-]+$/.test(photoId) ? photoId : null
} catch {
return null
}
}
// Pose une annotation acceptée. La photo d'une famille devient définitive : sa ligne d'attente disparaît.
export async function applyAnnotationChange(tx: Tx, treeId: string, change: AnnotationChange, actorUserId: string | null): Promise<{ entityId: string | null }> {
validateAnnotationChange(change, treeId)
if (change.action === 'create') {
const data = annotationCreateSchema.parse(change.after)
const count = await tx.annotation.count({ where: { treeId, deletedAt: null } })
if (count + 1 > MAX_ANNOTATIONS_PER_TREE) throw new AnnotationChangeError('annotation_limit_reached')
const created = await tx.annotation.create({
data: {
treeId,
type: data.type,
x: data.x,
y: data.y,
content: cleanContent(data.content, treeId),
style: (data.style ?? {}) as Prisma.InputJsonValue,
zIndex: data.zIndex ?? 0,
createdBy: actorUserId,
},
select: { id: true },
})
const photoId = annotationPhotoId(change.after, treeId)
if (photoId) await tx.annotationPhotoUpload.deleteMany({ where: { id: photoId, treeId } })
return { entityId: created.id }
}
if (change.action === 'update') {
const data = annotationUpdateSchema.parse(change.after)
const patch: Prisma.AnnotationUpdateManyMutationInput = {}
if (data.x !== undefined) patch.x = data.x
if (data.y !== undefined) patch.y = data.y
if (data.content !== undefined) patch.content = cleanContent(data.content, treeId)
if (data.style !== undefined) patch.style = data.style as Prisma.InputJsonValue
if (data.zIndex !== undefined) patch.zIndex = data.zIndex
if (Object.keys(patch).length > 0) {
await tx.annotation.updateMany({ where: { id: change.entityId as string, treeId, deletedAt: null }, data: patch })
}
const photoId = annotationPhotoId(change.after, treeId)
if (photoId) await tx.annotationPhotoUpload.deleteMany({ where: { id: photoId, treeId } })
return { entityId: change.entityId ?? null }
}
await tx.annotation.updateMany({
where: { id: change.entityId as string, treeId, deletedAt: null },
data: { deletedAt: new Date() },
})
return { entityId: change.entityId ?? null }
}
// Photos citées par les propositions d'annotation encore en attente sur cet arbre (hors une session donnée).
// Lu dans le JSON décodé, pas dans son texte : un chemin échappé (\/trees\/…) reste reconnu. Un arbre a au plus
// quelques centaines de propositions en attente.
async function photosInPendingProposals(db: Db, treeId: string, excludeSessionId?: string): Promise<Set<string>> {
const changes = await db.contributionChange.findMany({
where: {
entityType: 'annotation',
session: { treeId, status: 'pending', ...(excludeSessionId ? { id: { not: excludeSessionId } } : {}) },
},
select: { afterJson: true },
})
const cited = new Set<string>()
for (const change of changes) {
const photoId = annotationPhotoId(change.afterJson, treeId)
if (photoId) cited.add(photoId)
}
return cited
}
// Photos d'une famille refusées pendant une relecture : effacées si leur ligne d'attente existe encore (une
// acceptation la retire) et si aucune autre proposition en attente ne les cite. Appelé une fois, après toutes les
// décisions. Renvoie les chemins à effacer une fois la transaction validée.
export async function releaseRejectedPhotos(tx: Tx, treeId: string, rejectedAfters: unknown[], sessionId: string): Promise<string[]> {
const candidates = new Set(rejectedAfters.map((after) => annotationPhotoId(after, treeId)).filter((id): id is string => Boolean(id)))
if (candidates.size === 0) return []
const cited = await photosInPendingProposals(tx, treeId, sessionId)
const removed: string[] = []
for (const photoId of candidates) {
if (cited.has(photoId)) continue
const upload = await tx.annotationPhotoUpload.findFirst({ where: { id: photoId, treeId }, select: { filePath: true } })
if (!upload) continue
await tx.annotationPhotoUpload.delete({ where: { id: photoId } })
removed.push(upload.filePath)
}
return removed
}
// Envois de photos jamais proposés depuis 7 jours : effacés. Appelé à chaque nouvel envoi de la famille, sur
// cet arbre seulement. Renvoie les chemins à effacer.
export async function sweepStalePendingPhotos(db: Db, treeId: string, now = new Date()): Promise<string[]> {
const stale = await db.annotationPhotoUpload.findMany({
where: { treeId, createdAt: { lt: new Date(now.getTime() - PENDING_PHOTO_TTL_MS) } },
select: { id: true, filePath: true },
})
if (stale.length === 0) return []
const cited = await photosInPendingProposals(db, treeId)
const removed: string[] = []
for (const upload of stale) {
if (cited.has(upload.id)) continue
await db.annotationPhotoUpload.delete({ where: { id: upload.id } })
removed.push(upload.filePath)
}
return removed
}
// Réserve une place d'envoi en attente sous un verrou par arbre : deux envois simultanés ne dépassent pas le
// plafond. Renvoie false si l'arbre est plein. La ligne est créée avant l'écriture du fichier ; si l'écriture
// échoue, l'appelant la retire (forgetPendingPhoto).
export async function reservePendingPhoto(prisma: PrismaClient, treeId: string, photoId: string, filePath: string): Promise<boolean> {
return prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'annotation-photos:' + treeId}))`
if (await tx.annotationPhotoUpload.count({ where: { treeId } }) >= MAX_PENDING_PHOTOS_PER_TREE) return false
await tx.annotationPhotoUpload.create({ data: { id: photoId, treeId, filePath } })
return true
})
}
export async function forgetPendingPhoto(db: Db, photoId: string): Promise<void> {
await db.annotationPhotoUpload.deleteMany({ where: { id: photoId } })
}
// Chemin relatif sûr d'une photo d'annotation (sous <treeId>/annotation-photos/)
export function annotationPhotoRelativePath(treeId: string, photoId: string, extension: string): string {
return path.posix.join(treeId, 'annotation-photos', `${photoId}${extension}`)
}
+17 -5
View File
@@ -1,11 +1,23 @@
import bcrypt from 'bcryptjs'
// bcrypt natif (binaire précompilé) : le calcul passe sur les fils de libuv, la boucle de l'API garde la main.
// bcryptjs calculait en JavaScript : 8 vérifications simultanées retardaient chaque requête de 0,7 s en moyenne.
// Mêmes empreintes $2a$/$2b$ : les mots de passe enregistrés restent valables.
import { hash, verify } from '@node-rs/bcrypt'
import { env } from '../config/env.js'
const HASH_ROUNDS = 12
export async function hashPassword(value: string): Promise<string> {
return bcrypt.hash(value, HASH_ROUNDS)
return hash(value, { cost: HASH_ROUNDS })
}
export async function verifyPassword(value: string, hash: string): Promise<boolean> {
return bcrypt.compare(value, hash)
}
export async function verifyPassword(value: string, storedHash: string): Promise<boolean> {
return verify(value, storedHash)
}
// Un compte à mot de passe se confirme avec son mot de passe seulement si la connexion par mot de passe est ouverte.
// Avec PASSWORD_LOGIN=false, un compte relié à un fournisseur OIDC (et qui avait un mot de passe) ne peut plus le
// redonner : il se confirme par son email, comme un compte né du fournisseur, après une connexion récente.
export function confirmsWithPassword(passwordHash: string | null | undefined): boolean {
return Boolean(passwordHash) && env.PASSWORD_LOGIN
}
+37
View File
@@ -0,0 +1,37 @@
import { z } from 'zod'
// Cadrage d'un portrait : centre (proportions de l'image), zoom, et forme du cadre si on l'a choisie.
// Zoom 1 = l'image entière recouvre le cadre. Les bornes qui empêchent le vide sont appliquées à
// l'affichage (frontend, portraitCrop.js), le serveur vérifie seulement la forme et les plages.
export const FRAME_SHAPES = ['round', 'square', 'polaroid', 'portrait', 'landscape'] as const
export type AvatarCrop = { x: number; y: number; zoom: number; shape?: (typeof FRAME_SHAPES)[number] }
export const AVATAR_CROP_MAX_ZOOM = 4
export const avatarCropInputSchema = z.object({
crop: z
.object({
x: z.number().min(0).max(1),
y: z.number().min(0).max(1),
zoom: z.number().min(1).max(AVATAR_CROP_MAX_ZOOM),
shape: z.enum(FRAME_SHAPES).optional(),
})
.nullable(),
})
const round4 = (value: number) => Math.round(value * 10000) / 10000
// Forme à écrire en base. Le cadrage par défaut, sans forme choisie, se stocke comme absence de cadrage (null).
export function toStoredAvatarCrop(crop: AvatarCrop | null): AvatarCrop | null {
if (!crop) return null
const normalized = { x: round4(crop.x), y: round4(crop.y), zoom: round4(crop.zoom) }
if (crop.shape) return { ...normalized, shape: crop.shape }
if (normalized.x === 0.5 && normalized.y === 0.5 && normalized.zoom === 1) return null
return normalized
}
// Lecture de la colonne JSON : tout ce qui ne respecte pas la forme est ignoré (portrait affiché tel quel).
export function readAvatarCrop(value: unknown): AvatarCrop | null {
const parsed = avatarCropInputSchema.shape.crop.safeParse(value)
return parsed.success ? parsed.data : null
}
+92
View File
@@ -0,0 +1,92 @@
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { encode } from 'blurhash'
import sharp from 'sharp'
import type { FastifyInstance } from 'fastify'
// Aperçus flous (BlurHash) : une courte chaîne par photo, que le navigateur décode en image floue aux bonnes
// couleurs pendant que la vraie se charge. Les photos partent en taille réelle, sans miniature : sur un réseau
// lent, une vignette restait un carré vide.
//
// Calculé à l'envoi d'une photo ou d'un portrait (routes/media.ts). Les autres chemins (Immich, import GEDCOM,
// propositions acceptées) et les photos d'avant cette version sont rattrapés en tâche de fond. Une image
// illisible reçoit une chaîne vide, pour ne pas être relue à chaque passage.
// Assez petit pour un calcul rapide ; l'aperçu garde les proportions de la photo
const SAMPLE_MAX = 32
const COMPONENTS_X = 4
const COMPONENTS_Y = 3
export async function computeBlurhash(buf: Buffer): Promise<string | null> {
try {
const { data, info } = await sharp(buf, { limitInputPixels: 50_000_000, failOn: 'error' })
.rotate()
.resize(SAMPLE_MAX, SAMPLE_MAX, { fit: 'inside' })
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true })
return encode(new Uint8ClampedArray(data), info.width, info.height, COMPONENTS_X, COMPONENTS_Y)
} catch {
return null
}
}
async function hashStoredFile(storageRoot: string, relativePath: string): Promise<string> {
const absolutePath = path.resolve(storageRoot, relativePath)
if (!absolutePath.startsWith(storageRoot + path.sep)) return ''
const buf = await readFile(absolutePath).catch(() => null)
return (buf && await computeBlurhash(buf)) || ''
}
const BATCH = 25
const PAUSE_MS = 60_000
// Un lot de photos puis de portraits sans aperçu ; renvoie le nombre traité
export async function fillMissingBlurhashes(app: FastifyInstance, storageRoot: string): Promise<number> {
const medias = await app.prisma.mediaItem.findMany({
where: { type: 'photo', blurhash: null, deletedAt: null, NOT: { mimeType: 'video/youtube' } },
select: { id: true, filePath: true },
take: BATCH,
})
for (const media of medias) {
const blurhash = await hashStoredFile(storageRoot, media.filePath)
await app.prisma.mediaItem.updateMany({ where: { id: media.id, blurhash: null }, data: { blurhash } })
}
const persons = await app.prisma.person.findMany({
where: { avatarPath: { not: null }, avatarBlurhash: null, deletedAt: null },
select: { id: true, avatarPath: true },
take: BATCH,
})
for (const person of persons) {
const avatarBlurhash = await hashStoredFile(storageRoot, person.avatarPath as string)
// Le portrait a pu changer pendant le calcul : on n'écrit que s'il est toujours le même
await app.prisma.person.updateMany({ where: { id: person.id, avatarPath: person.avatarPath, avatarBlurhash: null }, data: { avatarBlurhash } })
}
return medias.length + persons.length
}
// Tâche de fond : enchaîne les lots tant qu'il en reste, puis repasse toutes les minutes
export function startBlurhashFiller(app: FastifyInstance, storageRoot: string) {
let timer: NodeJS.Timeout | null = null
let stopped = false
const run = async () => {
let done = 0
try {
done = await fillMissingBlurhashes(app, storageRoot)
} catch (error) {
app.log.warn({ err: error }, 'aperçus flous : lot en échec')
}
if (!stopped) {
timer = setTimeout(run, done >= BATCH ? 0 : PAUSE_MS)
timer.unref()
}
}
timer = setTimeout(run, 5_000)
timer.unref()
app.addHook('onClose', async () => {
stopped = true
if (timer) clearTimeout(timer)
})
}
+26
View File
@@ -0,0 +1,26 @@
// Une date corrigée à la main perd la précision lue dans le GEDCOM (« vers 1879 », « entre 1914 et 1918 ») :
// elle décrivait l'ancienne date, pas celle qu'on vient de saisir. Une date renvoyée telle quelle la garde.
const sameDay = (a: Date | null | undefined, b: Date | null | undefined) =>
(a ? a.toISOString().slice(0, 10) : null) === (b ? b.toISOString().slice(0, 10) : null)
type PersonDates = { birthDate: Date | null; deathDate: Date | null }
export function personPrecisionResets(current: PersonDates | null, next: Partial<PersonDates>) {
const out: { birthDatePrecision?: null; birthDateEnd?: null; deathDatePrecision?: null; deathDateEnd?: null } = {}
if (!current) return out
if (next.birthDate !== undefined && !sameDay(current.birthDate, next.birthDate)) {
out.birthDatePrecision = null
out.birthDateEnd = null
}
if (next.deathDate !== undefined && !sameDay(current.deathDate, next.deathDate)) {
out.deathDatePrecision = null
out.deathDateEnd = null
}
return out
}
export function unionPrecisionResets(current: { startDate: Date | null } | null, next: { startDate?: Date | null }) {
if (!current || next.startDate === undefined || sameDay(current.startDate, next.startDate)) return {}
return { startDatePrecision: null }
}
+20 -3
View File
@@ -1,4 +1,4 @@
import type { Prisma, PrismaClient } from '@prisma/client'
import { Prisma, type PrismaClient } from '../generated/prisma/client.js'
type Db = PrismaClient | Prisma.TransactionClient
@@ -32,7 +32,7 @@ export async function softDeletePersonCascade(
await db.person.updateMany({
where: { id: personId, treeId, deletedAt: null },
data: { deletedAt: now, updatedBy: actorUserId, avatarPath: null, avatarMimeType: null },
data: { deletedAt: now, updatedBy: actorUserId, avatarPath: null, avatarMimeType: null, avatarCrop: Prisma.DbNull, avatarBlurhash: null, immichPersonId: null },
})
const unions = await db.union.findMany({
@@ -47,7 +47,15 @@ export async function softDeletePersonCascade(
const medias = await db.mediaItem.findMany({
where: { treeId, personId, deletedAt: null },
select: { filePath: true, thumbPath: true },
select: { id: true, filePath: true, thumbPath: true },
})
// Identifications : la personne disparaît des photos des autres, et ses souvenirs n'ont plus personne
await db.mediaPersonTag.deleteMany({
where: {
treeId,
OR: [{ personId }, ...(medias.length > 0 ? [{ mediaId: { in: medias.map((media) => media.id) } }] : [])],
},
})
const [deletedUnions, deletedLinks, deletedMedia] = await Promise.all([
@@ -72,6 +80,15 @@ export async function softDeletePersonCascade(
where: { treeId, personId, deletedAt: null },
data: { deletedAt: now },
}),
// Événements de vie de la personne et de ses unions
db.lifeEvent.updateMany({
where: {
treeId,
deletedAt: null,
OR: [{ personId }, ...(unionIds.length > 0 ? [{ unionId: { in: unionIds } }] : [])],
},
data: { deletedAt: now },
}),
])
// Racine de l'arbre supprimée : le graphe repart sans racine plutôt que sur une personne effacée
+108
View File
@@ -0,0 +1,108 @@
// Dates GEDCOM : « 15 JAN 1900 », mais aussi « ABT 1900 » (vers), « BEF », « AFT », « BET 1794 AND 1796 »,
// « FROM … TO … », « INT 1900 (texte) », les doubles années « 12 FEB 1700/01 », le calendrier julien
// (« @#DJULIAN@ 1563 ») et le calendrier républicain (« @#DFRENCH R@ 18 BRUM 8 », converti en grégorien).
// On renvoie une date ISO (année, année-mois ou jour), sa précision et le texte d'origine.
export type GedcomDatePrecision = 'exact' | 'about' | 'estimated' | 'calculated' | 'before' | 'after' | 'between' | 'from' | 'to' | 'interpreted'
export type ParsedGedcomDate = {
iso: string
precision: GedcomDatePrecision
endIso: string | null
text: string
}
const MONTHS: Record<string, number> = {
JAN: 1, FEB: 2, MAR: 3, APR: 4, MAY: 5, JUN: 6, JUL: 7, AUG: 8, SEP: 9, OCT: 10, NOV: 11, DEC: 12,
}
// Calendrier républicain : début de chaque année (an I à XIV), années sextiles III, VII et XI
const FRENCH_YEAR_START: Record<number, string> = {
1: '1792-09-22', 2: '1793-09-22', 3: '1794-09-22', 4: '1795-09-23', 5: '1796-09-22', 6: '1797-09-22',
7: '1798-09-22', 8: '1799-09-23', 9: '1800-09-23', 10: '1801-09-23', 11: '1802-09-23', 12: '1803-09-24',
13: '1804-09-23', 14: '1805-09-23',
}
const FRENCH_MONTHS: Record<string, number> = {
VEND: 1, BRUM: 2, FRIM: 3, NIVO: 4, PLUV: 5, VENT: 6, GERM: 7, FLOR: 8, PRAI: 9, MESS: 10, THER: 11, FRUC: 12, COMP: 13,
}
const pad = (n: number) => String(n).padStart(2, '0')
function frenchToIso(day: number | null, month: number | null, year: number): string | null {
const start = FRENCH_YEAR_START[year]
if (!start) return null
if (!month) return start.slice(0, 4)
const offset = (month - 1) * 30 + ((day ?? 1) - 1)
const date = new Date(`${start}T00:00:00Z`)
date.setUTCDate(date.getUTCDate() + offset)
const iso = date.toISOString().slice(0, 10)
return day ? iso : iso.slice(0, 7)
}
// Une date simple, après le qualificatif : jour, mois, année, avec son calendrier
function parseSimple(raw: string): string | null {
let text = raw.trim().toUpperCase()
let calendar = 'GREGORIAN'
const escape = text.match(/^@#D([A-Z ]+?)@\s*/)
if (escape) {
calendar = escape[1].trim()
text = text.slice(escape[0].length)
}
text = text.replace(/\s+B\.?C\.?$/, '').trim()
const parts = text.split(/\s+/).filter(Boolean)
if (parts.length === 0 || parts.length > 3) return null
if (calendar === 'FRENCH R') {
const year = Number(parts[parts.length - 1])
const month = parts.length >= 2 ? FRENCH_MONTHS[parts[parts.length - 2]] ?? null : null
const day = parts.length === 3 ? Number(parts[0]) : null
if (!Number.isInteger(year) || (parts.length >= 2 && !month) || (day !== null && !(day >= 1 && day <= 30))) return null
return frenchToIso(day, month, year)
}
if (calendar !== 'GREGORIAN' && calendar !== 'JULIAN') return null
// Double année : « 1700/01 » = 1701 en style moderne
const yearMatch = parts[parts.length - 1].match(/^(\d{3,4})(?:\/(\d{1,2}))?$/)
if (!yearMatch) return null
let year = Number(yearMatch[1])
if (yearMatch[2]) {
const suffix = yearMatch[2]
year = Number(String(year).slice(0, String(year).length - suffix.length) + suffix)
if (year < Number(yearMatch[1])) year += 10 ** suffix.length
}
const yearText = String(year).padStart(4, '0')
if (parts.length === 1) return yearText
const month = MONTHS[parts[parts.length - 2]]
if (!month) return null
if (parts.length === 2) return `${yearText}-${pad(month)}`
const day = Number(parts[0])
if (!(day >= 1 && day <= 31)) return null
return `${yearText}-${pad(month)}-${pad(day)}`
}
export function parseGedcomDate(value: string | null | undefined): ParsedGedcomDate | null {
const text = (value ?? '').trim()
if (!text) return null
const upper = text.toUpperCase()
const range = upper.match(/^BET\s+(.+?)\s+AND\s+(.+)$/) ?? upper.match(/^FROM\s+(.+?)\s+TO\s+(.+)$/)
if (range) {
const iso = parseSimple(range[1])
const endIso = parseSimple(range[2])
if (!iso && !endIso) return null
return { iso: (iso ?? endIso)!, endIso: iso ? endIso : null, precision: upper.startsWith('BET') ? 'between' : 'from', text }
}
const qualifiers: Record<string, GedcomDatePrecision> = {
ABT: 'about', EST: 'estimated', CAL: 'calculated', BEF: 'before', AFT: 'after', FROM: 'from', TO: 'to', INT: 'interpreted',
}
const qualified = upper.match(/^(ABT|EST|CAL|BEF|AFT|FROM|TO|INT)\.?\s+(.+)$/)
if (qualified) {
const rest = qualified[1] === 'INT' ? qualified[2].replace(/\s*\(.*\)\s*$/, '') : qualified[2]
const iso = parseSimple(rest)
return iso ? { iso, endIso: null, precision: qualifiers[qualified[1]], text } : null
}
const iso = parseSimple(upper.replace(/\s*\(.*\)\s*$/, ''))
return iso ? { iso, endIso: null, precision: 'exact', text } : null
}
+189
View File
@@ -0,0 +1,189 @@
// Événements de vie lus dans un GEDCOM. Naissance, décès et mariage gardent leur date et leur lieu sur la
// personne ou l'union : on n'en tire ici que la précision, le lieu de décès, les sources et les notes.
// Les autres événements (baptême, résidence, métier, inhumation, divorce…) deviennent des LifeEvent.
import { parseGedcomDate } from './gedcom-dates.js'
import { joinedText, noteText, sourceLabel } from './gedcom-photos.js'
import type { GedcomPhotoNode, GedcomRecords } from './gedcom-photos.js'
type Node = GedcomPhotoNode
export type ImportedEvent = {
type: string
label: string | null
date: Date | null
dateEnd: Date | null
datePrecision: string | null
dateText: string | null
place: string | null
description: string | null
notes: string | null
sources: string | null
}
// Balises de la norme et variantes courantes (Gramps, Heredis, Geneatique, RootsMagic)
export const PERSON_EVENT_TYPES: Record<string, string> = {
BAPM: 'baptism', CHR: 'baptism', BURI: 'burial', CREM: 'cremation', RESI: 'residence', OCCU: 'occupation',
EDUC: 'education', GRAD: 'graduation', RELI: 'religion', CONF: 'confirmation', FCOM: 'first_communion',
BARM: 'bar_mitzvah', BASM: 'bat_mitzvah', ORDN: 'ordination', ADOP: 'adoption', EMIG: 'emigration',
IMMI: 'immigration', NATU: 'naturalization', CENS: 'census', TITL: 'title', PROP: 'property',
RETI: 'retirement', WILL: 'will', PROB: 'probate', _MILT: 'military', _MDCL: 'medical', _DEG: 'degree',
EVEN: 'custom', FACT: 'custom',
}
export const FAMILY_EVENT_TYPES: Record<string, string> = {
DIV: 'divorce', DIVF: 'divorce_filed', ANUL: 'annulment', ENGA: 'engagement', MARB: 'marriage_banns',
MARC: 'marriage_contract', MARL: 'marriage_license', MARS: 'marriage_settlement', EVEN: 'custom', FACT: 'custom',
}
const MAX_EVENTS_PER_OWNER = 200
const clip = (value: string | null | undefined, max: number) => {
const text = (value ?? '').replace(/\s+\n/g, '\n').trim()
if (!text) return null
return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text
}
const child = (node: Node | undefined, tag: string) => node?.children.find((c) => c.tag === tag)
// Même règle que le lieu de naissance : noms seulement, sans case vide ni code INSEE ou postal
export function cleanPlace(value: string | null | undefined): string | null {
if (!value) return null
const parts: string[] = []
for (const raw of value.split(',')) {
const part = raw.trim()
if (!part || /^(\d{4,5}|2[AB]\d{3})$/i.test(part)) continue
if (parts.length && parts[parts.length - 1].toLowerCase() === part.toLowerCase()) continue
parts.push(part)
}
return parts.length ? parts.join(', ') : null
}
export function isoToDbDate(iso: string | null | undefined): Date | null {
if (!iso) return null
if (/^\d{4}$/.test(iso)) return new Date(`${iso}-01-01T00:00:00.000Z`)
if (/^\d{4}-\d{2}$/.test(iso)) return new Date(`${iso}-01T00:00:00.000Z`)
return new Date(`${iso}T00:00:00.000Z`)
}
function eventNotes(node: Node, records: GedcomRecords): string | null {
const texts = node.children.filter((c) => c.tag === 'NOTE').map((n) => noteText(n, records)).filter(Boolean)
return clip(texts.join('\n\n'), 4000)
}
function eventSources(node: Node, records: GedcomRecords): string | null {
const labels = node.children.filter((c) => c.tag === 'SOUR').map((s) => sourceLabel(s, records)).filter(Boolean) as string[]
return clip([...new Set(labels)].join('\n'), 4000)
}
// Ce qu'on lit d'un événement : date et précision, lieu (ou adresse), description, notes, sources
export function readEvent(node: Node, type: string, records: GedcomRecords): ImportedEvent {
const dateText = child(node, 'DATE')?.value ?? null
const parsed = parseGedcomDate(dateText)
const addr = child(node, 'ADDR')
const address = [joinedText(addr).replace(/\n+/g, ', '), ...['CITY', 'STAE', 'CTRY'].map((tag) => child(addr, tag)?.value ?? '')]
.map((part) => part.trim())
.filter(Boolean)
.join(', ')
const place = cleanPlace(child(node, 'PLAC')?.value) ?? clip(address, 300)
const typeLabel = joinedText(child(node, 'TYPE'))
const value = node.value && node.value.toUpperCase() !== 'Y' ? node.value : ''
return {
type,
label: type === 'custom' ? clip(typeLabel || value, 120) : null,
date: isoToDbDate(parsed?.iso),
dateEnd: isoToDbDate(parsed?.endIso),
datePrecision: parsed && parsed.precision !== 'exact' ? parsed.precision : null,
dateText: parsed && parsed.precision === 'exact' ? null : clip(dateText, 120),
place: clip(place, 300),
description: clip(type === 'custom' && !typeLabel ? '' : value, 1000),
notes: eventNotes(node, records),
sources: eventSources(node, records),
}
}
export type PersonExtras = {
birthDatePrecision: string | null
birthDateEnd: Date | null
deathDatePrecision: string | null
deathDateEnd: Date | null
deathPlace: string | null
nickname: string | null
events: ImportedEvent[]
}
const hasDetails = (e: ImportedEvent) => Boolean(e.notes || e.sources)
export function personExtras(indi: Node, records: GedcomRecords): PersonExtras {
const events: ImportedEvent[] = []
const birth = child(indi, 'BIRT')
const death = child(indi, 'DEAT')
const birthDate = parseGedcomDate(child(birth, 'DATE')?.value)
const deathDate = parseGedcomDate(child(death, 'DATE')?.value)
// Naissance et décès : leurs sources et notes, sans recopier la date ni le lieu
for (const [node, type] of [[birth, 'birth'], [death, 'death']] as const) {
if (!node) continue
const event = readEvent(node, type, records)
if (hasDetails(event)) events.push({ ...event, date: null, dateEnd: null, datePrecision: null, dateText: null, place: null, description: null })
}
// La première profession reste aussi sur la fiche (champ « profession ») et figure quand même ici
for (const c of indi.children) {
const type = PERSON_EVENT_TYPES[c.tag]
if (!type || events.length >= MAX_EVENTS_PER_OWNER) continue
const event = readEvent(c, type, records)
if (!event.date && !event.place && !event.description && !event.label && !hasDetails(event)) continue
events.push(event)
}
const nameNode = child(indi, 'NAME')
return {
birthDatePrecision: birthDate && birthDate.precision !== 'exact' ? birthDate.precision : null,
birthDateEnd: isoToDbDate(birthDate?.endIso),
deathDatePrecision: deathDate && deathDate.precision !== 'exact' ? deathDate.precision : null,
deathDateEnd: isoToDbDate(deathDate?.endIso),
deathPlace: clip(cleanPlace(child(death, 'PLAC')?.value), 200),
nickname: clip(child(nameNode, 'NICK')?.value ?? child(indi, 'NICK')?.value, 120),
events,
}
}
export type UnionExtras = {
startPlace: string | null
startDatePrecision: string | null
events: ImportedEvent[]
}
export function familyExtras(fam: Node, records: GedcomRecords): UnionExtras {
const marriage = child(fam, 'MARR')
const date = parseGedcomDate(child(marriage, 'DATE')?.value)
const events: ImportedEvent[] = []
if (marriage) {
const event = readEvent(marriage, 'marriage', records)
if (hasDetails(event)) events.push({ ...event, date: null, dateEnd: null, datePrecision: null, dateText: null, place: null, description: null })
}
for (const c of fam.children) {
const type = FAMILY_EVENT_TYPES[c.tag]
if (!type || events.length >= MAX_EVENTS_PER_OWNER) continue
const event = readEvent(c, type, records)
// « 1 DIV Y » sans rien d'autre dit quand même qu'il y a eu divorce
if (!event.date && !event.place && !event.description && !event.label && !hasDetails(event) && c.value.toUpperCase() !== 'Y') continue
events.push(event)
}
return {
startPlace: clip(cleanPlace(child(marriage, 'PLAC')?.value), 300),
startDatePrecision: date && date.precision !== 'exact' ? date.precision : null,
events,
}
}
// Adoption déclarée sous l'enfant : « 1 FAMC @F1@ » / « 2 PEDI adopted »
export function adoptedFamilies(indi: Node): Set<string> {
const out = new Set<string>()
for (const famc of indi.children.filter((c) => c.tag === 'FAMC')) {
const pedi = (child(famc, 'PEDI')?.value || '').toLowerCase()
const pointer = famc.value.match(/^@([^@]+)@$/)?.[1]
if (pointer && pedi === 'adopted') out.add(pointer)
}
return out
}
+280
View File
@@ -0,0 +1,280 @@
// Photos et documents cités par un fichier GEDCOM. Le fichier ne contient pas les fichiers, seulement leur
// chemin sur l'ordinateur de la personne (« C:\Généalogie\Photos\jean.jpg »). L'import renvoie ces chemins
// pour chaque personne, et l'app les relie aux fichiers du dossier que la personne choisit, sans rien
// garder en base : le portrait devient la photo de la fiche, le reste des souvenirs.
export type GedcomPhotoNode = {
xref?: string
tag: string
value: string
rawValue?: string
children: GedcomPhotoNode[]
}
const IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'tif', 'tiff', 'heic', 'heif'])
const MAX_PATH_LENGTH = 500
function child(node: GedcomPhotoNode | undefined, tag: string): GedcomPhotoNode | undefined {
return node?.children.find((c) => c.tag === tag)
}
function isImagePath(value: string): boolean {
if (!value || /^[a-z][a-z0-9+.-]*:\/\//i.test(value)) return false
const name = value.split(/[\\/]/).pop() || ''
const dot = name.lastIndexOf('.')
return dot > 0 && IMAGE_EXTENSIONS.has(name.slice(dot + 1).toLowerCase())
}
// Un OBJE est soit écrit sous la personne (« 1 OBJE » puis « 2 FILE »), soit un renvoi (« 1 OBJE @M1@ »)
// vers un enregistrement « 0 @M1@ OBJE » en fin de fichier. Les logiciels marquent la photo principale
// par _PRIM Y (sous OBJE ou sous FILE) : elle passe devant, sinon la première image citée.
export function photoPathForPerson(
indi: GedcomPhotoNode,
objeByXref: Map<string, GedcomPhotoNode>,
): string | null {
// Family Tree Maker désigne la photo principale par « 1 _PHOTO @O1@ » sous la personne
const photoPointer = child(indi, '_PHOTO')?.value.match(/^@([^@]+)@$/)?.[1]
const flagged = photoPointer ? objeByXref.get(photoPointer) : undefined
for (const file of flagged?.children.filter((c) => c.tag === 'FILE') ?? []) {
const filePath = file.value.trim()
if (isImagePath(filePath) && filePath.length <= MAX_PATH_LENGTH) return filePath
}
let first: string | null = null
for (const ref of indi.children) {
if (ref.tag !== 'OBJE') continue
const pointer = ref.value.match(/^@([^@]+)@$/)
const obje = pointer ? objeByXref.get(pointer[1]) : ref
if (!obje) continue
const isPrimary = (node: GedcomPhotoNode | undefined) => child(node, '_PRIM')?.value?.toUpperCase().startsWith('Y')
for (const file of obje.children.filter((c) => c.tag === 'FILE')) {
const filePath = file.value.trim()
if (!isImagePath(filePath) || filePath.length > MAX_PATH_LENGTH) continue
if (isPrimary(ref) || isPrimary(obje) || isPrimary(file)) return filePath
first ??= filePath
}
}
return first
}
export function indexObjeRecords(roots: GedcomPhotoNode[]): Map<string, GedcomPhotoNode> {
const map = new Map<string, GedcomPhotoNode>()
for (const node of roots) {
if (node.tag === 'OBJE' && node.xref) map.set(node.xref, node)
}
return map
}
// ---------- Souvenirs : tous les fichiers cités pour une personne ----------
// Un logiciel de généalogie accroche ses fichiers à plusieurs endroits : sous la personne (« 1 OBJE »), sous
// un événement (« 1 BIRT » / « 2 OBJE »), sous une source citée (« 2 SOUR @S1@ » / « 3 OBJE »), dans la
// source elle-même (« 0 @S1@ SOUR » / « 1 OBJE ») ou sous la famille (actes de mariage). On les ramasse
// tous, comme Gramps (libgedcom.py), pour que les actes, registres et photos de famille deviennent des
// souvenirs. Le portrait reste à part (photoPathForPerson).
export type GedcomMediaRef = { file: string; caption: string | null; source: string | null }
const MEDIA_EXTENSIONS = new Set([
...IMAGE_EXTENSIONS,
'pdf', 'txt',
'mp4', 'mov', 'm4v', 'webm', 'avi',
'mp3', 'm4a', 'wav', 'ogg', 'aac', 'flac',
])
const MAX_MEDIA_PER_PERSON = 60
const MAX_CAPTION_LENGTH = 280
const MAX_SOURCE_LENGTH = 500
export type GedcomRecords = {
obje: Map<string, GedcomPhotoNode>
sour: Map<string, GedcomPhotoNode>
note: Map<string, GedcomPhotoNode>
fam: Map<string, GedcomPhotoNode>
}
export function indexGedcomRecords(roots: GedcomPhotoNode[]): GedcomRecords {
const records: GedcomRecords = { obje: new Map(), sour: new Map(), note: new Map(), fam: new Map() }
for (const node of roots) {
if (!node.xref) continue
if (node.tag === 'OBJE') records.obje.set(node.xref, node)
else if (node.tag === 'SOUR') records.sour.set(node.xref, node)
else if (node.tag === 'NOTE') records.note.set(node.xref, node)
else if (node.tag === 'FAM') records.fam.set(node.xref, node)
}
return records
}
function pointerOf(value: string): string | null {
return value.match(/^@([^@]+)@$/)?.[1] ?? null
}
// Chemin local d'un fichier cité : les adresses web ne sont pas des fichiers de l'ordinateur ;
// « file:///C:/… » (GEDCOM 7) redevient un chemin ordinaire.
export function localMediaPath(value: string): string | null {
let path = value.trim()
if (!path || path.length > MAX_PATH_LENGTH) return null
if (/^file:\/\//i.test(path)) {
path = path.replace(/^file:\/\/(localhost)?/i, '')
if (/^\/[a-z]:[\\/]/i.test(path)) path = path.slice(1)
try { path = decodeURIComponent(path) } catch { /* chemin laissé tel quel */ }
} else if (/^[a-z][a-z0-9+.-]+:/i.test(path)) {
return null
}
const name = path.split(/[\\/]/).pop() || ''
const dot = name.lastIndexOf('.')
return dot > 0 && MEDIA_EXTENSIONS.has(name.slice(dot + 1).toLowerCase()) ? path : null
}
export function joinedText(node: GedcomPhotoNode | undefined): string {
if (!node) return ''
let text = node.rawValue ?? node.value
for (const c of node.children) {
if (c.tag === 'CONT') text += `\n${c.rawValue ?? c.value}`
else if (c.tag === 'CONC') text += c.rawValue ?? c.value
}
return text
}
// Heredis Mac écrit certaines notes en RTF : on n'en garde que le texte
export function plainNoteText(raw: string): string {
let text = raw
if (/^\s*\{\\rtf/.test(text)) {
text = text
.replace(/\{\\\*[^{}]*\}/g, '')
.replace(/\{\\(fonttbl|colortbl|stylesheet|info)[^{}]*(\{[^{}]*\}[^{}]*)*\}/g, '')
.replace(/\\'([0-9a-f]{2})/gi, (_, hex) => new TextDecoder('windows-1252').decode(new Uint8Array([parseInt(hex, 16)])))
.replace(/\\par[d]?\b ?/g, '\n')
.replace(/\\[a-z]+-?\d* ?/gi, '')
.replace(/[{}]/g, '')
}
return text.replace(/[ \t]+/g, ' ').replace(/\s*\n\s*/g, '\n').trim()
}
// Une note ou une source partagée est citée par des milliers de fiches : son texte se calcule une fois
// par import (les nœuds sont neufs à chaque import, la WeakMap se vide avec eux)
const noteTextCache = new WeakMap<GedcomPhotoNode, string>()
const recordTitleCache = new WeakMap<GedcomPhotoNode, string>()
const hasMediaCache = new WeakMap<GedcomPhotoNode, boolean>()
const MAX_RECORD_DEPTH = 200
const MAX_WALKED_NODES_PER_PERSON = 5000
export function noteText(node: GedcomPhotoNode | undefined, records: GedcomRecords): string {
if (!node) return ''
const pointer = pointerOf(node.value)
const target = pointer ? records.note.get(pointer) : node
if (!target) return ''
let text = noteTextCache.get(target)
if (text === undefined) {
text = plainNoteText(joinedText(target))
noteTextCache.set(target, text)
}
return text
}
function recordTitle(record: GedcomPhotoNode): string {
let title = recordTitleCache.get(record)
if (title === undefined) {
title = joinedText(child(record, 'TITL')) || joinedText(child(record, 'ABBR'))
recordTitleCache.set(record, title)
}
return title
}
// Un nœud mène-t-il à un fichier (OBJE), directement ou par une source ou une union citée ?
// Un renvoi en boucle compte pour non pendant le calcul.
function hasMedia(node: GedcomPhotoNode, records: GedcomRecords, depth = 0): boolean {
const known = hasMediaCache.get(node)
if (known !== undefined) return known
if (depth > MAX_RECORD_DEPTH) return false
hasMediaCache.set(node, false)
let found = false
for (const c of node.children) {
const pointer = pointerOf(c.value)
const linked = !pointer ? undefined : c.tag === 'SOUR' ? records.sour.get(pointer) : c.tag === 'FAMS' ? records.fam.get(pointer) : undefined
if (c.tag === 'OBJE' || hasMedia(c, records, depth + 1) || (linked && hasMedia(linked, records, depth + 1))) { found = true; break }
}
hasMediaCache.set(node, found)
return found
}
function clipText(value: string, max: number): string | null {
const text = value.replace(/\s+/g, ' ').trim()
if (!text) return null
return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text
}
function baseNameWithoutExtension(path: string): string {
const name = path.split(/[\\/]/).pop() || ''
const dot = name.lastIndexOf('.')
return dot > 0 ? name.slice(0, dot) : name
}
// Titre d'une source citée : son titre (ou son abréviation), puis la page ou le détail de la citation
export function sourceLabel(citation: GedcomPhotoNode, records: GedcomRecords): string | null {
const pointer = pointerOf(citation.value)
const record = pointer ? records.sour.get(pointer) : null
const title = (record ? recordTitle(record) : '') || (pointer ? '' : citation.value)
const page = joinedText(child(citation, 'PAGE'))
return clipText([title, page].filter(Boolean).join(', '), MAX_SOURCE_LENGTH)
}
export function mediaRefsForPerson(indi: GedcomPhotoNode, records: GedcomRecords, portraitPath: string | null = null): GedcomMediaRef[] {
const refs: GedcomMediaRef[] = []
const seenPaths = new Set<string>()
if (portraitPath) seenPaths.add(portraitPath.toLowerCase())
const visitedRecords = new Set<GedcomPhotoNode>()
const addObje = (ref: GedcomPhotoNode, source: string | null) => {
const pointer = pointerOf(ref.value)
const obje = pointer ? records.obje.get(pointer) : ref
if (!obje) return
const objeTitle = joinedText(child(obje, 'TITL')) || joinedText(child(ref, 'TITL'))
const objeNote = noteText(child(obje, 'NOTE'), records) || noteText(child(ref, 'NOTE'), records)
const form = (child(obje, 'FORM')?.value || '').toLowerCase()
if (form === 'url') return
for (const file of obje.children.filter((c) => c.tag === 'FILE')) {
if ((child(file, 'FORM')?.value || '').toLowerCase() === 'url') continue
const path = localMediaPath(file.value)
if (!path || seenPaths.has(path.toLowerCase()) || refs.length >= MAX_MEDIA_PER_PERSON) continue
seenPaths.add(path.toLowerCase())
const title = joinedText(child(file, 'TITL')) || objeTitle || objeNote
refs.push({
file: path,
caption: clipText(title || baseNameWithoutExtension(path), MAX_CAPTION_LENGTH),
source,
})
}
}
let budget = MAX_WALKED_NODES_PER_PERSON
const walk = (node: GedcomPhotoNode, source: string | null, depth: number) => {
if (depth > MAX_RECORD_DEPTH) return
for (const c of node.children) {
if (--budget < 0 || refs.length >= MAX_MEDIA_PER_PERSON) return
if (c.tag === 'OBJE') {
addObje(c, source)
} else if (c.tag === 'SOUR') {
const pointer = pointerOf(c.value)
const record = pointer ? records.sour.get(pointer) : null
const recordToWalk = record && !visitedRecords.has(record) && hasMedia(record, records) ? record : null
if (!hasMedia(c, records) && !recordToWalk) continue
const label = sourceLabel(c, records) ?? source
walk(c, label, depth + 1)
if (recordToWalk) {
visitedRecords.add(recordToWalk)
walk(recordToWalk, label, depth + 1)
}
} else if (c.tag === 'FAMS') {
const pointer = pointerOf(c.value)
const fam = pointer ? records.fam.get(pointer) : null
if (fam && !visitedRecords.has(fam) && hasMedia(fam, records)) {
visitedRecords.add(fam)
walk({ ...fam, children: fam.children.filter((f) => !['HUSB', 'WIFE', 'CHIL'].includes(f.tag)) }, source, depth + 1)
}
} else if (!pointerOf(c.value) && c.children.length > 0 && hasMedia(c, records)) {
walk(c, source, depth + 1)
}
}
}
walk(indi, null, 0)
return refs
}
+112
View File
@@ -0,0 +1,112 @@
import { createHash } from 'node:crypto'
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
// Envois rejoués sans doublon (en-tête Idempotency-Key). Sur un réseau faible, la famille renvoie une proposition
// ou une photo dont la réponse s'est perdue : le serveur avait déjà tout créé, et l'arbre recevait deux fois la
// même chose. Le navigateur tire une clé au début d'un envoi et la garde pour chaque nouvel essai ; le serveur
// répond au second essai avec la réponse du premier, sans rien recréer. Deux essais simultanés : le second attend
// le premier.
//
// En mémoire, sur une seule instance de l'API (le cas de Parenthèse et de l'auto-hébergement) : un redémarrage
// oublie les clés, ce qui ne recrée de doublon que si un essai tombe pile pendant le redémarrage.
// Les créations faites par la famille : propositions, souvenirs, photos posées sur l'arbre, moments
const IDEMPOTENT_ROUTES = new Set([
'POST /trees/:id/contributions/sessions',
'POST /trees/:id/persons/:personId/media',
'POST /trees/:id/annotation-photos',
'POST /trees/:id/persons/:personId/life/events',
])
const TTL_MS = 24 * 60 * 60 * 1000
const MAX_ENTRIES = 5000
const KEY_PATTERN = /^[A-Za-z0-9_-]{16,100}$/
type Stored = { statusCode: number, contentType: string | undefined, payload: string }
type Entry = { expires: number, done: Stored | null, settled: Promise<void>, settle: () => void }
const entries = new Map<string, Entry>()
function prune(now: number) {
for (const [scope, entry] of entries) {
if (entry.expires <= now) entries.delete(scope)
}
// Au-delà du plafond, les plus anciennes partent d'abord (la Map garde l'ordre d'insertion)
while (entries.size > MAX_ENTRIES) {
const oldest = entries.keys().next().value
if (oldest === undefined) break
entries.get(oldest)?.settle()
entries.delete(oldest)
}
}
// Une clé ne vaut que pour la même route, la même adresse et la même personne : celle d'un autre ne rejoue rien
function scopeOf(request: FastifyRequest, key: string) {
const who = createHash('sha256').update(request.headers.authorization ?? `ip:${request.ip}`).digest('hex').slice(0, 32)
return `${request.method} ${request.url} ${who} ${key}`
}
function replay(reply: FastifyReply, stored: Stored) {
if (stored.contentType) reply.header('content-type', stored.contentType)
return reply.code(stored.statusCode).header('idempotent-replayed', 'true').send(stored.payload)
}
export function registerIdempotency(app: FastifyInstance) {
app.addHook('preHandler', async (request, reply) => {
if (!IDEMPOTENT_ROUTES.has(`${request.method} ${request.routeOptions.url}`)) return
const key = request.headers['idempotency-key']
if (typeof key !== 'string' || !KEY_PATTERN.test(key)) return
const now = Date.now()
prune(now)
const scope = scopeOf(request, key)
// Un essai précédent est en cours : on attend sa fin, puis on rejoue sa réponse s'il a abouti
for (let existing = entries.get(scope); existing; existing = entries.get(scope)) {
if (existing.done) return replay(reply, existing.done)
await existing.settled
}
let settle = () => {}
const settled = new Promise<void>((resolve) => { settle = resolve })
entries.set(scope, { expires: now + TTL_MS, done: null, settled, settle })
request.idempotencyScope = scope
})
app.addHook('onSend', async (request, reply, payload) => {
const scope = request.idempotencyScope
if (!scope) return payload
const entry = entries.get(scope)
if (!entry) return payload
// Seule une réussite se rejoue ; un échec laisse le prochain essai refaire la requête
if (reply.statusCode < 300 && typeof payload === 'string') {
const contentType = reply.getHeader('content-type')
entry.done = { statusCode: reply.statusCode, contentType: typeof contentType === 'string' ? contentType : undefined, payload }
} else {
entries.delete(scope)
}
entry.settle()
return payload
})
// Requête coupée avant toute réponse : l'essai suivant ne doit pas attendre pour rien
app.addHook('onResponse', async (request) => {
const scope = request.idempotencyScope
const entry = scope ? entries.get(scope) : undefined
if (entry && !entry.done) {
entries.delete(scope as string)
entry.settle()
}
})
}
declare module 'fastify' {
interface FastifyRequest {
idempotencyScope?: string
}
}
// Tests
export function resetIdempotency() {
entries.clear()
}
+37
View File
@@ -0,0 +1,37 @@
import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from 'node:crypto'
import { env } from '../config/env.js'
// Clé d'API Immich d'un arbre : secrète au repos, jamais renvoyée au front ni écrite dans les
// journaux. Même mécanisme que le mot de passe de partage (share-password.ts) : AES-256-GCM,
// clé dérivée de JWT_SECRET. Changer ce secret rend les clés illisibles ; chaque arbre
// repasse alors en « à configurer », sans rien casser d'autre.
const VERSION = 'v1'
function getKey(): Buffer {
return Buffer.from(hkdfSync('sha256', env.JWT_SECRET, 'parenthese', 'immich-api-key-v1', 32))
}
export function encryptImmichApiKey(value: string): string {
const iv = randomBytes(12)
const cipher = createCipheriv('aes-256-gcm', getKey(), iv)
const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()])
const tag = cipher.getAuthTag()
return `${VERSION}:${Buffer.concat([iv, tag, encrypted]).toString('base64')}`
}
// null si la valeur stockée est absente, d'un autre format, ou illisible (JWT_SECRET changé).
export function decryptImmichApiKey(stored: string | null | undefined): string | null {
if (!stored || !stored.startsWith(`${VERSION}:`)) {
return null
}
try {
const raw = Buffer.from(stored.slice(VERSION.length + 1), 'base64')
const decipher = createDecipheriv('aes-256-gcm', getKey(), raw.subarray(0, 12))
decipher.setAuthTag(raw.subarray(12, 28))
return Buffer.concat([decipher.update(raw.subarray(28)), decipher.final()]).toString('utf8')
} catch {
return null
}
}
+351
View File
@@ -0,0 +1,351 @@
import { createHash } from 'node:crypto'
import type { PrismaClient } from '../generated/prisma/client.js'
import { env } from '../config/env.js'
import { decryptImmichApiKey } from './immich-secret.js'
import { sniffMimeType } from './media-files.js'
// Client Immich : la reconnaissance des visages lit les personnes et leurs vignettes.
// Contrat vérifié sur la spécification OpenAPI officielle d'Immich v3.1.0 :
// GET /api/server/ping -> 200 {"res":"pong"}
// GET /api/people?page=N&size=M -> 200 { people: PersonResponseDto[], total, hasNextPage, hidden } (size <= 1000)
// GET /api/people/{id} -> 200 PersonResponseDto | 404 (id : UUID v4)
// GET /api/people/{id}/thumbnail -> 200 image binaire | 404
// Authentification : en-tête x-api-key ; clé refusée -> 401.
// Instance injoignable : réseau coupé, délai dépassé, réponse illisible.
export class ImmichUnavailableError extends Error {
constructor(message = 'Immich est injoignable') {
super(message)
this.name = 'ImmichUnavailableError'
}
}
// Clé d'API refusée par Immich (401/403).
export class ImmichAuthError extends Error {
constructor(message = 'Clé d’API Immich refusée') {
super(message)
this.name = 'ImmichAuthError'
}
}
// Personne ou vignette inexistante côté Immich (404).
export class ImmichNotFoundError extends Error {
constructor(message = 'Personne Immich introuvable') {
super(message)
this.name = 'ImmichNotFoundError'
}
}
const IMMICH_PEOPLE_PAGE_SIZE = 1000
const IMMICH_TIMEOUT_MS = 10_000
// Délai global de la liste complète : chaque page a son propre délai, la liste entière aussi.
const IMMICH_LIST_DEADLINE_MS = 30_000
// Identifiants de personnes Immich : UUID v4 (spécification OpenAPI).
const IMMICH_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
export function isImmichPersonId(value: string): boolean {
return IMMICH_ID_RE.test(value)
}
export type ImmichPersonSummary = {
id: string
name: string
hasThumbnail: boolean
}
type ImmichPeoplePage = {
people?: unknown
hasNextPage?: unknown
}
export class ImmichClient {
private readonly apiUrl: string
private readonly apiKey: string
constructor(apiUrl: string, apiKey: string) {
this.apiUrl = apiUrl.replace(/\/$/, '')
this.apiKey = apiKey
}
// Le chemin de l'instance (ex. /immich) reste devant les chemins de l'API : on concatène,
// on ne résout pas une URL relative (qui effacerait ce chemin).
// Corps JSON lu hors de toute gestion d'erreur : un corps illisible est une instance
// défaillante (503), pas une erreur serveur.
private async readJson(response: Response): Promise<unknown> {
try {
return await response.json()
} catch {
throw new ImmichUnavailableError('Réponse Immich illisible')
}
}
private buildUrl(path: string): string {
return `${this.apiUrl}${path}`
}
private async fetch(path: string, body?: unknown): Promise<Response> {
const url = this.buildUrl(path)
let response: Response
try {
response = await fetch(url, {
method: body === undefined ? 'GET' : 'POST',
body: body === undefined ? undefined : JSON.stringify(body),
headers: { 'x-api-key': this.apiKey, Accept: 'application/json', ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
signal: AbortSignal.timeout(IMMICH_TIMEOUT_MS),
// Aucune redirection suivie : la clé ne doit jamais partir vers une autre adresse.
redirect: 'error',
})
} catch (error) {
// L'URL ne contient aucun secret ; la clé voyage dans l'en-tête, jamais dans les journaux.
console.warn(`Immich injoignable : ${url} (${(error as Error).message})`)
throw new ImmichUnavailableError()
}
if (response.ok) {
return response
}
// Journal avec le statut et l'URL (sans secret), puis remontée d'une erreur distincte par cause.
console.warn(`Immich a répondu ${response.status} sur ${url}`)
if (response.status === 401 || response.status === 403) {
throw new ImmichAuthError()
}
if (response.status === 404) {
throw new ImmichNotFoundError()
}
throw new ImmichUnavailableError(`Immich a répondu ${response.status}`)
}
async ping(): Promise<boolean> {
try {
const response = await this.fetch('/api/server/ping')
const payload = (await response.json()) as { res?: unknown }
return payload?.res === 'pong'
} catch {
return false
}
}
async listAssets(page: number, type: 'IMAGE' | 'VIDEO', filters: { query?: string; personId?: string } = {}) {
const query = filters.query?.trim()
const response = await this.fetch(query ? '/api/search/smart' : '/api/search/metadata', {
page, size: 30, type, isTrashed: false,
...(query ? { query } : {}),
...(filters.personId ? { personIds: [filters.personId] } : {}),
})
const payload = await this.readJson(response) as { assets?: { items?: Array<{ id: string; type: string; originalFileName: string }>; nextPage?: string | null } }
if (!Array.isArray(payload.assets?.items)) throw new ImmichUnavailableError('Liste de médias Immich illisible')
return {
assets: payload.assets.items.filter((asset) => isImmichPersonId(asset.id) && asset.type === type).map((asset) => ({
id: asset.id, type: asset.type, name: asset.originalFileName,
})),
hasNextPage: Boolean(payload.assets.nextPage),
}
}
async getAssetBytes(assetId: string, thumbnail: boolean, maxBytes: number, preview = false): Promise<Buffer | null> {
const variant = preview ? 'thumbnail?size=preview' : thumbnail ? 'thumbnail?size=thumbnail' : 'original'
return this.readBytes(`/api/assets/${encodeURIComponent(assetId)}/${variant}`, maxBytes, 5 * 1024 * 1024)
}
// Toutes les personnes (sans les masquées), en suivant la pagination d'Immich.
async listPeople(): Promise<ImmichPersonSummary[]> {
const people: ImmichPersonSummary[] = []
let page = 1
const deadline = Date.now() + IMMICH_LIST_DEADLINE_MS
// Garde-fou : une instance qui répondrait toujours hasNextPage ne doit pas nous faire boucler.
for (let guard = 0; guard < 1000; guard += 1, page += 1) {
if (Date.now() > deadline) {
throw new ImmichUnavailableError('Liste des personnes Immich trop longue')
}
const response = await this.fetch(`/api/people?page=${page}&size=${IMMICH_PEOPLE_PAGE_SIZE}`)
const payload = (await this.readJson(response)) as ImmichPeoplePage
if (!Array.isArray(payload.people)) {
throw new ImmichUnavailableError('Réponse Immich inattendue sur /api/people')
}
for (const person of payload.people as Array<{ id?: unknown; name?: unknown; thumbnailPath?: unknown }>) {
if (typeof person?.id !== 'string' || typeof person?.name !== 'string') {
throw new ImmichUnavailableError('Réponse Immich inattendue sur /api/people')
}
people.push({ id: person.id, name: person.name, hasThumbnail: typeof person.thumbnailPath === 'string' && person.thumbnailPath.length > 0 })
}
if (payload.hasNextPage !== true || (payload.people as unknown[]).length === 0) {
break
}
}
return people
}
// Une personne précise : 200 avec ses données, 404 si elle n'existe pas. Le 404 de la
// vignette ne prouve rien (personne absente ou simple vignette manquante), l'existence
// se vérifie donc ici.
async getPerson(personId: string): Promise<ImmichPersonSummary> {
const response = await this.fetch(`/api/people/${encodeURIComponent(personId)}`)
const person = (await this.readJson(response)) as { id?: unknown; name?: unknown; thumbnailPath?: unknown }
if (typeof person?.id !== 'string' || typeof person?.name !== 'string') {
throw new ImmichUnavailableError('Réponse Immich inattendue sur /api/people/{id}')
}
return {
id: person.id,
name: person.name,
hasThumbnail: typeof person.thumbnailPath === 'string' && person.thumbnailPath.length > 0,
}
}
// Vignette d'une personne, en octets bruts (JPEG côté Immich). Absente -> ImmichNotFoundError.
// Lue en flux : au-delà de maxBytes, la lecture s'arrête et renvoie null sans charger le reste.
async getPersonThumbnail(personId: string, maxBytes: number): Promise<Buffer | null> {
return this.readBytes(`/api/people/${encodeURIComponent(personId)}/thumbnail`, maxBytes)
}
private async readBytes(path: string, maxBytes: number, maxImageBytes = maxBytes): Promise<Buffer | null> {
const response = await this.fetch(path)
let limit = response.headers.get('content-type')?.startsWith('image/') ? Math.min(maxBytes, maxImageBytes) : maxBytes
const declared = Number(response.headers.get('content-length'))
if (Number.isFinite(declared) && declared > limit) {
await response.body?.cancel().catch(() => {})
return null
}
if (!response.body) {
return Buffer.alloc(0)
}
const reader = response.body.getReader()
const chunks: Buffer[] = []
let total = 0
let signature = Buffer.alloc(0)
try {
for (;;) {
const { done, value } = await reader.read()
if (done) break
total += value.byteLength
// Ne pas se fier au Content-Type : même avec une signature répartie sur
// plusieurs chunks, une image est bornée à 5 Mo dès ses premiers octets.
if (signature.length < 12) {
signature = Buffer.concat([signature, Buffer.from(value.subarray(0, 12 - signature.length))])
if (sniffMimeType(signature, 'video')?.startsWith('image/')) limit = Math.min(limit, maxImageBytes)
}
if (total > limit || (Number.isFinite(declared) && declared > limit)) {
await reader.cancel().catch(() => {})
return null
}
chunks.push(Buffer.from(value))
}
} catch {
throw new ImmichUnavailableError('Lecture de la vignette Immich interrompue')
}
return Buffer.concat(chunks, total)
}
}
// Instance Immich normalisée pour comparaison : origine + chemin, sans slash final. Null si l'URL
// est mal formée ou d'un autre protocole que http(s).
export function normalizeImmichApiUrl(value: string): string | null {
try {
const url = new URL(value)
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return null
}
// Ni identifiants, ni requête, ni fragment : une adresse d'instance n'en porte pas, et
// « https://instance@autre-hote » ne doit jamais passer pour l'instance.
if (url.username || url.password || url.search || url.hash) {
return null
}
return `${url.origin}${url.pathname.replace(/\/+$/, '')}`
} catch {
return null
}
}
export type ImmichInstance = {
// Identifiant opaque, dérivé de l'adresse (stable même si l'opérateur réordonne la liste).
id: string
label: string
apiUrl: string
}
let instancesCache: ImmichInstance[] | undefined
export function immichInstanceId(apiUrl: string): string {
return createHash('sha256').update(apiUrl).digest('hex').slice(0, 16)
}
// Instances Immich autorisées par l'opérateur du serveur (IMMICH_ALLOWED_API_URLS). C'est la seule
// liste d'adresses possibles : les administrateurs d'un arbre y choisissent la leur, le serveur
// ne devient jamais un proxy vers un service interne choisi librement. Format d'une entrée :
// « Libellé=https://adresse » ou « https://adresse ». Sans libellé, le nom d'hôte sert de nom.
// Entrées invalides, doublons et libellés en conflit sont ignorés avec un avertissement.
export function getImmichInstances(): ImmichInstance[] {
if (instancesCache !== undefined) {
return instancesCache
}
const instances: ImmichInstance[] = []
for (const entry of env.IMMICH_ALLOWED_API_URLS.split(',')) {
const trimmed = entry.trim()
if (!trimmed) {
continue
}
const named = /^(.*?)\s*=\s*(https?:\/\/.+)$/i.exec(trimmed)
const rawUrl = named ? named[2] : trimmed
const apiUrl = normalizeImmichApiUrl(rawUrl)
if (!apiUrl) {
console.warn(`IMMICH_ALLOWED_API_URLS : entrée ignorée (URL http(s) invalide) : ${trimmed}`)
continue
}
if (instances.some((instance) => instance.apiUrl === apiUrl)) {
continue
}
const label = (named?.[1] ?? '').trim() || new URL(apiUrl).hostname
if (instances.some((instance) => instance.label.toLowerCase() === label.toLowerCase())) {
console.warn(`IMMICH_ALLOWED_API_URLS : entrée ignorée, le nom « ${label} » est déjà pris : donnez-lui un libellé (Libellé=https://…)`)
continue
}
instances.push({ id: immichInstanceId(apiUrl), label, apiUrl })
}
instancesCache = instances
return instances
}
export function getImmichInstanceByApiUrl(value: string): ImmichInstance | undefined {
const normalized = normalizeImmichApiUrl(value)
return normalized === null ? undefined : getImmichInstances().find((instance) => instance.apiUrl === normalized)
}
export function isImmichApiUrlAllowed(value: string): boolean {
return getImmichInstanceByApiUrl(value) !== undefined
}
// Connexion Immich d'un arbre : résolue par arbre, côté serveur, à chaque requête. Pas de client
// global qui garderait les identifiants d'un arbre et pourrait servir un autre ; pas d'héritage
// de configuration du serveur. Une clé illisible (JWT_SECRET changé) vaut « non configuré », et
// une adresse retirée de la liste de l'opérateur aussi : la connexion existante cesse de marcher.
export async function getTreeImmichClient(prisma: PrismaClient, treeId: string): Promise<ImmichClient | null> {
const config = await prisma.treeImmichConfig.findUnique({
where: { treeId },
select: { apiUrl: true, apiKeyEnc: true },
})
if (!config) {
return null
}
const apiKey = decryptImmichApiKey(config.apiKeyEnc)
if (!apiKey) {
return null
}
if (!isImmichApiUrlAllowed(config.apiUrl)) {
return null
}
// Le client reçoit l'adresse autorisée, normalisée : pas celle, brute, de la base.
return new ImmichClient(getImmichInstanceByApiUrl(config.apiUrl)!.apiUrl, apiKey)
}
+62 -2
View File
@@ -1,7 +1,8 @@
import { unlink } from 'node:fs/promises'
import { createReadStream } from 'node:fs'
import { stat, unlink } from 'node:fs/promises'
import path from 'node:path'
import type { FastifyReply } from 'fastify'
import type { FastifyReply, FastifyRequest } from 'fastify'
import sharp from 'sharp'
// Fichiers envoyés : le type vient des premiers octets, jamais de ce que déclare le navigateur.
@@ -59,6 +60,9 @@ const EXTENSION_BY_MIME: Record<string, string> = {
'application/gpx+xml': '.gpx',
}
// Marques ISO BMFF des images HEIF/HEIC (ISO/IEC 23008-12)
const HEIC_BRANDS = new Set(['heic', 'heix', 'mif1', 'msf1', 'heim', 'heis'])
function startsWith(buf: Buffer, bytes: number[], offset = 0): boolean {
if (buf.length < offset + bytes.length) return false
return bytes.every((byte, index) => buf[offset + index] === byte)
@@ -89,6 +93,10 @@ export function sniffMimeType(buf: Buffer, declaredType: BinaryMediaType, fileNa
if (ascii(buf, 4, 8) === 'ftyp') {
const brand = ascii(buf, 8, 12)
if (brand === 'qt ') return 'video/quicktime'
// HEIF (photos iPhone et Android) : pas une vidéo, et refusée à l'envoi faute de conversion (sharp ne décode
// pas le HEVC). « hevc » peut aussi désigner une vidéo HEVC en MP4 : photo seulement si l'envoi se déclare photo.
if (HEIC_BRANDS.has(brand) || (brand === 'hevc' && declaredType === 'photo')) return 'image/heic'
if (brand === 'avif' || brand === 'avis') return 'image/avif'
if (declaredType === 'audio' || brand === 'M4A ' || brand === 'M4B ') return 'audio/mp4'
return 'video/mp4'
}
@@ -211,6 +219,12 @@ export function applyMediaResponseHeaders(reply: FastifyReply, storedMimeType: s
reply.header('Content-Disposition', `attachment; filename="${safeName}"`)
}
// Portrait venu d'une association Immich : son fichier porte le préfixe « immich- ». Le graphe
// ne publie jamais l'identifiant Immich, seulement cette origine du portrait.
export function isImmichAvatarPath(avatarPath: string | null | undefined): boolean {
return avatarPath != null && path.basename(avatarPath).startsWith('immich-')
}
// Efface un fichier du stockage, sans jamais sortir du dossier racine
export async function removeStoredFile(storageRoot: string, relativePath: string | null | undefined): Promise<void> {
if (!relativePath || /^https?:\/\//.test(relativePath)) return
@@ -222,3 +236,49 @@ export async function removeStoredFile(storageRoot: string, relativePath: string
// déjà absent
}
}
// Plage demandée par « Range: bytes=début-fin » (une seule plage), bornée à la taille du fichier. null : pas de
// plage lisible, on envoie tout ; 'unsatisfiable' : la plage tombe hors du fichier.
export function parseByteRange(header: string | undefined, size: number): { start: number; end: number } | 'unsatisfiable' | null {
const match = /^bytes=(\d*)-(\d*)$/.exec(String(header ?? '').trim())
if (!match || (match[1] === '' && match[2] === '')) return null
if (match[1] === '') {
// Les n derniers octets
const suffix = Number(match[2])
if (suffix === 0 || size === 0) return 'unsatisfiable'
return { start: Math.max(0, size - suffix), end: size - 1 }
}
const start = Number(match[1])
const end = match[2] === '' ? size - 1 : Math.min(Number(match[2]), size - 1)
if (start >= size || end < start) return 'unsatisfiable'
return { start, end }
}
// Taille d'un fichier stocké, null s'il manque
export async function storedFileSize(absolutePath: string): Promise<number | null> {
try {
const info = await stat(absolutePath)
return info.isFile() ? info.size : null
} catch {
return null
}
}
// Envoie un fichier en flux, par morceaux si le navigateur le demande. Safari ne lit une vidéo que si le serveur
// répond aux plages (206) ; avant, le fichier entier passait en mémoire puis partait d'un bloc. À appeler après
// applyMediaResponseHeaders.
export function sendFileWithRanges(request: FastifyRequest, reply: FastifyReply, absolutePath: string, size: number): FastifyReply {
reply.header('Accept-Ranges', 'bytes')
const range = parseByteRange(request.headers.range, size)
if (range === 'unsatisfiable') {
return reply.code(416).header('Content-Range', `bytes */${size}`).send()
}
if (range) {
reply.code(206)
reply.header('Content-Range', `bytes ${range.start}-${range.end}/${size}`)
reply.header('Content-Length', String(range.end - range.start + 1))
return reply.send(createReadStream(absolutePath, { start: range.start, end: range.end }))
}
reply.header('Content-Length', String(size))
return reply.send(createReadStream(absolutePath))
}
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Prisma } from '@prisma/client'
import type { Prisma } from '../generated/prisma/client.js'
// Renumérote les souvenirs acceptés d'une personne et met en avant les trois premiers.
// Les souvenirs en attente de relecture ne comptent pas.
+73
View File
@@ -0,0 +1,73 @@
import type { Prisma, PrismaClient } from '../generated/prisma/client.js'
import { z } from 'zod'
type Db = PrismaClient | Prisma.TransactionClient
// Personnes identifiées sur un souvenir. La liste envoyée remplace la précédente : retirer quelqu'un, c'est
// renvoyer la liste sans lui. Le point (x, y) est facultatif, en proportion de l'image (0 à 1), et va par paire.
export const MAX_PEOPLE_PER_MEDIA = 50
const coordinate = z.number().min(0).max(1).nullable().optional()
export const mediaPersonSchema = z
.object({
personId: z.string().min(1).max(64),
x: coordinate,
y: coordinate,
})
.refine((value) => (value.x == null) === (value.y == null), { message: 'point_requires_x_and_y' })
export const mediaPeopleSchema = z
.array(mediaPersonSchema)
.max(MAX_PEOPLE_PER_MEDIA)
.refine((people) => new Set(people.map((person) => person.personId)).size === people.length, { message: 'duplicate_person' })
export type MediaPerson = { personId: string; x: number | null; y: number | null }
export function normalizeMediaPeople(people: z.infer<typeof mediaPeopleSchema>): MediaPerson[] {
return people.map((person) => ({ personId: person.personId, x: person.x ?? null, y: person.y ?? null }))
}
export async function readMediaPeople(db: Db, treeId: string, mediaId: string): Promise<MediaPerson[]> {
const tags = await db.mediaPersonTag.findMany({
where: { treeId, mediaId },
select: { personId: true, x: true, y: true },
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
})
return tags
}
// Remplace les personnes d'un souvenir. Une personne supprimée entre l'envoi et la relecture est écartée
// sans faire échouer le reste. Renvoie la liste réellement enregistrée.
export async function replaceMediaPeople(
db: Db,
treeId: string,
mediaId: string,
people: MediaPerson[],
actorUserId: string | null,
): Promise<MediaPerson[]> {
const ids = people.map((person) => person.personId)
const existing = ids.length
? await db.person.findMany({ where: { treeId, id: { in: ids }, deletedAt: null }, select: { id: true } })
: []
const alive = new Set(existing.map((person) => person.id))
const kept = people.filter((person) => alive.has(person.personId))
await db.mediaPersonTag.deleteMany({ where: { treeId, mediaId } })
if (kept.length) {
// Une date par rang : l'ordre d'affichage suit celui de la liste envoyée
const base = Date.now()
await db.mediaPersonTag.createMany({
data: kept.map((person, index) => ({
treeId,
mediaId,
personId: person.personId,
x: person.x,
y: person.y,
createdBy: actorUserId,
createdAt: new Date(base + index),
})),
})
}
return kept
}
+129
View File
@@ -0,0 +1,129 @@
import * as client from 'openid-client'
import { env } from '../config/env.js'
// Connexion par un fournisseur OIDC (#115). Tout le protocole passe par openid-client : découverte, PKCE (S256),
// state et nonce, échange du code, puis vérification de l'ID token (signature par les clés publiées du
// fournisseur, émetteur, audience = notre client, expiration, nonce). Aucun jeton n'est décodé à la main ici.
export type OidcPending = {
state: string
nonce: string
codeVerifier: string
returnTo: string
// Rattachement demandé depuis un compte connecté (ticket de /auth/oidc/link) : l'identité du fournisseur rejoint
// ce compte, au lieu d'en chercher un par email
linkUserId?: string
}
export type OidcIdentity = {
issuer: string
subject: string
email: string | null
emailVerified: boolean
firstName: string | null
}
export function isOidcEnabled(): boolean {
return Boolean(env.OIDC_ISSUER)
}
let configuration: Promise<client.Configuration> | null = null
// Découverte faite au premier besoin puis gardée ; un échec (fournisseur injoignable) sera retenté à la connexion suivante
export function getOidcConfiguration(): Promise<client.Configuration> {
configuration ??= discover().catch((error: unknown) => {
configuration = null
throw error
})
return configuration
}
function discover(): Promise<client.Configuration> {
const issuer = new URL(env.OIDC_ISSUER)
// Sans secret : client public, PKCE seul. Avec : client_secret_basic, la méthode par défaut d'OIDC.
const authentication = env.OIDC_CLIENT_SECRET ? client.ClientSecretBasic(env.OIDC_CLIENT_SECRET) : client.None()
// Un fournisseur en http (réseau local) doit être autorisé explicitement par openid-client
const options = issuer.protocol === 'http:' ? { execute: [client.allowInsecureRequests] } : undefined
return client.discovery(issuer, env.OIDC_CLIENT_ID, undefined, authentication, options)
}
// Adresse de l'app où revenir : OIDC_APP_URL, sinon l'origine de l'adresse de retour (front et API sur la même adresse)
export function appBaseUrl(): string {
return (env.OIDC_APP_URL || new URL(env.OIDC_REDIRECT_URI).origin).replace(/\/$/, '')
}
// Seules la racine, l'adresse d'un arbre partagé et celle d'une invitation à co-gérer un arbre sont acceptées
// comme retour : pas de redirection ouverte
export function safeReturnTo(value: unknown): string {
return typeof value === 'string' && /^\/(arbre\/[A-Za-z0-9-]{3,64}|invitation\/[A-Za-z0-9_-]{20,100})?$/.test(value) ? value : '/'
}
export async function startAuthorization(returnTo: string): Promise<{ url: URL; pending: OidcPending }> {
const config = await getOidcConfiguration()
const codeVerifier = client.randomPKCECodeVerifier()
const pending: OidcPending = {
state: client.randomState(),
nonce: client.randomNonce(),
codeVerifier,
returnTo,
}
const url = client.buildAuthorizationUrl(config, {
redirect_uri: env.OIDC_REDIRECT_URI,
scope: env.OIDC_SCOPES,
code_challenge: await client.calculatePKCECodeChallenge(codeVerifier),
code_challenge_method: 'S256',
state: pending.state,
nonce: pending.nonce,
})
return { url, pending }
}
function stringClaim(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value.trim() : null
}
// Retour du fournisseur. L'adresse vérifiée est OIDC_REDIRECT_URI plus les paramètres reçus, jamais l'en-tête Host.
export async function finishAuthorization(callbackQuery: string, pending: OidcPending): Promise<OidcIdentity> {
const config = await getOidcConfiguration()
const currentUrl = new URL(env.OIDC_REDIRECT_URI)
currentUrl.search = callbackQuery
const tokens = await client.authorizationCodeGrant(config, currentUrl, {
pkceCodeVerifier: pending.codeVerifier,
expectedState: pending.state,
expectedNonce: pending.nonce,
idTokenExpected: true,
})
const claims = tokens.claims()
if (!claims) {
throw new Error('oidc_missing_id_token')
}
let email = stringClaim(claims.email)
let emailVerified = claims.email_verified === true
let firstName = stringClaim(claims.given_name) ?? stringClaim(claims.name)
// Certains fournisseurs (Authelia par défaut) ne mettent l'email que dans userinfo. Le sujet attendu est
// celui de l'ID token : une réponse userinfo pour un autre compte est refusée par openid-client.
if (!email && tokens.access_token) {
const info = await client.fetchUserInfo(config, tokens.access_token, claims.sub)
email = stringClaim(info.email)
emailVerified = info.email_verified === true
firstName ??= stringClaim(info.given_name) ?? stringClaim(info.name)
}
return {
issuer: claims.iss,
subject: claims.sub,
email: email?.toLowerCase() ?? null,
emailVerified,
firstName: firstName?.slice(0, 60) ?? null,
}
}
// Pour les tests : oublie la configuration découverte
export function resetOidcConfiguration() {
configuration = null
}
+4
View File
@@ -0,0 +1,4 @@
// Nom de famille facultatif, prénom facultatif (prénom inconnu = vide) : une personne garde au moins un des deux
export function hasAnyName(firstName: string, lastName: string): boolean {
return Boolean(firstName.trim() || lastName.trim())
}
+36
View File
@@ -0,0 +1,36 @@
import { z } from 'zod'
// Sexe d'une personne : 'male', 'female', 'other' (autre), ou null (non précisé). L'import GEDCOM écrit
// 'male'/'female'/'other' (SEX X de GEDCOM 7), d'anciens clients de l'API ont pu écrire 'M'/'F' : tout est
// ramené à ces valeurs, à l'écriture comme à la lecture, sans migration des lignes existantes.
export type PersonSex = 'male' | 'female' | 'other'
const MALE = new Set(['male', 'm', 'homme', 'h'])
const FEMALE = new Set(['female', 'f', 'femme'])
const OTHER = new Set(['other', 'x', 'autre'])
const UNSPECIFIED = new Set(['', 'u', 'unknown', 'unspecified'])
// undefined : valeur que l'on ne sait pas lire (refusée à l'écriture)
export function parseSex(value: string | null): PersonSex | null | undefined {
if (value === null) return null
const key = value.trim().toLowerCase()
if (MALE.has(key)) return 'male'
if (FEMALE.has(key)) return 'female'
if (OTHER.has(key)) return 'other'
if (UNSPECIFIED.has(key)) return null
return undefined
}
// Lecture d'une valeur déjà en base : ce qu'on ne sait pas lire vaut « non précisé »
export function sexOrNull(value: string | null | undefined): PersonSex | null {
return parseSex(value ?? null) ?? null
}
// Champ « sex » des schémas d'écriture : facultatif, null pour « non précisé », valeur ramenée à male/female/other
export const sexSchema = z
.string()
.max(20)
.nullable()
.refine((value) => parseSex(value) !== undefined, { message: 'invalid_sex' })
.transform((value) => parseSex(value) ?? null)
.optional()
+5 -8
View File
@@ -1,12 +1,9 @@
import pkg from '@prisma/client'
import { PrismaPg } from '@prisma/adapter-pg'
import { env } from '../config/env.js'
import { PrismaClient } from '../generated/prisma/client.js'
const { PrismaClient } = pkg
// Prisma 7 passe par le pilote pg. Délai de connexion de 5 s, comme le moteur de Prisma 6 (pg attend sans fin par défaut)
export const prisma = new PrismaClient({
datasources: {
db: {
url: env.DATABASE_URL,
},
},
adapter: new PrismaPg({ connectionString: env.DATABASE_URL, connectionTimeoutMillis: 5_000 }),
})
+30
View File
@@ -0,0 +1,30 @@
import type { Prisma, PrismaClient } from '../generated/prisma/client.js'
import { env } from '../config/env.js'
// Inscription fermée (ALLOW_REGISTRATION=false) : une instance sans aucun compte accepte quand même le premier,
// sinon personne ne pourrait jamais s'y connecter. Dès qu'un compte existe, plus aucun ne se crée.
export async function isRegistrationOpen(prisma: PrismaClient): Promise<boolean> {
if (env.ALLOW_REGISTRATION) return true
return (await prisma.user.count()) === 0
}
// Seul chemin de création d'un compte. Inscription fermée : le compte n'est créé que si la base n'en a aucun,
// sous un verrou de transaction pour que deux inscriptions simultanées sur une instance neuve n'en créent pas
// deux. Renvoie null quand la création est refusée.
// invited : l'appelant a vérifié une invitation à co-gérer un arbre pour cette adresse (issue #145) ; le
// propriétaire de l'arbre a été admis par l'admin du serveur, son invitation ouvre un compte.
export async function createAccountIfAllowed<T>(
prisma: PrismaClient,
create: (tx: Prisma.TransactionClient) => Promise<T>,
{ invited = false }: { invited?: boolean } = {},
): Promise<T | null> {
if (env.ALLOW_REGISTRATION || invited) return create(prisma)
return prisma.$transaction(async (tx) => {
// Clé arbitraire, propre à la création de compte. pg_advisory_xact_lock renvoie void : $executeRaw, pas $queryRaw.
await tx.$executeRaw`SELECT pg_advisory_xact_lock(113001)`
if ((await tx.user.count()) > 0) return null
return create(tx)
})
}
+207
View File
@@ -0,0 +1,207 @@
import { env } from '../config/env.js'
// Sources publiques interrogées par le panneau « Chercher quelqu'un » (SEARCH_SOURCES). Chaque source
// reçoit la même question et rend des résultats au même format, que le front affiche sans savoir d'où
// ils viennent. Ajouter une source : un objet de plus dans SOURCES, avec ce qu'elle envoie au service.
export type SearchQuery = {
firstName: string
lastName: string
birthYear?: number
place?: string
}
export type SearchEvent = { date: string | null; place: string | null }
export type SearchResult = {
id: string
firstName: string
lastName: string
sex: 'M' | 'F' | null
birth: SearchEvent
death: SearchEvent
url: string
// good : prénom, nom, année et lieu de naissance concordent
match: 'good' | 'partial'
}
type Source = {
id: string
search: (query: SearchQuery) => Promise<SearchResult[]>
}
export class SearchSourceUnavailableError extends Error {}
const TIMEOUT_MS = 10_000
const MAX_RESULTS = 10
const USER_AGENT = 'Parenthese (+https://parenthese.io)'
export function normalizeName(value: string): string {
return value.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase().replace(/[^a-z]+/g, ' ').trim()
}
// « 18980824 » ou « 1898-08-24 » → « 1898-08-24 » ; « 0000-00-00 » ou mois inconnu → année seule
function toIsoDate(raw: unknown): string | null {
const digits = String(raw ?? '').replace(/-/g, '')
if (!/^\d{8}$/.test(digits) || digits.startsWith('0000')) return null
const [year, month, day] = [digits.slice(0, 4), digits.slice(4, 6), digits.slice(6, 8)]
if (month === '00') return year
if (day === '00') return `${year}-${month}`
return `${year}-${month}-${day}`
}
function yearOf(date: string | null): number | null {
return date ? Number(date.slice(0, 4)) : null
}
function grade(query: SearchQuery, result: Omit<SearchResult, 'match'>): SearchResult['match'] {
const sameLast = normalizeName(result.lastName) === normalizeName(query.lastName)
const firstNames = normalizeName(result.firstName).split(' ')
const sameFirst = firstNames.includes(normalizeName(query.firstName).split(' ')[0] ?? '')
const birthYear = yearOf(result.birth.date)
const sameYear = !query.birthYear || (birthYear !== null && Math.abs(birthYear - query.birthYear) <= 1)
// Sans lieu, un nom et une année courants ramènent des homonymes : seul le lieu de naissance départage
const samePlace = Boolean(query.place && result.birth.place
&& normalizeName(result.birth.place).startsWith(normalizeName(query.place)))
return sameLast && sameFirst && sameYear && samePlace ? 'good' : 'partial'
}
async function fetchJson(url: URL, headers: Record<string, string> = {}): Promise<unknown> {
let response: Response
try {
response = await fetch(url, { headers: { 'User-Agent': USER_AGENT, Accept: 'application/json', ...headers }, signal: AbortSignal.timeout(TIMEOUT_MS) })
} catch {
throw new SearchSourceUnavailableError('unreachable')
}
// matchID répond 401 ou 422 quand la recherche sans jeton est épuisée ou que le jeton a expiré
if (response.status === 401 || response.status === 422) throw new SearchSourceUnavailableError('quota')
if (!response.ok) throw new SearchSourceUnavailableError(`http_${response.status}`)
try {
return await response.json()
} catch {
throw new SearchSourceUnavailableError('invalid_json')
}
}
// matchID demande une recherche par seconde au plus : les appels de toute l'instance passent l'un après l'autre
let matchIdQueue: Promise<unknown> = Promise.resolve()
const MATCHID_INTERVAL_MS = 1100
function throttled<T>(run: () => Promise<T>): Promise<T> {
const next = matchIdQueue.then(run, run)
matchIdQueue = next.then(
() => new Promise((resolve) => setTimeout(resolve, MATCHID_INTERVAL_MS)),
() => new Promise((resolve) => setTimeout(resolve, MATCHID_INTERVAL_MS)),
)
return next
}
type MatchIdLocation = { city?: string | string[]; departmentCode?: string; country?: string }
type MatchIdPerson = {
id: string
name?: { first?: string[] | string; last?: string }
sex?: string
birth?: { date?: string; location?: MatchIdLocation }
death?: { date?: string; location?: MatchIdLocation }
}
function matchIdPlace(location: MatchIdLocation | undefined): string | null {
if (!location) return null
const city = Array.isArray(location.city) ? location.city[0] : location.city
if (!city) return location.country || null
if (location.country && location.country !== 'France') return `${city}, ${location.country}`
return location.departmentCode ? `${city} (${location.departmentCode})` : city
}
// Décès en France depuis 1970 : fichier de l'INSEE, cherché par matchID (deces.matchid.io)
const deces: Source = {
id: 'deces',
search: (query) => throttled(async () => {
const url = new URL('https://deces.matchid.io/deces/api/v1/search')
url.searchParams.set('firstName', query.firstName)
url.searchParams.set('lastName', query.lastName)
if (query.birthYear) url.searchParams.set('birthDate', String(query.birthYear))
url.searchParams.set('size', String(MAX_RESULTS))
const headers: Record<string, string> = env.MATCHID_TOKEN ? { Authorization: `Bearer ${env.MATCHID_TOKEN}` } : {}
const payload = await fetchJson(url, headers) as { response?: { persons?: MatchIdPerson[] } }
const persons = payload?.response?.persons ?? []
return persons.map((person) => {
const first = person.name?.first
const base: Omit<SearchResult, 'match'> = {
id: person.id,
firstName: Array.isArray(first) ? first.join(' ') : first ?? '',
lastName: person.name?.last ?? '',
sex: person.sex === 'M' || person.sex === 'F' ? person.sex : null,
birth: { date: toIsoDate(person.birth?.date), place: matchIdPlace(person.birth?.location) },
death: { date: toIsoDate(person.death?.date), place: matchIdPlace(person.death?.location) },
url: `https://deces.matchid.io/id/${encodeURIComponent(person.id)}`,
}
return { ...base, match: grade(query, base) }
})
}),
}
type WikiTreeMatch = {
Id: number
Name: string
FirstName?: string
MiddleName?: string
LastNameAtBirth?: string
Gender?: string
BirthDate?: string
DeathDate?: string
BirthLocation?: string
DeathLocation?: string
}
// WikiTree : arbre mondial tenu par ses membres, profils publics seulement (api.wikitree.com)
const wikitree: Source = {
id: 'wikitree',
search: async (query) => {
const url = new URL('https://api.wikitree.com/api.php')
url.searchParams.set('action', 'searchPerson')
url.searchParams.set('appId', 'parenthese')
url.searchParams.set('FirstName', query.firstName)
url.searchParams.set('LastName', query.lastName)
if (query.birthYear) {
url.searchParams.set('BirthDate', `${query.birthYear}-01-01`)
url.searchParams.set('dateSpread', '2')
}
url.searchParams.set('limit', String(MAX_RESULTS))
const payload = await fetchJson(url) as [{ matches?: WikiTreeMatch[] }] | undefined
const matches = Array.isArray(payload) ? payload[0]?.matches ?? [] : []
// La recherche de WikiTree est floue (« du Pont » pour Dupont, autres prénoms) : même nom de famille seulement
const wantedLast = normalizeName(query.lastName).replace(/ /g, '')
return matches.filter((match) => normalizeName(match.LastNameAtBirth ?? '').replace(/ /g, '') === wantedLast).map((match) => {
const base: Omit<SearchResult, 'match'> = {
id: String(match.Id),
firstName: [match.FirstName, match.MiddleName].filter(Boolean).join(' '),
lastName: match.LastNameAtBirth ?? '',
sex: match.Gender === 'Male' ? 'M' : match.Gender === 'Female' ? 'F' : null,
birth: { date: toIsoDate(match.BirthDate), place: match.BirthLocation || null },
death: { date: toIsoDate(match.DeathDate), place: match.DeathLocation || null },
url: `https://www.wikitree.com/wiki/${encodeURIComponent(match.Name)}`,
}
return { ...base, match: grade(query, base) }
})
},
}
const SOURCES: Source[] = [deces, wikitree]
// Sources allumées par l'opérateur, dans l'ordre de SOURCES ; un nom inconnu est signalé puis ignoré
export function enabledSearchSources(): string[] {
const wanted = env.SEARCH_SOURCES.split(',').map((entry) => entry.trim().toLowerCase()).filter(Boolean)
for (const name of wanted) {
if (!SOURCES.some((source) => source.id === name)) console.warn(`SEARCH_SOURCES : source inconnue ignorée : ${name}`)
}
return SOURCES.filter((source) => wanted.includes(source.id)).map((source) => source.id)
}
export async function searchSource(id: string, query: SearchQuery): Promise<SearchResult[]> {
const source = SOURCES.find((candidate) => candidate.id === id)
if (!source) throw new SearchSourceUnavailableError('unknown_source')
const results = await source.search(query)
// Les meilleures concordances d'abord, l'ordre du service ensuite
return results.slice(0, MAX_RESULTS).sort((a, b) => Number(b.match === 'good') - Number(a.match === 'good'))
}
+14
View File
@@ -0,0 +1,14 @@
import type { Prisma, PrismaClient } from '../generated/prisma/client.js'
type LinkReader = Pick<PrismaClient, 'parentChildLink'> | Pick<Prisma.TransactionClient, 'parentChildLink'>
// Ordre d'un nouvel enfant : après ses frères et sœurs, pour qu'une fratrie déjà réordonnée
// ne voie pas le dernier arrivé passer devant (le placement trie les enfants sur displayOrder).
export async function nextSiblingOrder(db: LinkReader, treeId: string, parentPersonId: string): Promise<number> {
const last = await db.parentChildLink.findFirst({
where: { treeId, parentPersonId, deletedAt: null },
orderBy: { displayOrder: 'desc' },
select: { displayOrder: true },
})
return Number.isInteger(last?.displayOrder) ? (last!.displayOrder as number) + 1 : 1
}
+29 -1
View File
@@ -1,11 +1,21 @@
import type { PrismaClient } from '../generated/prisma/client.js'
// Sessions fermées par « Se déconnecter » ou par la suppression du compte, jusqu'à la fin de leur jeton.
// Gardées en mémoire pour ne coûter aucune requête par appel, et en base pour survivre au redémarrage de l'API :
// avant, chaque déploiement les oubliait et un jeton déconnecté redevenait valable jusqu'à 7 jours.
// L'API tourne en un seul processus (pm2 fork, un conteneur) : la mémoire suffit entre deux redémarrages.
const revokedUserTokens = new Map<string, number>()
function nowSeconds(): number {
return Math.floor(Date.now() / 1000)
}
export function revokeUserToken(jti: string, exp: number): void {
type RevocationStore = Pick<PrismaClient, 'revokedSession'>
export async function revokeUserToken(store: RevocationStore, jti: string, exp: number): Promise<void> {
revokedUserTokens.set(jti, exp)
const expiresAt = new Date(exp * 1000)
await store.revokedSession.upsert({ where: { jti }, create: { jti, expiresAt }, update: {} })
}
export function isUserTokenRevoked(jti: string): boolean {
@@ -22,3 +32,21 @@ export function isUserTokenRevoked(jti: string): boolean {
return true
}
// Au démarrage : les déconnexions encore valables reviennent en mémoire, les expirées sont effacées. L'effacement
// n'a lieu qu'ici : la table grossit entre deux redémarrages, d'une ligne par déconnexion, ce qui reste minuscule
// avec un déploiement par jour ou presque.
export async function loadRevokedTokens(store: RevocationStore): Promise<number> {
const now = new Date()
await store.revokedSession.deleteMany({ where: { expiresAt: { lte: now } } })
const rows = await store.revokedSession.findMany({ where: { expiresAt: { gt: now } }, select: { jti: true, expiresAt: true } })
for (const row of rows) {
revokedUserTokens.set(row.jti, Math.floor(row.expiresAt.getTime() / 1000))
}
return rows.length
}
// Tests : repartir d'un processus neuf, comme après un redémarrage
export function forgetRevokedTokensInMemory(): void {
revokedUserTokens.clear()
}
+121
View File
@@ -0,0 +1,121 @@
import { createHash, randomBytes } from 'node:crypto'
import type { Prisma, PrismaClient } from '../generated/prisma/client.js'
// Co-gestion d'un arbre (issue #145). Le propriétaire invite une adresse ; Parenthèse n'envoie pas de mail, il
// donne un lien à transmettre. Seule l'empreinte du jeton est gardée en base.
//
// Qui est rattaché, et comment :
// - par le lien, avec un compte à la même adresse (mot de passe ou OIDC). L'inscription par mot de passe ne
// vérifie pas l'adresse : le lien est la preuve, l'adresse seule ne suffit jamais ;
// - sans le lien, seulement par OIDC, quand le fournisseur dit l'email vérifié (claimInvitationsForVerifiedEmail).
export const INVITATION_TTL_MS = 14 * 24 * 60 * 60 * 1000
export const MAX_PENDING_INVITATIONS = 20
type Db = PrismaClient | Prisma.TransactionClient
export function newInvitationToken(): { token: string; tokenHash: string } {
const token = randomBytes(32).toString('base64url')
return { token, tokenHash: hashInvitationToken(token) }
}
export function hashInvitationToken(token: string): string {
return createHash('sha256').update(token).digest('hex')
}
export function normalizeEmail(email: string): string {
return email.trim().toLowerCase()
}
// Invitation encore valable : ni acceptée, ni annulée, ni expirée
export function pendingInvitationWhere(now = new Date()): Prisma.TreeInvitationWhereInput {
return { acceptedAt: null, revokedAt: null, expiresAt: { gt: now }, tree: { deletedAt: null } }
}
export async function findPendingInvitationByToken(db: Db, token: string) {
if (!token || token.length > 200) return null
return db.treeInvitation.findFirst({
where: { tokenHash: hashInvitationToken(token), ...pendingInvitationWhere() },
include: { tree: { select: { id: true, name: true, icon: true, ownerUserId: true, owner: { select: { firstName: true } } } } },
})
}
export async function hasPendingInvitationForEmail(db: Db, email: string): Promise<boolean> {
const found = await db.treeInvitation.findFirst({ where: { email: normalizeEmail(email), ...pendingInvitationWhere() }, select: { id: true } })
return Boolean(found)
}
export type AcceptResult = { ok: true; treeId: string } | { ok: false; error: 'invitation_invalid' | 'invitation_email_mismatch' | 'already_owner' }
// Accepte une invitation pour ce compte : co-gestionnaire (admin) de l'arbre. L'invitation ne sert qu'une fois,
// même pour deux acceptations simultanées (updateMany conditionnel dans la transaction).
export async function acceptInvitation(prisma: PrismaClient, invitationId: string, userId: string, userEmail: string): Promise<AcceptResult> {
return prisma.$transaction(async (tx) => {
const invitation = await tx.treeInvitation.findFirst({
where: { id: invitationId, ...pendingInvitationWhere() },
select: { id: true, treeId: true, email: true, tree: { select: { ownerUserId: true } } },
})
if (!invitation) return { ok: false, error: 'invitation_invalid' } as const
if (normalizeEmail(userEmail) !== invitation.email) return { ok: false, error: 'invitation_email_mismatch' } as const
if (invitation.tree.ownerUserId === userId) return { ok: false, error: 'already_owner' } as const
const claimed = await tx.treeInvitation.updateMany({
where: { id: invitation.id, ...pendingInvitationWhere() },
data: { acceptedAt: new Date(), acceptedUserId: userId },
})
if (claimed.count !== 1) return { ok: false, error: 'invitation_invalid' } as const
const existing = await tx.treeMembership.findUnique({
where: { treeId_userId: { treeId: invitation.treeId, userId } },
select: { role: true },
})
if (!existing) {
await tx.treeMembership.create({ data: { treeId: invitation.treeId, userId, role: 'admin' } })
} else if (existing.role === 'member') {
await tx.treeMembership.update({ where: { treeId_userId: { treeId: invitation.treeId, userId } }, data: { role: 'admin' } })
}
// L'arbre s'ouvre à la prochaine visite
await tx.user.update({
where: { id: userId },
data: { lastOpenedTreeId: invitation.treeId, lastOpenedAccessMode: 'member', lastOpenedRole: 'admin', lastOpenedAt: new Date() },
})
await tx.auditLog.create({
data: {
treeId: invitation.treeId,
actorType: 'user',
actorId: userId,
action: 'manager_joined',
entityType: 'tree',
entityId: invitation.treeId,
payloadJson: { invitationId: invitation.id },
},
})
return { ok: true, treeId: invitation.treeId } as const
})
}
// Connexion OIDC avec un email vérifié par le fournisseur : les invitations en attente à cette adresse sont
// acceptées sans le lien. Jamais pour un email non vérifié, jamais pour une connexion par mot de passe, et
// seulement si c'est l'adresse du compte : une identité déjà reliée dont l'email a changé chez le fournisseur
// ne prend pas les invitations faites à sa nouvelle adresse (elle passe par le lien).
export async function claimInvitationsForVerifiedEmail(prisma: PrismaClient, userId: string, verifiedEmail: string): Promise<string[]> {
const email = normalizeEmail(verifiedEmail)
const invitations = await prisma.treeInvitation.findMany({ where: { email, ...pendingInvitationWhere() }, select: { id: true } })
if (!invitations.length) return []
const account = await prisma.user.findUnique({ where: { id: userId }, select: { email: true } })
if (!account || normalizeEmail(account.email) !== email) return []
const joined: string[] = []
for (const invitation of invitations) {
const result = await acceptInvitation(prisma, invitation.id, userId, email)
if (result.ok) joined.push(result.treeId)
}
return joined
}
// « jacques@exemple.fr » devient « ja•••@exemple.fr » sur la page publique du lien
export function maskEmail(email: string): string {
const [local, domain] = email.split('@')
if (!domain) return '•••'
return `${local.slice(0, Math.min(2, local.length))}•••@${domain}`
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { rm } from 'node:fs/promises'
import path from 'node:path'
import type { Prisma, PrismaClient } from '@prisma/client'
import type { Prisma, PrismaClient } from '../generated/prisma/client.js'
import { env } from '../config/env.js'
+17 -1
View File
@@ -6,8 +6,11 @@ import { env } from '../config/env.js'
// Une ouverture d'arbre = une visite. Les rechargements du même visiteur dans cette fenêtre
// (graphe rechargé après une modification, onglet rouvert) ne comptent qu'une fois.
export const VISIT_DEDUP_WINDOW_MS = 30 * 60 * 1000
// Au-delà, les visites sont effacées (lecture de l'onglet Visites)
// Au-delà, les visites sont effacées : à l'enregistrement d'une visite (au plus une fois par heure,
// pour tous les arbres) et à la lecture de l'onglet Visites
export const VISIT_RETENTION_DAYS = 365
const PURGE_INTERVAL_MS = 60 * 60 * 1000
let lastPurgeAt = 0
export type VisitAccessKind = 'share' | 'member' | 'shared_account'
@@ -34,6 +37,18 @@ export function visitorKeyOf(treeId: string, ip: string, userAgent: string): str
return createHash('sha256').update(`${env.JWT_SECRET}|${treeId}|${ip}|${userAgent}`).digest('hex').slice(0, 32)
}
// Compte supprimé : ses visites restent comptées, sans son identifiant
export function deletedAccountVisitorKey(userId: string): string {
return createHash('sha256').update(`${env.JWT_SECRET}|deleted|${userId}`).digest('hex').slice(0, 32)
}
async function purgeExpiredVisits(app: FastifyInstance): Promise<void> {
const now = Date.now()
if (now - lastPurgeAt < PURGE_INTERVAL_MS) return
lastPurgeAt = now
await app.prisma.treeVisit.deleteMany({ where: { createdAt: { lt: new Date(now - VISIT_RETENTION_DAYS * 24 * 60 * 60 * 1000) } } })
}
// N'échoue jamais : un journal qui casse ne doit pas empêcher d'ouvrir l'arbre
export async function recordTreeVisit(
app: FastifyInstance,
@@ -69,6 +84,7 @@ export async function recordTreeVisit(
device: deviceFromUserAgent(userAgent),
},
})
await purgeExpiredVisits(app)
} catch (error) {
request.log.warn({ err: error, treeId }, 'tree_visit_record_failed')
}
+3 -3
View File
@@ -50,9 +50,9 @@ export function resetTreeUnlockGuard(): void {
attemptsByTree.clear()
}
// bcryptjs calcule en JavaScript sur le processus de l'API (≈ 250 ms par comparaison au coût 12) : des essais
// lancés en parallèle depuis quelques adresses suffisaient à le saturer pour toutes les familles. Les
// comparaisons de mot de passe de partage passent une par une, le reste de l'API garde la main entre deux.
// Une comparaison bcrypt coûte ≈ 250 ms au coût 12. Même calculée hors de la boucle (bcrypt natif), des essais
// lancés en parallèle depuis quelques adresses occuperaient tous les fils de calcul, connexions comprises. Les
// comparaisons de mot de passe de partage passent une par une.
// Au-delà de MAX_WAITING comparaisons en attente, on refuse tout de suite (null) plutôt que d'allonger la file.
const MAX_WAITING = 20
let queue: Promise<unknown> = Promise.resolve()
+18
View File
@@ -0,0 +1,18 @@
import type { FastifyInstance } from 'fastify'
import { randomUUID } from 'node:crypto'
import { env } from '../config/env.js'
// Session d'un compte, la même quelle que soit la façon de se connecter (mot de passe ou fournisseur OIDC)
export function signUserSession(app: FastifyInstance, user: { id: string; email: string }): string {
return app.jwt.sign(
{
kind: 'user',
sub: user.id,
jti: randomUUID(),
userId: user.id,
email: user.email,
},
{ expiresIn: env.JWT_EXPIRES_IN },
)
}
+58 -55
View File
@@ -1,5 +1,5 @@
import type { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'
import { Prisma } from '@prisma/client'
import { Prisma } from '../generated/prisma/client.js'
import { z } from 'zod'
import {
@@ -306,64 +306,67 @@ export const annotationRoutes: FastifyPluginAsync = async (app) => {
return reply.code(400).send({ error: 'invalid_annotation_photo_path' })
}
// Creates
for (const [index, item] of payload.data.create.entries()) {
await app.prisma.annotation.create({
// Créations, modifications, suppressions et trace : tout ou rien, un nouvel essai ne double rien
await app.prisma.$transaction(async (tx) => {
// Creates
for (const [index, item] of payload.data.create.entries()) {
await tx.annotation.create({
data: {
treeId,
type: item.type,
x: item.x,
y: item.y,
content: createContents[index] as string,
style: (item.style ?? {}) as Prisma.InputJsonValue,
zIndex: item.zIndex ?? 0,
createdBy: user.userId,
},
})
results.created++
}
// Updates
for (const [index, item] of payload.data.update.entries()) {
const data: Record<string, unknown> = {}
if (item.x !== undefined) data.x = item.x
if (item.y !== undefined) data.y = item.y
if (item.content !== undefined) data.content = updateContents[index]
if (item.style !== undefined) data.style = item.style
if (item.zIndex !== undefined) data.zIndex = item.zIndex
if (Object.keys(data).length > 0) {
const updated = await tx.annotation.updateMany({
where: { id: item.id, treeId, deletedAt: null },
data,
})
if (updated.count > 0) results.updated++
}
}
// Deletes
if (payload.data.delete.length > 0) {
const deleted = await tx.annotation.updateMany({
where: {
id: { in: payload.data.delete },
treeId,
deletedAt: null,
},
data: { deletedAt: new Date() },
})
results.deleted = deleted.count
}
await tx.auditLog.create({
data: {
treeId,
type: item.type,
x: item.x,
y: item.y,
content: createContents[index] as string,
style: (item.style ?? {}) as Prisma.InputJsonValue,
zIndex: item.zIndex ?? 0,
createdBy: user.userId,
actorType: 'user',
actorId: user.userId,
action: 'annotations_batch',
entityType: 'annotation',
entityId: treeId,
payloadJson: results,
},
})
results.created++
}
// Updates
for (const [index, item] of payload.data.update.entries()) {
const data: Record<string, unknown> = {}
if (item.x !== undefined) data.x = item.x
if (item.y !== undefined) data.y = item.y
if (item.content !== undefined) data.content = updateContents[index]
if (item.style !== undefined) data.style = item.style
if (item.zIndex !== undefined) data.zIndex = item.zIndex
if (Object.keys(data).length > 0) {
const updated = await app.prisma.annotation.updateMany({
where: { id: item.id, treeId, deletedAt: null },
data,
})
if (updated.count > 0) results.updated++
}
}
// Deletes
if (payload.data.delete.length > 0) {
const deleted = await app.prisma.annotation.updateMany({
where: {
id: { in: payload.data.delete },
treeId,
deletedAt: null,
},
data: { deletedAt: new Date() },
})
results.deleted = deleted.count
}
await app.prisma.auditLog.create({
data: {
treeId,
actorType: 'user',
actorId: user.userId,
action: 'annotations_batch',
entityType: 'annotation',
entityId: treeId,
payloadJson: results,
},
})
return reply.send({ ok: true, ...results })
+136 -35
View File
@@ -3,8 +3,13 @@ import { randomUUID } from 'node:crypto'
import { z } from 'zod'
import { env } from '../config/env.js'
import { hashPassword, verifyPassword } from '../lib/auth.js'
import { confirmsWithPassword, hashPassword, verifyPassword } from '../lib/auth.js'
import { isOidcEnabled } from '../lib/oidc.js'
import { createAccountIfAllowed, isRegistrationOpen } from '../lib/registration.js'
import { acceptInvitation, findPendingInvitationByToken } from '../lib/tree-invitations.js'
import { signUserSession } from '../lib/user-session.js'
import { revokeUserToken } from '../lib/token-revocation.js'
import { deletedAccountVisitorKey } from '../lib/tree-visits.js'
import { deleteTreeRecords, removeTreeMediaDirectories } from '../lib/tree-purge.js'
import type { AnyJwtPayload } from '../types/auth.js'
@@ -20,10 +25,27 @@ const credentialsSchema = z.object({
password: z.string().min(8).max(128),
})
const deleteAccountSchema = z.object({
password: z.string().min(1).max(128),
// Prénom du compte, facultatif : seulement à l'inscription (la connexion ne le demande pas)
const registerSchema = credentialsSchema.extend({
firstName: z.string().max(60).optional().nullable(),
// Lien d'invitation à co-gérer un arbre : ouvre un compte même inscription fermée, à l'adresse invitée seulement
invitationToken: z.string().min(1).max(200).optional(),
})
function trimmedFirstName(value: string | null | undefined): string | null {
const trimmed = (value ?? '').trim()
return trimmed ? trimmed : null
}
// Suppression d'un compte sans mot de passe : connexion chez le fournisseur de moins de 15 minutes
const RECENT_SIGN_IN_MS = 15 * 60 * 1000
// Un compte avec mot de passe le redonne ; un compte né d'une connexion OIDC, sans mot de passe, retape son email
const deleteAccountSchema = z.object({
password: z.string().min(1).max(128).optional(),
confirmEmail: z.string().min(1).max(320).optional(),
}).refine((value) => value.password || value.confirmEmail, { message: 'password or confirmEmail required' })
const updateLastTreeSchema = z.object({
treeId: z.string().min(1),
accessMode: z.enum(['member', 'share']),
@@ -83,6 +105,22 @@ async function resolveAccessibleLastTree(
}
export const authRoutes: FastifyPluginAsync = async (app) => {
// Ce que l'écran d'accueil propose : l'app cache « Créer un compte » quand l'instance n'en accepte plus
app.get(
'/options',
{ config: { rateLimit: { max: 60, timeWindow: '1 minute' } } },
async (_request, reply) => {
const registration = await isRegistrationOpen(app.prisma)
return reply.send({
registration,
passwordLogin: env.PASSWORD_LOGIN,
oidc: isOidcEnabled() ? { name: env.OIDC_PROVIDER_NAME } : null,
// Rien à proposer quand l'instance ne crée plus de compte
signupPrompts: env.SIGNUP_PROMPTS && registration && env.PASSWORD_LOGIN,
})
},
)
app.post(
'/register',
{
@@ -94,41 +132,67 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
},
},
async (request, reply) => {
const parsed = credentialsSchema.safeParse(request.body)
const parsed = registerSchema.safeParse(request.body)
if (!parsed.success) {
return reply.code(400).send({ error: 'invalid_payload', details: parsed.error.flatten() })
}
// Connexion par le fournisseur seulement (PASSWORD_LOGIN=false) : pas de compte à mot de passe
if (!env.PASSWORD_LOGIN) {
return reply.code(403).send({ error: 'password_login_disabled' })
}
const email = parsed.data.email.trim().toLowerCase()
const invitation = parsed.data.invitationToken ? await findPendingInvitationByToken(app.prisma, parsed.data.invitationToken) : null
if (parsed.data.invitationToken && !invitation) {
return reply.code(410).send({ error: 'invitation_invalid' })
}
if (invitation && invitation.email !== email) {
return reply.code(403).send({ error: 'invitation_email_mismatch' })
}
// Inscription fermée : refusée avant toute lecture, la réponse ne dit pas si l'adresse a un compte
if (!invitation && !(await isRegistrationOpen(app.prisma))) {
return reply.code(403).send({ error: 'registration_closed' })
}
const existing = await app.prisma.user.findUnique({ where: { email } })
if (existing) {
return reply.code(409).send({ error: 'email_already_exists' })
}
const passwordHash = await hashPassword(parsed.data.password)
const user = await app.prisma.user.create({
const user = await createAccountIfAllowed(app.prisma, (tx) => tx.user.create({
data: {
email,
passwordHash,
firstName: trimmedFirstName(parsed.data.firstName),
},
select: {
id: true,
email: true,
firstName: true,
},
})
}), { invited: Boolean(invitation) })
// Un autre compte est né pendant le calcul du hash, sur une instance fermée
if (!user) {
return reply.code(403).send({ error: 'registration_closed' })
}
const token = app.jwt.sign(
{
kind: 'user',
sub: user.id,
jti: randomUUID(),
userId: user.id,
email: user.email,
},
{ expiresIn: env.JWT_EXPIRES_IN },
)
// Compte ouvert par une invitation : co-gestionnaire de l'arbre tout de suite
let joinedTreeId: string | null = null
if (invitation) {
const accepted = await acceptInvitation(app.prisma, invitation.id, user.id, user.email)
if (accepted.ok) {
joinedTreeId = accepted.treeId
} else if (!(await isRegistrationOpen(app.prisma))) {
// Invitation annulée ou servie entre-temps, sur une instance fermée : pas de compte sans arbre
await app.prisma.user.delete({ where: { id: user.id } })
return reply.code(410).send({ error: 'invitation_invalid' })
}
}
return reply.code(201).send({ token, user })
return reply.code(201).send({ token: signUserSession(app, user), user: { ...user, hasPassword: true }, joinedTreeId })
},
)
@@ -143,6 +207,10 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
},
},
async (request, reply) => {
if (!env.PASSWORD_LOGIN) {
return reply.code(403).send({ error: 'password_login_disabled' })
}
const parsed = credentialsSchema.safeParse(request.body)
if (!parsed.success) {
return reply.code(400).send({ error: 'invalid_payload', details: parsed.error.flatten() })
@@ -154,12 +222,14 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
select: {
id: true,
email: true,
firstName: true,
passwordHash: true,
},
})
if (!user) {
// Même durée qu'un mauvais mot de passe : la réponse ne dit pas si l'adresse a un compte
// Adresse inconnue, ou compte né d'une connexion OIDC (sans mot de passe) : même durée qu'un mauvais mot de
// passe, la réponse ne dit pas si l'adresse a un compte
if (!user?.passwordHash) {
await verifyPassword(parsed.data.password, await getDummyHash())
return reply.code(401).send({ error: 'invalid_credentials' })
}
@@ -169,27 +239,37 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
return reply.code(401).send({ error: 'invalid_credentials' })
}
const token = app.jwt.sign(
{
kind: 'user',
sub: user.id,
jti: randomUUID(),
userId: user.id,
email: user.email,
},
{ expiresIn: env.JWT_EXPIRES_IN },
)
return reply.send({
token,
token: signUserSession(app, user),
user: {
id: user.id,
email: user.email,
firstName: user.firstName,
hasPassword: true,
},
})
},
)
// Profil du compte connecté (prénom compris) : la session restaurée sur un autre appareil le retrouve
app.get('/me', async (request, reply) => {
if (!request.actor || request.actor.kind !== 'user') {
return reply.code(401).send({ error: 'authentication_required' })
}
const user = await app.prisma.user.findUnique({
where: { id: request.actor.userId },
select: { id: true, email: true, firstName: true, passwordHash: true, identities: { select: { id: true }, take: 1 } },
})
if (!user) {
return reply.code(401).send({ error: 'authentication_required' })
}
// hasPassword : l'écran de suppression demande le mot de passe, ou l'email pour un compte sans mot de passe.
// oidcLinked : le compte se connecte déjà par le fournisseur ; sinon le compte propose « Relier à … »
return reply.send({ user: { id: user.id, email: user.email, firstName: user.firstName, hasPassword: confirmsWithPassword(user.passwordHash), oidcLinked: (user.identities?.length ?? 0) > 0 } })
})
app.get('/me/last-tree', async (request, reply) => {
if (!request.actor || request.actor.kind !== 'user') {
return reply.code(401).send({ error: 'authentication_required' })
@@ -318,6 +398,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
where: { id: userId },
select: {
id: true,
email: true,
passwordHash: true,
},
})
@@ -326,12 +407,26 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
return reply.code(401).send({ error: 'authentication_required' })
}
const isValid = await verifyPassword(parsed.data.password, user.passwordHash)
const payload = await request.jwtVerify<AnyJwtPayload & { exp?: number; iat?: number }>()
const withPassword = confirmsWithPassword(user.passwordHash)
if (!withPassword) {
// Compte né d'une connexion OIDC, ou dont la connexion par mot de passe est fermée : pas de mot de passe à
// redonner. Il retape son email, et la session doit venir d'une connexion récente chez le fournisseur
// (une session volée ou oubliée ouverte ne suffit pas).
const sessionAgeMs = typeof payload.iat === 'number' ? Date.now() - payload.iat * 1000 : Infinity
if (sessionAgeMs > RECENT_SIGN_IN_MS) {
return reply.code(403).send({ error: 'recent_sign_in_required' })
}
}
const isValid = withPassword
? Boolean(parsed.data.password) && await verifyPassword(parsed.data.password as string, user.passwordHash as string)
: (parsed.data.confirmEmail ?? '').trim().toLowerCase() === user.email
if (!isValid) {
return reply.code(403).send({ error: 'invalid_password' })
}
const payload = await request.jwtVerify<AnyJwtPayload & { exp?: number }>()
// Arbres possédés (y compris ceux marqués supprimés) puis compte, dans une seule transaction :
// la relation propriétaire est en Restrict, les arbres doivent partir avant l'utilisateur.
@@ -350,12 +445,18 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
data: { submittedByLabel: 'Contributeur' },
})
// Ses visites des autres arbres restent comptées, sans son identifiant
await tx.treeVisit.updateMany({
where: { visitorKey: `user:${userId}` },
data: { visitorKey: deletedAccountVisitorKey(userId) },
})
await tx.user.delete({ where: { id: userId } })
return treeIds
})
if (payload.kind === 'user' && typeof payload.exp === 'number') {
revokeUserToken(payload.jti, payload.exp)
await revokeUserToken(app.prisma, payload.jti, payload.exp)
}
await removeTreeMediaDirectories(ownedTreeIds, request.log)
@@ -375,7 +476,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
}
if (typeof payload.exp === 'number') {
revokeUserToken(payload.jti, payload.exp)
await revokeUserToken(app.prisma, payload.jti, payload.exp)
}
return reply.send({ ok: true })
+271 -20
View File
@@ -1,16 +1,23 @@
import path from 'node:path'
import type { FastifyInstance, FastifyPluginAsync } from 'fastify'
import { Prisma } from '@prisma/client'
import { Prisma } from '../generated/prisma/client.js'
import { z } from 'zod'
import { env, UPLOAD_BODY_LIMIT } from '../config/env.js'
import { sanitizeAnnotationContentForRead } from '../lib/annotation-content.js'
import { AnnotationChangeError, applyAnnotationChange, normalizedAnnotationAfter, releaseRejectedPhotos, validateAnnotationChange } from '../lib/annotation-changes.js'
import { softDeletePersonCascade, softDeleteUnionCascade } from '../lib/entity-deletion.js'
import { removeStoredFile } from '../lib/media-files.js'
import { canReadTree } from '../lib/tree-access.js'
import { syncPersonMediaOrder } from '../lib/media-order.js'
import { mediaPeopleSchema, normalizeMediaPeople, replaceMediaPeople } from '../lib/media-people.js'
import type { Actor, MembershipRole } from '../types/auth.js'
import { findParentChildValidationError, findUnionValidationError } from '../utils/relationship-guards.js'
import { nextSiblingOrder } from '../lib/sibling-order.js'
import { personPrecisionResets, unionPrecisionResets } from '../lib/date-precision.js'
import { hasAnyName } from '../lib/person-names.js'
import { sexOrNull, sexSchema } from '../lib/person-sex.js'
const isoDateRegex = /^\d{4}-\d{2}-\d{2}$/
@@ -27,6 +34,8 @@ const sessionParamsSchema = z.object({
// remplissait la base à 12 Mo par envoi
const MAX_CHANGE_JSON_CHARS = 64_000
const MAX_PENDING_SESSIONS_PER_TREE = 200
// Annotations proposées avant la 1.5.1 : écartées à la relecture si elles ne se posent pas
const SKIPPABLE_ANNOTATION_ERRORS = new Set(['invalid_annotation_change', 'invalid_annotation_photo_path', 'unsupported_change_action_for_annotation'])
const boundedJson = z.unknown().optional().refine(
(value) => value === undefined || JSON.stringify(value).length <= MAX_CHANGE_JSON_CHARS,
{ message: 'change_too_large' },
@@ -66,31 +75,39 @@ const reviewSessionSchema = z.object({
})
const personCreateSchema = z.object({
firstName: z.string().min(1).max(120),
lastName: z.string().min(1).max(120),
firstName: z.string().trim().max(120),
lastName: z.string().trim().max(120),
birthName: z.string().max(120).optional().nullable(),
nickname: z.string().max(120).optional().nullable(),
birthDate: z.string().regex(isoDateRegex).optional().nullable(),
deathDate: z.string().regex(isoDateRegex).optional().nullable(),
isDeceased: z.boolean().optional(),
toVerify: z.boolean().optional(),
birthPlace: z.string().max(200).optional().nullable(),
deathPlace: z.string().max(200).optional().nullable(),
profession: z.string().max(200).optional().nullable(),
region: z.string().max(120).optional().nullable(),
nationality: z.string().max(120).optional().nullable(),
sex: z.string().max(20).optional().nullable(),
sex: sexSchema,
notes: z.string().max(2000).optional().nullable(),
})
}).refine((value) => hasAnyName(value.firstName, value.lastName), { message: 'name_required', path: ['lastName'] })
const personUpdateSchema = z
.object({
firstName: z.string().min(1).max(120).optional(),
lastName: z.string().min(1).max(120).optional(),
firstName: z.string().trim().max(120).optional(),
lastName: z.string().trim().max(120).optional(),
birthName: z.string().max(120).optional().nullable(),
nickname: z.string().max(120).optional().nullable(),
birthDate: z.string().regex(isoDateRegex).optional().nullable(),
deathDate: z.string().regex(isoDateRegex).optional().nullable(),
isDeceased: z.boolean().optional(),
toVerify: z.boolean().optional(),
birthPlace: z.string().max(200).optional().nullable(),
deathPlace: z.string().max(200).optional().nullable(),
profession: z.string().max(200).optional().nullable(),
region: z.string().max(120).optional().nullable(),
nationality: z.string().max(120).optional().nullable(),
sex: z.string().max(20).optional().nullable(),
sex: sexSchema,
notes: z.string().max(2000).optional().nullable(),
})
.refine((value) => Object.keys(value).length > 0, {
@@ -145,7 +162,11 @@ const reorderSchema = z.object({
type ContributionStatus = 'pending' | 'approved' | 'rejected'
type ParsedChange = z.infer<typeof contributionChangeSchema>
// Les souvenirs, les moments et les personnes d'un souvenir arrivent par leurs propres routes, jamais par une
// session envoyée en bloc
type ParsedChange = Omit<z.infer<typeof contributionChangeSchema>, 'entityType'> & {
entityType: z.infer<typeof contributionChangeSchema>['entityType'] | 'life_event' | 'media_people'
}
type TxClient = Prisma.TransactionClient
@@ -177,7 +198,7 @@ function recordTempRef(after: unknown, createdId: string, refs: RefMap) {
}
// Personnes d'abord, puis unions, puis liens : un lien vers une personne ajoutée s'applique après elle
const ENTITY_ORDER: Record<string, number> = { person: 0, union: 1, parent_child_link: 2, media: 3, annotation: 4 }
const ENTITY_ORDER: Record<string, number> = { person: 0, union: 1, parent_child_link: 2, media: 3, annotation: 4, media_people: 5 }
function byApplicationOrder<T extends { entityType: string; action: string }>(changes: T[]): T[] {
const rank = (change: T) => (ENTITY_ORDER[change.entityType] ?? 9) * 10 + (change.action === 'create' ? 0 : 1)
return changes.map((change, index) => ({ change, index }))
@@ -229,7 +250,7 @@ function stripAnnotationUrl<T>(value: T): T {
function toPrismaJson(
value: unknown,
): Prisma.InputJsonValue | Prisma.NullTypes.DbNull | Prisma.NullTypes.JsonNull | undefined {
): Prisma.InputJsonValue | typeof Prisma.DbNull | typeof Prisma.JsonNull | undefined {
if (value === undefined) {
return undefined
}
@@ -308,10 +329,50 @@ async function applyContributionChange(
// Fichiers à effacer une fois la transaction validée (suppression d'une personne)
filesToRemove: string[] = [],
): Promise<{ entityId: string | null }> {
// Les annotations sont appliquées par le client après la relecture (batchAnnotations) :
// rien à faire ici. Avant, elles tombaient dans la branche des liens et faisaient échouer la relecture.
// Annotation (dessin, texte, sticker, photo posée sur l'arbre) : posée ici, dans la transaction qui accepte
// la proposition. Avant la 1.5.1, le navigateur du relecteur la posait après coup, et pouvait la perdre.
if (change.entityType === 'annotation') {
return { entityId: change.entityId ?? null }
try {
return await applyAnnotationChange(tx, treeId, { action: change.action, entityId: change.entityId, after: change.after }, actorUserId)
} catch (error) {
if (error instanceof AnnotationChangeError) throw new ContributionRouteError(400, error.code)
throw error
}
}
// Moment proposé par la famille : il existe déjà, on le rend visible
if (change.entityType === 'life_event') {
if (change.action !== 'create' || !change.entityId) {
throw new ContributionRouteError(400, 'unsupported_change_action_for_life_event')
}
const updated = await tx.lifeEvent.updateMany({
where: { id: change.entityId, treeId, deletedAt: null },
data: { status: 'approved' },
})
if (updated.count === 0) {
throw new ContributionRouteError(400, 'invalid_entity_reference')
}
return { entityId: change.entityId }
}
// Personnes identifiées sur un souvenir : la liste proposée remplace l'actuelle. Souvenir supprimé entre
// l'envoi et la relecture : rien à faire, la relecture continue.
if (change.entityType === 'media_people') {
if (change.action !== 'update' || !change.entityId) {
throw new ContributionRouteError(400, 'unsupported_change_action_for_media_people')
}
const people = mediaPeopleSchema.safeParse(isRecord(change.after) ? change.after.people : undefined)
if (!people.success) {
throw new ContributionRouteError(400, 'invalid_media_people')
}
const media = await tx.mediaItem.findFirst({
where: { id: change.entityId, treeId, status: 'approved', deletedAt: null },
select: { id: true },
})
if (media) {
await replaceMediaPeople(tx, treeId, change.entityId, normalizeMediaPeople(people.data), actorUserId)
}
return { entityId: change.entityId }
}
// Souvenir envoyé par la famille : le fichier existe déjà, on le rend visible
@@ -356,9 +417,13 @@ async function applyContributionChange(
firstName: parsed.data.firstName.trim(),
lastName: parsed.data.lastName.trim(),
birthName: toTrimmedStringOrNull(parsed.data.birthName),
nickname: toTrimmedStringOrNull(parsed.data.nickname),
birthDate: toDateOrNull(parsed.data.birthDate),
deathDate: toDateOrNull(parsed.data.deathDate),
isDeceased: !parsed.data.deathDate && Boolean(parsed.data.isDeceased),
toVerify: Boolean(parsed.data.toVerify),
birthPlace: toTrimmedStringOrNull(parsed.data.birthPlace),
deathPlace: toTrimmedStringOrNull(parsed.data.deathPlace),
profession: toTrimmedStringOrNull(parsed.data.profession),
region: toTrimmedStringOrNull(parsed.data.region),
nationality: toTrimmedStringOrNull(parsed.data.nationality),
@@ -390,6 +455,27 @@ async function applyContributionChange(
throw new ContributionRouteError(400, 'invalid_change_after')
}
const nextDates = {
birthDate: parsed.data.birthDate !== undefined ? toDateOrNull(parsed.data.birthDate) : undefined,
deathDate: parsed.data.deathDate !== undefined ? toDateOrNull(parsed.data.deathDate) : undefined,
}
const currentDates = nextDates.birthDate !== undefined || nextDates.deathDate !== undefined
? await tx.person.findFirst({
where: { id: change.entityId, treeId, deletedAt: null },
select: { birthDate: true, deathDate: true },
})
: null
if (parsed.data.firstName !== undefined || parsed.data.lastName !== undefined) {
const before = await tx.person.findFirst({
where: { id: change.entityId, treeId, deletedAt: null },
select: { firstName: true, lastName: true },
})
if (before && !hasAnyName(parsed.data.firstName ?? before.firstName, parsed.data.lastName ?? before.lastName)) {
throw new ContributionRouteError(400, 'invalid_change_after')
}
}
const result = await tx.person.updateMany({
where: {
id: change.entityId,
@@ -397,12 +483,17 @@ async function applyContributionChange(
deletedAt: null,
},
data: {
...personPrecisionResets(currentDates, nextDates),
firstName: parsed.data.firstName !== undefined ? parsed.data.firstName.trim() : undefined,
lastName: parsed.data.lastName !== undefined ? parsed.data.lastName.trim() : undefined,
birthName: parsed.data.birthName !== undefined ? toTrimmedStringOrNull(parsed.data.birthName) : undefined,
birthDate: parsed.data.birthDate !== undefined ? toDateOrNull(parsed.data.birthDate) : undefined,
deathDate: parsed.data.deathDate !== undefined ? toDateOrNull(parsed.data.deathDate) : undefined,
nickname: parsed.data.nickname !== undefined ? toTrimmedStringOrNull(parsed.data.nickname) : undefined,
birthDate: nextDates.birthDate,
deathDate: nextDates.deathDate,
isDeceased: nextDates.deathDate ? false : parsed.data.isDeceased,
toVerify: parsed.data.toVerify,
birthPlace: parsed.data.birthPlace !== undefined ? toTrimmedStringOrNull(parsed.data.birthPlace) : undefined,
deathPlace: parsed.data.deathPlace !== undefined ? toTrimmedStringOrNull(parsed.data.deathPlace) : undefined,
profession: parsed.data.profession !== undefined ? toTrimmedStringOrNull(parsed.data.profession) : undefined,
region: parsed.data.region !== undefined ? toTrimmedStringOrNull(parsed.data.region) : undefined,
nationality: parsed.data.nationality !== undefined ? toTrimmedStringOrNull(parsed.data.nationality) : undefined,
@@ -510,6 +601,7 @@ async function applyContributionChange(
select: {
partner1PersonId: true,
partner2PersonId: true,
startDate: true,
},
})
if (!existingUnion) {
@@ -536,6 +628,9 @@ async function applyContributionChange(
deletedAt: null,
},
data: {
...unionPrecisionResets(existingUnion, {
startDate: parsed.data.startDate !== undefined ? toDateOrNull(parsed.data.startDate) : undefined,
}),
partner1PersonId: parsed.data.partner1PersonId,
partner2PersonId: parsed.data.partner2PersonId,
unionType: parsed.data.unionType,
@@ -632,7 +727,7 @@ async function applyContributionChange(
childPersonId: parsed.data.childPersonId,
viaUnionId: parsed.data.viaUnionId ?? null,
parentageType: parsed.data.parentageType ?? 'biologique',
displayOrder: parsed.data.displayOrder ?? 1,
displayOrder: parsed.data.displayOrder ?? await nextSiblingOrder(tx, treeId, parsed.data.parentPersonId),
},
select: {
id: true,
@@ -792,9 +887,13 @@ async function snapshotEntity(
firstName: true,
lastName: true,
birthName: true,
nickname: true,
birthDate: true,
deathDate: true,
isDeceased: true,
toVerify: true,
birthPlace: true,
deathPlace: true,
profession: true,
region: true,
nationality: true,
@@ -807,6 +906,7 @@ async function snapshotEntity(
...current,
birthDate: current.birthDate?.toISOString().slice(0, 10) ?? null,
deathDate: current.deathDate?.toISOString().slice(0, 10) ?? null,
sex: sexOrNull(current.sex),
}
}
@@ -999,6 +1099,115 @@ export async function resolveSubmissionContext(app: FastifyInstance, actor: Acto
}
export const contributionRoutes: FastifyPluginAsync = async (app) => {
// Personnes identifiées sur un souvenir. Administrateur : enregistré tout de suite. Famille (membre, lien
// de partage) : une proposition, appliquée à la relecture, ou tout de suite si l'arbre l'autorise.
app.put('/trees/:id/media/:mediaId/people', { config: { rateLimit: { max: 60, timeWindow: '1 minute' } } }, async (request, reply) => {
const params = z.object({ id: z.string().min(1), mediaId: z.string().min(1) }).safeParse(request.params)
if (!params.success) {
return reply.code(400).send({ error: 'invalid_params' })
}
const payload = z.object({
people: mediaPeopleSchema,
submittedByLabel: z.string().trim().min(1).max(120).optional(),
}).safeParse(request.body)
if (!payload.success) {
return reply.code(400).send({ error: 'invalid_payload' })
}
const treeId = params.data.id
const mediaId = params.data.mediaId
try {
const context = await resolveSubmissionContext(app, request.actor, treeId)
const actor = request.actor
const membership = actor?.kind === 'user'
? await app.prisma.treeMembership.findUnique({ where: { treeId_userId: { treeId, userId: actor.userId } }, select: { role: true } })
: null
const isAdmin = Boolean(membership && hasAdminRight(membership.role))
const media = await app.prisma.mediaItem.findFirst({
where: { id: mediaId, treeId, status: 'approved', deletedAt: null },
select: { id: true, personId: true },
})
if (!media) {
return reply.code(404).send({ error: 'media_not_found' })
}
const people = normalizeMediaPeople(payload.data.people)
const known = people.length
? await app.prisma.person.count({ where: { treeId, id: { in: people.map((person) => person.personId) }, deletedAt: null } })
: 0
if (known !== people.length) {
return reply.code(400).send({ error: 'invalid_person_reference' })
}
if (context.autoStatus === 'pending') {
const pendingCount = await app.prisma.contributionSession.count({ where: { treeId, status: 'pending' } })
if (pendingCount >= MAX_PENDING_SESSIONS_PER_TREE) {
return reply.code(429).send({ error: 'too_many_pending_contributions' })
}
}
const now = new Date()
const saved = await app.prisma.$transaction(async (tx) => {
const before = await tx.mediaPersonTag.findMany({
where: { treeId, mediaId },
select: { personId: true, x: true, y: true },
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
})
const applied = context.autoStatus === 'approved'
? await replaceMediaPeople(tx, treeId, mediaId, people, context.submittedByUserId)
: null
// L'administrateur n'a rien à relire : pas de session, comme pour ses propres souvenirs
if (!isAdmin) {
const session = await tx.contributionSession.create({
data: {
treeId,
submittedByUserId: context.submittedByUserId,
submittedByLabel: payload.data.submittedByLabel?.trim() || context.submittedByLabel,
mode: context.mode,
status: context.autoStatus,
title: 'Personnes sur un souvenir',
reviewedAt: context.autoStatus === 'approved' ? now : undefined,
reviewedBy: context.autoStatus === 'approved' ? context.submittedByUserId : undefined,
},
})
await tx.contributionChange.create({
data: {
sessionId: session.id,
entityType: 'media_people',
entityId: mediaId,
action: 'update',
beforeJson: toPrismaJson({ mediaId, ownerPersonId: media.personId, people: before }),
afterJson: toPrismaJson({ mediaId, ownerPersonId: media.personId, people }),
decision: context.autoStatus === 'approved' ? 'approved' : undefined,
},
})
}
await tx.auditLog.create({
data: {
treeId,
actorType: context.actorType,
actorId: context.actorId,
action: 'media_people_updated',
entityType: 'media_item',
entityId: mediaId,
payloadJson: { status: context.autoStatus, count: people.length },
},
})
return applied
})
return reply.send({ status: context.autoStatus, people: saved ?? people })
} catch (error) {
if (error instanceof ContributionRouteError) {
return reply.code(error.statusCode).send({ error: error.code })
}
throw error
}
})
app.post('/trees/:id/contributions/sessions', { bodyLimit: UPLOAD_BODY_LIMIT, config: { rateLimit: { max: 30, timeWindow: '1 minute' } } }, async (request, reply) => {
const params = treeIdParamSchema.safeParse(request.params)
if (!params.success) {
@@ -1014,6 +1223,17 @@ export const contributionRoutes: FastifyPluginAsync = async (app) => {
return reply.code(400).send({ error: 'media_changes_not_allowed' })
}
// Une annotation refusée à l'écriture le serait aussi à la relecture : on le dit tout de suite
for (const change of payload.data.changes) {
if (change.entityType !== 'annotation') continue
try {
validateAnnotationChange(change, params.data.id)
} catch (error) {
if (error instanceof AnnotationChangeError) return reply.code(400).send({ error: error.code })
throw error
}
}
try {
const context = await resolveSubmissionContext(app, request.actor, params.data.id)
const now = new Date()
@@ -1075,7 +1295,8 @@ export const contributionRoutes: FastifyPluginAsync = async (app) => {
entityId: effectiveEntityId,
action: change.action,
beforeJson: toPrismaJson(change.entityType === 'annotation' ? stripAnnotationUrl(before) : before),
afterJson: toPrismaJson(change.entityType === 'annotation' ? stripAnnotationUrl(change.after) : change.after),
// Annotation : gardée nettoyée (photoPath canonique), pour être relue sans ambiguïté à la relecture
afterJson: toPrismaJson(change.entityType === 'annotation' ? stripAnnotationUrl(normalizedAnnotationAfter(change, params.data.id)) : change.after),
conflictState: change.conflictState ?? 'none',
decision: context.autoStatus === 'approved' ? 'approved' : undefined,
},
@@ -1174,6 +1395,21 @@ export const contributionRoutes: FastifyPluginAsync = async (app) => {
}
})
// État d'une proposition, pour la personne qui l'a envoyée : ses fantômes « en attente » s'effacent quand elle
// est relue. Seulement le statut, à qui lit l'arbre et connaît l'identifiant (rendu à l'envoi).
app.get('/trees/:id/contributions/sessions/:sessionId/status', { config: { rateLimit: { max: 120, timeWindow: '1 minute' } } }, async (request, reply) => {
const params = sessionParamsSchema.safeParse(request.params)
if (!params.success) return reply.code(400).send({ error: 'invalid_params' })
if (!request.actor) return reply.code(401).send({ error: 'authentication_required' })
if (!(await canReadTree(app, params.data.id, request.actor))) return reply.code(403).send({ error: 'forbidden' })
const session = await app.prisma.contributionSession.findFirst({
where: { id: params.data.sessionId, treeId: params.data.id },
select: { status: true },
})
if (!session) return reply.code(404).send({ error: 'contribution_session_not_found' })
return reply.send({ status: session.status })
})
app.post('/trees/:id/contributions/sessions/:sessionId/review', async (request, reply) => {
const params = sessionParamsSchema.safeParse(request.params)
if (!params.success) {
@@ -1229,6 +1465,10 @@ export const contributionRoutes: FastifyPluginAsync = async (app) => {
let rejectedCount = 0
let conflictCount = 0
const refs: RefMap = new Map()
// Photos posées sur l'arbre par la famille : celles des annotations refusées sont libérées après toutes les
// décisions. Une annotation acceptée dans cette même relecture a déjà retiré la ligne d'attente de sa photo,
// qui n'est donc plus effacée (avant, la libération dans la boucle effaçait une photo acceptée plus loin).
const rejectedAnnotationAfters: unknown[] = []
for (const change of byApplicationOrder(session.changes)) {
let decision = explicitDecisions.get(change.id) ?? payload.data.decision
@@ -1267,15 +1507,24 @@ export const contributionRoutes: FastifyPluginAsync = async (app) => {
)
approvedCount += 1
} catch (error) {
// Lien vers une personne ajoutée mais refusée : le lien est écarté, pas la relecture
if (error instanceof ContributionRouteError && error.code === 'unresolved_reference') {
// Lien vers une personne ajoutée mais refusée, ou annotation d'avant la 1.5.1 que le serveur ne
// peut pas poser (contenu incomplet) : la proposition est écartée, pas la relecture
if (error instanceof ContributionRouteError && (error.code === 'unresolved_reference' || SKIPPABLE_ANNOTATION_ERRORS.has(error.code))) {
decision = 'rejected'
rejectedCount += 1
if (change.entityType === 'annotation') rejectedAnnotationAfters.push(change.afterJson)
} else {
throw error
}
}
} else {
if (change.entityType === 'life_event' && change.entityId) {
await tx.lifeEvent.updateMany({
where: { id: change.entityId, treeId: params.data.id },
data: { status: 'rejected', deletedAt: new Date() },
})
}
if (change.entityType === 'annotation') rejectedAnnotationAfters.push(change.afterJson)
if (change.entityType === 'media' && change.entityId) {
await tx.mediaItem.updateMany({
where: { id: change.entityId, treeId: params.data.id },
@@ -1296,6 +1545,8 @@ export const contributionRoutes: FastifyPluginAsync = async (app) => {
})
}
filesToRemove.push(...await releaseRejectedPhotos(tx, params.data.id, rejectedAnnotationAfters, session.id))
const status: ContributionStatus = approvedCount > 0 ? 'approved' : 'rejected'
const updatedSession = await tx.contributionSession.update({
+333 -75
View File
@@ -2,7 +2,13 @@ import type { FastifyPluginAsync } from 'fastify'
import { z } from 'zod'
import { UPLOAD_BODY_LIMIT } from '../config/env.js'
import { parseGedcomDate } from '../lib/gedcom-dates.js'
import { adoptedFamilies, FAMILY_EVENT_TYPES, familyExtras, PERSON_EVENT_TYPES, personExtras } from '../lib/gedcom-events.js'
import { indexGedcomRecords, mediaRefsForPerson, noteText, photoPathForPerson } from '../lib/gedcom-photos.js'
import type { GedcomRecords } from '../lib/gedcom-photos.js'
import type { MembershipRole } from '../types/auth.js'
import { nextSiblingOrder } from '../lib/sibling-order.js'
import { sexOrNull, type PersonSex } from '../lib/person-sex.js'
const treeIdParamSchema = z.object({
id: z.string().min(1),
@@ -15,12 +21,17 @@ const importBodySchema = z.object({
// Un fichier GEDCOM vient de l'extérieur : longueur des lignes, nombre de fiches et taille des champs
// plafonnés comme partout ailleurs (sinon un seul fichier bloque l'API ou remplit la base)
const MAX_LINE_LENGTH = 4096
const MAX_GEDCOM_LEVEL = 99
// Remontées d'ascendance permises sur tout l'import : au-delà, les liens ne sont plus contrôlés (fichier hors norme)
const MAX_ANCESTRY_STEPS = 5_000_000
const MAX_IMPORTED_PERSONS = 20000
const MAX_IMPORTED_FAMILIES = 20000
// Le parseur construit un objet par ligne : 10 Mo de lignes courtes montaient à 370 Mo de mémoire, et le
// redémarrage pm2 qui suivait effaçait les limites d'essais gardées en mémoire. Au-delà, le fichier est refusé.
const MAX_GEDCOM_NODES = 300_000
const MAX_IMPORTED_LINKS = 60_000
// Fichiers cités renvoyés à l'app pour en faire des souvenirs : au-delà, la réponse deviendrait trop lourde
const MAX_IMPORTED_MEDIA_REFS = 20_000
class GedcomTooLargeError extends Error {}
@@ -33,6 +44,22 @@ function clip(value: string | null, max: number): string | null {
return trimmed ? trimmed.slice(0, max) : null
}
// Lieu d'un GEDCOM : les logiciels remplissent toutes les colonnes de leur format de lieu, même vides, et y
// glissent des codes. Heredis écrit « La Roche-sur-Yon,85191,Vendée,Pays de la Loire,France, » (ville, code
// INSEE, département, région, pays, subdivision). On garde les noms, sans case vide, sans code INSEE ni
// postal, sans répétition : « La Roche-sur-Yon, Vendée, Pays de la Loire, France ».
export function cleanGedcomPlace(value: string | null | undefined): string | null {
if (!value) return null
const parts: string[] = []
for (const raw of value.split(',')) {
const part = raw.trim()
if (!part || /^(\d{4,5}|2[AB]\d{3})$/i.test(part)) continue
if (parts.length && parts[parts.length - 1].toLowerCase() === part.toLowerCase()) continue
parts.push(part)
}
return parts.length ? parts.join(', ') : null
}
// Export : un champ sur une ligne ne doit jamais en créer une autre (enregistrement injecté)
function oneLine(value: string | null | undefined): string {
return String(value ?? '').replace(/[\r\n]+/g, ' ')
@@ -49,30 +76,36 @@ type GedcomNode = {
xref?: string
tag: string
value: string
// Valeur telle qu'écrite, espaces compris : une ligne coupée en CONC se recolle sans rien perdre
// (« pendant » + « 2 CONC trente-cinq ans » ou « pendant » + « 2 CONC trente-cinq ans »)
rawValue?: string
children: GedcomNode[]
}
function parseGedcom(text: string): GedcomNode[] {
export function parseGedcom(text: string): GedcomNode[] {
const lines = text.split(/\r\n|\r|\n/)
const roots: GedcomNode[] = []
const stack: GedcomNode[] = []
let nodeCount = 0
for (const raw of lines) {
const line = raw.trim()
if (!line || line.length > MAX_LINE_LENGTH) continue
const line = raw.replace(/^[\s]+/, '')
if (!line.trim() || line.length > MAX_LINE_LENGTH) continue
nodeCount += 1
if (nodeCount > MAX_GEDCOM_NODES) throw new GedcomTooLargeError()
const match = line.match(/^(\d+)\s+(?:(@[^@]+@)\s+)?(\w+)(?:\s+(.*))?$/)
const match = line.match(/^(\d+)\s+(?:(@[^@]+@)\s+)?(\w+)(?:[ \t](.*))?$/)
if (!match) continue
const level = parseInt(match[1], 10)
// La norme s'arrête à 99 niveaux : au-delà, la lecture récursive de l'import déborderait la pile
if (level > MAX_GEDCOM_LEVEL) continue
const xref = match[2] ? match[2].slice(1, -1) : undefined
const tag = match[3]
const value = match[4]?.trim() ?? ''
const rawValue = match[4] ?? ''
const value = rawValue.trim()
const node: GedcomNode = { level, xref, tag, value, children: [] }
const node: GedcomNode = { level, xref, tag, value, rawValue, children: [] }
while (stack.length > 0 && stack[stack.length - 1].level >= level) {
stack.pop()
@@ -100,36 +133,18 @@ function findAllChildren(node: GedcomNode | undefined, tag: string): GedcomNode[
// ─── Date Conversion ────────────────────────────────────────────────────────
const MONTH_MAP: Record<string, string> = {
JAN: '01', FEB: '02', MAR: '03', APR: '04', MAY: '05', JUN: '06',
JUL: '07', AUG: '08', SEP: '09', OCT: '10', NOV: '11', DEC: '12',
}
const MONTHS_ARR = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC']
// Lecture complète dans lib/gedcom-dates.ts : vers, avant, après, entre, julien, républicain, double année
function gedcomDateToIso(value: string): string | null {
if (!value) return null
// Remove qualifiers (ABT, BEF, AFT, EST, CAL, etc.)
const cleaned = value.trim().toUpperCase().replace(/^(ABT|BEF|AFT|EST|CAL|INT|FROM|TO|BET|AND)\s+/, '')
return parseGedcomDate(value)?.iso ?? null
}
// "15 JAN 1900" → "1900-01-15"
const ddMmmYyyy = cleaned.match(/^(\d{1,2})\s+([A-Z]{3})\s+(\d{4})$/)
if (ddMmmYyyy) {
const month = MONTH_MAP[ddMmmYyyy[2]]
if (month) return `${ddMmmYyyy[3]}-${month}-${ddMmmYyyy[1].padStart(2, '0')}`
}
// "JAN 1900" → "1900-01"
const mmmYyyy = cleaned.match(/^([A-Z]{3})\s+(\d{4})$/)
if (mmmYyyy) {
const month = MONTH_MAP[mmmYyyy[1]]
if (month) return `${mmmYyyy[2]}-${month}`
}
// "1900" → "1900"
if (/^\d{4}$/.test(cleaned)) return cleaned
return null
// Notes d'une personne : écrites sous elle ou partagées (« 1 NOTE @N1@ »), lignes recollées (CONT, CONC),
// RTF de Heredis Mac nettoyé. Plusieurs notes sont séparées par une ligne vide.
export function personNotes(indi: GedcomNode, records: GedcomRecords): string | null {
const texts = findAllChildren(indi, 'NOTE').map((note) => noteText(note, records)).filter(Boolean)
return texts.length ? texts.join('\n\n') : null
}
function isoDateToDbDate(iso: string | null): Date | null {
@@ -159,10 +174,92 @@ function isoToGedcomDate(value: string | Date | null): string | null {
return null
}
// Date avec sa précision (ABT 1879, BET 1914 AND 1918…). Une date exacte reste telle quelle.
const PRECISION_PREFIX: Record<string, string> = {
about: 'ABT', estimated: 'EST', calculated: 'CAL', before: 'BEF', after: 'AFT', interpreted: 'INT',
}
// Une date tronquée au 1er janvier vient d'une année seule : on la réécrit comme une année
function exportedDate(value: Date | null): string | null {
if (!value) return null
const iso = value.toISOString().slice(0, 10)
return isoToGedcomDate(iso.endsWith('-01-01') ? iso.slice(0, 4) : iso)
}
function qualifiedGedcomDate(start: Date | null, end: Date | null, precision: string | null, text?: string | null): string | null {
// Le texte d'origine (calendrier républicain, julien, période) prime : on le rend tel qu'il a été lu
if (text && precision) return oneLine(text)
const from = exportedDate(start)
const to = exportedDate(end)
if (!from) return text ? oneLine(text) : null
if (!precision || precision === 'exact') return from
if (precision === 'between') return to ? `BET ${from} AND ${to}` : `AFT ${from}`
if (precision === 'from') return to ? `FROM ${from} TO ${to}` : `FROM ${from}`
if (precision === 'to') return `TO ${from}`
const prefix = PRECISION_PREFIX[precision]
return prefix ? `${prefix} ${from}` : from
}
type ExportedEvent = {
type: string
label: string | null
date: Date | null
dateEnd: Date | null
datePrecision: string | null
dateText: string | null
place: string | null
description: string | null
notes: string | null
sources: string | null
}
// Balise GEDCOM de chaque type : la première rencontrée dans la table de lecture (BAPM avant CHR)
const reverseTags = (table: Record<string, string>) => {
const out: Record<string, string> = {}
for (const [tag, type] of Object.entries(table)) if (!out[type]) out[type] = tag
return out
}
const PERSON_EVENT_TAGS = reverseTags(PERSON_EVENT_TYPES)
const FAMILY_EVENT_TAGS = reverseTags(FAMILY_EVENT_TYPES)
function pushMultiline(lines: string[], level: number, tag: string, text: string) {
const [first, ...rest] = text.split(/\r\n|\r|\n/)
lines.push(`${level} ${tag} ${first}`.trimEnd())
for (const line of rest) lines.push(`${level + 1} CONT ${line}`.trimEnd())
}
function pushNotesAndSources(lines: string[], level: number, event: Pick<ExportedEvent, 'notes' | 'sources'> | undefined) {
if (!event) return
if (event.notes) pushMultiline(lines, level, 'NOTE', event.notes)
for (const source of (event.sources ?? '').split('\n').map((s) => s.trim()).filter(Boolean)) {
lines.push(`${level} SOUR ${source}`)
}
}
function pushLifeEvent(lines: string[], event: ExportedEvent, tags: Record<string, string>) {
const tag = tags[event.type] ?? 'EVEN'
const value = event.description ? ` ${oneLine(event.description)}` : ''
lines.push(`1 ${tag}${value}`)
if (tag === 'EVEN' || tag === 'FACT') {
const label = event.label ?? (tags[event.type] ? null : event.type)
if (label) lines.push(`2 TYPE ${oneLine(label)}`)
}
const date = qualifiedGedcomDate(event.date, event.dateEnd, event.datePrecision, event.dateText)
if (date) lines.push(`2 DATE ${date}`)
if (event.place) lines.push(`2 PLAC ${oneLine(event.place)}`)
pushNotesAndSources(lines, 2, event)
}
// ─── Name Parsing ────────────────────────────────────────────────────────────
// Valeur de « 1 NAME ». Prénom inconnu (chaîne vide) : « /Dupont/ », sans espace de tête
export function gedcomNameValue(firstName: string, lastName: string): string {
const given = firstName ? `${oneLine(firstName)} ` : ''
return `${given}/${oneLine(lastName).replace(/\//g, '')}/`
}
// « Jean /Dupont/ » : lu par indexOf, l'ancienne expression régulière était quadratique sur les espaces
function parseGedcomName(value: string): { firstName: string; lastName: string } {
export function parseGedcomName(value: string): { firstName: string; lastName: string } {
const open = value.indexOf('/')
const close = open >= 0 ? value.indexOf('/', open + 1) : -1
if (open >= 0 && close > open) {
@@ -176,6 +273,27 @@ function parseGedcomName(value: string): { firstName: string; lastName: string }
return { firstName: value.trim(), lastName: '' }
}
// Réimport : une personne du fichier déjà présente dans l'arbre (même prénom, nom, naissance et décès)
// est reprise au lieu d'être créée une seconde fois
function personKey(firstName: string, lastName: string, birthDate: Date | null, deathDate: Date | null): string {
const norm = (value: string) => value.normalize('NFC').trim().toLowerCase()
const day = (value: Date | null) => (value ? value.toISOString().slice(0, 10) : '')
return [norm(firstName), norm(lastName), day(birthDate), day(deathDate)].join('|')
}
function pairKey(a: string, b: string | null): string {
return b ? [a, b].sort().join('|') : `${a}|`
}
// GEDCOM 5.5.1 n'admet que M, F et U (inconnu, autre). « X » n'existe qu'en 7.0 : un sexe « autre » s'écrit U,
// et l'import relit X comme avant.
export function gedcomSexValue(sex: PersonSex | null): 'M' | 'F' | 'U' | null {
if (sex === 'male') return 'M'
if (sex === 'female') return 'F'
if (sex === 'other') return 'U'
return null
}
function sanitizeXref(id: string): string {
return id.replace(/[^A-Za-z0-9_]/g, '_')
}
@@ -229,10 +347,11 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
}
}
const [persons, unions, links] = await Promise.all([
const [persons, unions, links, lifeEvents] = await Promise.all([
app.prisma.person.findMany({ where: { treeId, deletedAt: null } }),
app.prisma.union.findMany({ where: { treeId, deletedAt: null } }),
app.prisma.parentChildLink.findMany({ where: { treeId, deletedAt: null } }),
app.prisma.lifeEvent.findMany({ where: { treeId, deletedAt: null, status: 'approved' }, orderBy: [{ date: 'asc' }, { displayOrder: 'asc' }] }),
])
const personById = new Map(persons.map((p) => [p.id, p]))
@@ -269,6 +388,7 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
// Assign each child to a union FAM
const childrenByUnion = new Map<string, Set<string>>()
const adoptedInUnion = new Set<string>()
for (const link of links) {
const childId = link.childPersonId
let unionId: string | undefined
@@ -289,6 +409,7 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
}
if (!unionId) continue
if (link.parentageType === 'adoption') adoptedInUnion.add(`${unionId}|${childId}`)
const set = childrenByUnion.get(unionId) ?? new Set()
set.add(childId)
childrenByUnion.set(unionId, set)
@@ -315,6 +436,16 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
}
}
// Événements de vie : rangés par personne et par union, pour les réécrire sous leur propriétaire
const eventsByPerson = new Map<string, typeof lifeEvents>()
const eventsByUnion = new Map<string, typeof lifeEvents>()
for (const event of lifeEvents) {
const target = event.personId ? eventsByPerson : event.unionId ? eventsByUnion : null
const key = event.personId ?? event.unionId
if (!target || !key) continue
target.set(key, [...(target.get(key) ?? []), event])
}
// ── Write GEDCOM ──
const lines: string[] = []
const now = new Date()
@@ -331,25 +462,43 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
for (const person of persons) {
const xref = sanitizeXref(person.id)
lines.push(`0 @I${xref}@ INDI`)
lines.push(`1 NAME ${oneLine(person.firstName)} /${oneLine(person.lastName).replace(/\//g, '')}/`)
lines.push(`1 NAME ${gedcomNameValue(person.firstName, person.lastName)}`)
if (person.nickname) lines.push(`2 NICK ${oneLine(person.nickname)}`)
if (person.sex === 'male' || person.sex === 'M') lines.push('1 SEX M')
else if (person.sex === 'female' || person.sex === 'F') lines.push('1 SEX F')
const sexValue = gedcomSexValue(sexOrNull(person.sex))
if (sexValue) lines.push(`1 SEX ${sexValue}`)
const birthDate = isoToGedcomDate(person.birthDate)
if (birthDate || person.birthPlace) {
const personEvents = eventsByPerson.get(person.id) ?? []
const birthDetails = personEvents.find((event) => event.type === 'birth')
const deathDetails = personEvents.find((event) => event.type === 'death')
const birthDate = qualifiedGedcomDate(person.birthDate, person.birthDateEnd, person.birthDatePrecision)
if (birthDate || person.birthPlace || birthDetails) {
lines.push('1 BIRT')
if (birthDate) lines.push(`2 DATE ${birthDate}`)
if (person.birthPlace) lines.push(`2 PLAC ${oneLine(person.birthPlace)}`)
pushNotesAndSources(lines, 2, birthDetails)
}
const deathDate = isoToGedcomDate(person.deathDate)
if (deathDate) {
const deathDate = qualifiedGedcomDate(person.deathDate, person.deathDateEnd, person.deathDatePrecision)
if (deathDate || person.deathPlace || deathDetails) {
lines.push('1 DEAT')
lines.push(`2 DATE ${deathDate}`)
if (deathDate) lines.push(`2 DATE ${deathDate}`)
if (person.deathPlace) lines.push(`2 PLAC ${oneLine(person.deathPlace)}`)
pushNotesAndSources(lines, 2, deathDetails)
} else if (person.isDeceased) {
// Décédé(e) sans rien d'autre : la forme courte de la norme
lines.push('1 DEAT Y')
}
if (person.profession) lines.push(`1 OCCU ${oneLine(person.profession)}`)
// Le champ « profession » s'écrit seulement s'il n'est pas déjà un des métiers datés
const professionKnown = personEvents.some(
(event) => event.type === 'occupation' && event.description?.trim().toLowerCase() === person.profession?.trim().toLowerCase(),
)
if (person.profession && !professionKnown) lines.push(`1 OCCU ${oneLine(person.profession)}`)
for (const event of personEvents) {
if (event.type !== 'birth' && event.type !== 'death') pushLifeEvent(lines, event, PERSON_EVENT_TAGS)
}
if (person.notes) {
const noteLines = person.notes.split(/\r\n|\r|\n/)
@@ -367,6 +516,7 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
// FAMC: links this person to their parent family (as child)
for (const uid of famcByPerson.get(person.id) ?? []) {
lines.push(`1 FAMC @F${sanitizeXref(uid)}@`)
if (adoptedInUnion.has(`${uid}|${person.id}`)) lines.push('2 PEDI adopted')
}
}
@@ -378,9 +528,9 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
const p1 = union.partner1PersonId ? personById.get(union.partner1PersonId) : null
const p2 = union.partner2PersonId ? personById.get(union.partner2PersonId) : null
const p1IsFemale = p1?.sex === 'female' || p1?.sex === 'F'
const p1IsMale = p1?.sex === 'male' || p1?.sex === 'M'
const p2IsMale = p2?.sex === 'male' || p2?.sex === 'M'
const p1IsFemale = sexOrNull(p1?.sex) === 'female'
const p1IsMale = sexOrNull(p1?.sex) === 'male'
const p2IsMale = sexOrNull(p2?.sex) === 'male'
// Swap to HUSB=male if we know p1 is female and p2 is male (or unknown)
const shouldSwap = p1IsFemale && !p2IsMale === false ? false : p1IsFemale && (p2IsMale || !p1IsMale)
@@ -394,10 +544,17 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
lines.push(`1 CHIL @I${sanitizeXref(childId)}@`)
}
const marriageDate = isoToGedcomDate(union.startDate)
if (marriageDate) {
const unionEvents = eventsByUnion.get(union.id) ?? []
const marriageDetails = unionEvents.find((event) => event.type === 'marriage')
const marriageDate = qualifiedGedcomDate(union.startDate, null, union.startDatePrecision)
if (marriageDate || union.startPlace || marriageDetails) {
lines.push('1 MARR')
lines.push(`2 DATE ${marriageDate}`)
if (marriageDate) lines.push(`2 DATE ${marriageDate}`)
if (union.startPlace) lines.push(`2 PLAC ${oneLine(union.startPlace)}`)
pushNotesAndSources(lines, 2, marriageDetails)
}
for (const event of unionEvents) {
if (event.type !== 'marriage') pushLifeEvent(lines, event, FAMILY_EVENT_TAGS)
}
}
@@ -466,9 +623,57 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
}
const xrefToPersonId = new Map<string, string>()
const records = indexGedcomRecords(roots)
const objeByXref = records.obje
const photoRefs: Array<{ personId: string; file: string }> = []
// Les autres fichiers cités (actes, registres, photos de famille) : des souvenirs pour les personnes
// créées par cet import. Une personne déjà dans l'arbre n'en reçoit pas, un second import ne double rien.
const mediaRefs: Array<{ personId: string; file: string; caption: string | null; source: string | null }> = []
// Ce que l'arbre contient déjà : un second import du même fichier complète l'arbre sans rien doubler.
// Chaque personne existante ne sert qu'une fois, deux fiches identiques du fichier restent deux personnes.
const [existingPersons, existingUnions, existingLinks] = await Promise.all([
app.prisma.person.findMany({
where: { treeId, deletedAt: null },
select: { id: true, firstName: true, lastName: true, birthDate: true, deathDate: true, avatarPath: true },
}),
app.prisma.union.findMany({ where: { treeId, deletedAt: null }, select: { id: true, partner1PersonId: true, partner2PersonId: true } }),
app.prisma.parentChildLink.findMany({ where: { treeId, deletedAt: null }, select: { parentPersonId: true, childPersonId: true } }),
])
const reusablePersons = new Map<string, Array<{ id: string; hasAvatar: boolean }>>()
for (const person of existingPersons) {
const key = personKey(person.firstName, person.lastName, person.birthDate, person.deathDate)
if (!reusablePersons.has(key)) reusablePersons.set(key, [])
reusablePersons.get(key)!.push({ id: person.id, hasAvatar: Boolean(person.avatarPath) })
}
const unionByPair = new Map(existingUnions.map((u) => [pairKey(u.partner1PersonId, u.partner2PersonId), u.id]))
const linkKeys = new Set(existingLinks.map((l) => `${l.parentPersonId}:${l.childPersonId}`))
// Parents de chaque personne, pour refuser un lien qui ferait de quelqu'un son propre ancêtre
// (les validateurs de l'API relisent tout l'arbre à chaque lien : trop lent pour un import)
const parentsOf = new Map<string, string[]>()
for (const link of existingLinks) parentsOf.set(link.childPersonId, [...(parentsOf.get(link.childPersonId) ?? []), link.parentPersonId])
let ancestryBudget = MAX_ANCESTRY_STEPS
const isAncestorOf = (candidate: string, personId: string): boolean => {
const seen = new Set<string>()
const stack = [personId]
while (stack.length) {
const current = stack.pop()!
if (current === candidate) return true
if (seen.has(current) || --ancestryBudget < 0) continue
seen.add(current)
stack.push(...(parentsOf.get(current) ?? []))
}
return false
}
let personsCreated = 0
let personsReused = 0
let unionsCreated = 0
let linksCreated = 0
let eventsCreated = 0
let relationsSkipped = 0
// Adoptions déclarées sous l'enfant (« 2 PEDI adopted ») : enfant → familles où il est adopté
const adoptedByChild = new Map(indiNodes.map((indi) => [indi.xref!, adoptedFamilies(indi)]))
importInProgress = true
try {
@@ -477,45 +682,69 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
const nameNode = findChild(indi, 'NAME')
const { firstName, lastName } = nameNode
? parseGedcomName(nameNode.value)
: { firstName: '?', lastName: '' }
: { firstName: '', lastName: '' }
const sexNode = findChild(indi, 'SEX')
const sex = sexNode?.value === 'M' ? 'male' : sexNode?.value === 'F' ? 'female' : null
const sex = sexOrNull(sexNode?.value)
const birtNode = findChild(indi, 'BIRT')
const birthDateIso = gedcomDateToIso(findChild(birtNode, 'DATE')?.value ?? '')
const birthPlace = clip(findChild(birtNode, 'PLAC')?.value ?? null, 200)
const birthPlace = clip(cleanGedcomPlace(findChild(birtNode, 'PLAC')?.value), 200)
const deatNode = findChild(indi, 'DEAT')
const deathDateIso = gedcomDateToIso(findChild(deatNode, 'DATE')?.value ?? '')
const profession = clip(findChild(indi, 'OCCU')?.value ?? null, 200)
const noteNode = findChild(indi, 'NOTE')
let notes: string | null = null
if (noteNode?.value) {
const contLines = findAllChildren(noteNode, 'CONT').map((n) => n.value)
notes = clip([noteNode.value, ...contLines].join('\n'), 2000)
// Prénom absent du fichier : chaîne vide, comme un prénom inconnu dans l'arbre
const personFirstName = (firstName || '').slice(0, 120)
const personLastName = (lastName || '').slice(0, 120)
const birthDate = isoDateToDbDate(birthDateIso)
const deathDate = isoDateToDbDate(deathDateIso)
const photoPath = photoPathForPerson(indi, objeByXref)
const existing = reusablePersons.get(personKey(personFirstName, personLastName, birthDate, deathDate))?.shift()
if (existing) {
xrefToPersonId.set(indi.xref!, existing.id)
personsReused++
// Une personne qui a déjà son portrait le garde
if (photoPath && !existing.hasAvatar) photoRefs.push({ personId: existing.id, file: photoPath })
continue
}
const notes = clip(personNotes(indi, records), 2000)
const { events, ...extras } = personExtras(indi, records)
const person = await tx.person.create({
data: {
treeId,
firstName: (firstName || '?').slice(0, 120),
lastName: (lastName || '').slice(0, 120),
firstName: personFirstName,
lastName: personLastName,
sex,
birthDate: isoDateToDbDate(birthDateIso),
deathDate: isoDateToDbDate(deathDateIso),
birthDate,
deathDate,
// « 1 DEAT Y » ou un décès sans date : décédé(e), date inconnue
isDeceased: Boolean(deatNode) && !deathDate,
birthPlace,
profession,
notes,
...extras,
createdBy: actor.userId,
updatedBy: actor.userId,
},
})
if (events.length) {
await tx.lifeEvent.createMany({ data: events.map((event, index) => ({ ...event, treeId, personId: person.id, displayOrder: index + 1 })) })
eventsCreated += events.length
}
xrefToPersonId.set(indi.xref!, person.id)
personsCreated++
if (photoPath) photoRefs.push({ personId: person.id, file: photoPath })
const personMedia = mediaRefs.length < MAX_IMPORTED_MEDIA_REFS ? mediaRefsForPerson(indi, records, photoPath) : []
for (const ref of personMedia) {
if (mediaRefs.length >= MAX_IMPORTED_MEDIA_REFS) break
mediaRefs.push({ personId: person.id, ...ref })
}
}
for (const fam of famNodes) {
@@ -523,23 +752,40 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
const wifeXref = findChild(fam, 'WIFE')?.value?.replace(/@/g, '').trim()
const partner1Id = husbXref ? xrefToPersonId.get(husbXref) : undefined
const partner2Id = wifeXref ? xrefToPersonId.get(wifeXref) : undefined
let partner2Id = wifeXref ? xrefToPersonId.get(wifeXref) : undefined
// Une même personne des deux côtés d'une union : un seul partenaire
if (partner2Id && partner2Id === partner1Id) {
partner2Id = undefined
relationsSkipped++
}
if (!partner1Id && !partner2Id) continue
const marrNode = findChild(fam, 'MARR')
const marriageDateIso = gedcomDateToIso(findChild(marrNode, 'DATE')?.value ?? '')
const union = await tx.union.create({
data: {
treeId,
partner1PersonId: partner1Id ?? partner2Id!,
partner2PersonId: partner1Id ? (partner2Id ?? null) : null,
startDate: isoDateToDbDate(marriageDateIso),
},
})
unionsCreated++
const firstPartner = partner1Id ?? partner2Id!
const secondPartner = partner1Id ? (partner2Id ?? null) : null
let unionId = unionByPair.get(pairKey(firstPartner, secondPartner))
if (!unionId) {
const { events: familyEvents, ...unionExtras } = familyExtras(fam, records)
const union = await tx.union.create({
data: {
treeId,
partner1PersonId: firstPartner,
partner2PersonId: secondPartner,
startDate: isoDateToDbDate(marriageDateIso),
...unionExtras,
},
})
unionId = union.id
unionByPair.set(pairKey(firstPartner, secondPartner), unionId)
unionsCreated++
if (familyEvents.length) {
await tx.lifeEvent.createMany({ data: familyEvents.map((event, index) => ({ ...event, treeId, unionId: union.id, displayOrder: index + 1 })) })
eventsCreated += familyEvents.length
}
}
const chilNodes = findAllChildren(fam, 'CHIL')
const seenChildren = new Set<string>()
@@ -555,14 +801,24 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
const parentIds = [partner1Id, partner2Id].filter((id): id is string => Boolean(id))
for (const parentId of parentIds) {
const linkKey = `${parentId}:${childId}`
if (linkKeys.has(linkKey)) continue
if (parentId === childId || isAncestorOf(childId, parentId)) {
relationsSkipped++
continue
}
await tx.parentChildLink.create({
data: {
treeId,
parentPersonId: parentId,
childPersonId: childId,
viaUnionId: union.id,
viaUnionId: unionId,
...(adoptedByChild.get(chilXref)?.has(fam.xref!) ? { parentageType: 'adoption' } : {}),
displayOrder: await nextSiblingOrder(tx, treeId, parentId),
},
})
linkKeys.add(linkKey)
parentsOf.set(childId, [...(parentsOf.get(childId) ?? []), parentId])
linksCreated++
}
}
@@ -576,7 +832,7 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
action: 'gedcom_imported',
entityType: 'tree',
entityId: treeId,
payloadJson: { personsCreated, unionsCreated, linksCreated },
payloadJson: { personsCreated, personsReused, unionsCreated, linksCreated, eventsCreated, relationsSkipped, photoRefs: photoRefs.length, mediaRefs: mediaRefs.length },
},
})
}, { timeout: 60000 })
@@ -584,6 +840,8 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
importInProgress = false
}
return reply.send({ personsCreated, unionsCreated, linksCreated })
// photoRefs : chemin de la photo que le logiciel d'origine associait à chaque personne ; mediaRefs : les
// autres fichiers cités, avec leur titre et leur source (rien n'est gardé en base)
return reply.send({ personsCreated, personsReused, unionsCreated, linksCreated, eventsCreated, relationsSkipped, photoRefs, mediaRefs })
})
}
+168 -62
View File
@@ -1,4 +1,4 @@
import type { FastifyPluginAsync } from 'fastify'
import type { FastifyInstance, FastifyPluginAsync, FastifyRequest } from 'fastify'
import { z } from 'zod'
import { signMediaToken } from '../lib/tree-access.js'
@@ -9,47 +9,65 @@ const treeIdParamSchema = z.object({
id: z.string().min(1),
})
export const graphRoutes: FastifyPluginAsync = async (app) => {
app.get('/trees/:id/graph', async (request, reply) => {
const params = treeIdParamSchema.safeParse(request.params)
if (!params.success) {
return reply.code(400).send({ error: 'invalid_tree_id' })
type ReadAccess =
| { ok: true; rootPersonId: string | null; visitKind: VisitAccessKind }
| { ok: false; status: number; error: string }
// Qui peut lire un arbre : un accès par mot de passe de cet arbre, un membre, ou un compte avec qui il est
// partagé. Utilisé par le graphe et par la frise de vie d'une personne.
async function resolveReadAccess(app: FastifyInstance, actor: FastifyRequest['actor'], treeId: string): Promise<ReadAccess> {
let rootPersonId: string | null = null
let visitKind: VisitAccessKind = 'share'
if (!actor) {
return { ok: false, status: 401, error: 'authentication_required' }
}
if (actor.kind === 'tree_access') {
if (actor.treeId !== treeId) {
return { ok: false, status: 403, error: 'forbidden' }
}
const treeId = params.data.id
const actor = request.actor
let rootPersonId: string | null = null
let visitKind: VisitAccessKind = 'share'
const tree = await app.prisma.tree.findFirst({
where: {
id: treeId,
deletedAt: null,
},
select: {
id: true,
rootPersonId: true,
},
})
if (!actor) {
return reply.code(401).send({ error: 'authentication_required' })
if (!tree) {
return { ok: false, status: 404, error: 'tree_not_found' }
}
if (actor.kind === 'tree_access') {
if (actor.treeId !== treeId) {
return reply.code(403).send({ error: 'forbidden' })
}
rootPersonId = tree.rootPersonId
}
const tree = await app.prisma.tree.findFirst({
where: {
id: treeId,
deletedAt: null,
if (actor.kind === 'user') {
const membership = await app.prisma.treeMembership.findUnique({
where: {
treeId_userId: {
treeId,
userId: actor.userId,
},
select: {
id: true,
rootPersonId: true,
},
include: {
tree: {
select: {
deletedAt: true,
rootPersonId: true,
},
},
})
},
})
if (!tree) {
return reply.code(404).send({ error: 'tree_not_found' })
}
rootPersonId = tree.rootPersonId
}
if (actor.kind === 'user') {
const membership = await app.prisma.treeMembership.findUnique({
if (membership && !membership.tree.deletedAt) {
rootPersonId = membership.tree.rootPersonId
visitKind = 'member'
} else {
const shared = await app.prisma.userTreeAccess.findUnique({
where: {
treeId_userId: {
treeId,
@@ -66,40 +84,42 @@ export const graphRoutes: FastifyPluginAsync = async (app) => {
},
})
if (membership && !membership.tree.deletedAt) {
rootPersonId = membership.tree.rootPersonId
visitKind = 'member'
} else {
const shared = await app.prisma.userTreeAccess.findUnique({
where: {
treeId_userId: {
treeId,
userId: actor.userId,
},
},
include: {
tree: {
select: {
deletedAt: true,
rootPersonId: true,
},
},
},
})
if (!shared || shared.tree.deletedAt) {
return reply.code(403).send({ error: 'forbidden' })
}
rootPersonId = shared.tree.rootPersonId
visitKind = 'shared_account'
if (!shared || shared.tree.deletedAt) {
return { ok: false, status: 403, error: 'forbidden' }
}
rootPersonId = shared.tree.rootPersonId
visitKind = 'shared_account'
}
}
return { ok: true, rootPersonId, visitKind }
}
export const graphRoutes: FastifyPluginAsync = async (app) => {
app.get('/trees/:id/graph', async (request, reply) => {
const params = treeIdParamSchema.safeParse(request.params)
if (!params.success) {
return reply.code(400).send({ error: 'invalid_tree_id' })
}
const treeId = params.data.id
const actor = request.actor
let rootPersonId: string | null = null
let visitKind: VisitAccessKind = 'share'
const access = await resolveReadAccess(app, actor, treeId)
if (!access.ok || !actor) {
if (access.ok) return reply.code(401).send({ error: 'authentication_required' })
return reply.code(access.status).send({ error: access.error })
}
rootPersonId = access.rootPersonId
visitKind = access.visitKind
// Journal des visites (onglet Visites des administrateurs), sans attendre
void recordTreeVisit(app, request, treeId, visitKind, actor.kind === 'user' ? actor.userId : null)
const [persons, unions, links, medias, annotations] = await Promise.all([
const [persons, unions, links, medias, annotations, mediaPeople] = await Promise.all([
app.prisma.person.findMany({
where: {
treeId,
@@ -136,9 +156,14 @@ export const graphRoutes: FastifyPluginAsync = async (app) => {
},
orderBy: [{ zIndex: 'asc' }, { createdAt: 'asc' }, { id: 'asc' }],
}),
app.prisma.mediaPersonTag.findMany({
where: { treeId },
select: { mediaId: true, personId: true, x: true, y: true },
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
}),
])
const graph = buildGraphPayload(treeId, persons, unions, links, medias, annotations)
const graph = buildGraphPayload(treeId, persons, unions, links, medias, annotations, mediaPeople)
return reply.send({
treeId,
@@ -148,4 +173,85 @@ export const graphRoutes: FastifyPluginAsync = async (app) => {
mediaToken: signMediaToken(app, treeId, actor),
})
})
// Frise de vie d'une personne : chargée à l'ouverture de la fiche plutôt que dans le graphe, qui resterait
// lourd sur un grand arbre. Mêmes droits de lecture que le graphe.
app.get('/trees/:id/persons/:personId/life', async (request, reply) => {
const params = z.object({ id: z.string().min(1), personId: z.string().min(1) }).safeParse(request.params)
if (!params.success) {
return reply.code(400).send({ error: 'invalid_params' })
}
const { id: treeId, personId } = params.data
const access = await resolveReadAccess(app, request.actor, treeId)
if (!access.ok) {
return reply.code(access.status).send({ error: access.error })
}
const person = await app.prisma.person.findFirst({
where: { id: personId, treeId, deletedAt: null },
select: {
id: true, birthDatePrecision: true, birthDateEnd: true, deathDatePrecision: true, deathDateEnd: true,
deathPlace: true, nickname: true,
},
})
if (!person) {
return reply.code(404).send({ error: 'person_not_found' })
}
const unions = await app.prisma.union.findMany({
where: { treeId, deletedAt: null, OR: [{ partner1PersonId: personId }, { partner2PersonId: personId }] },
select: { id: true, partner1PersonId: true, partner2PersonId: true, startDate: true, startPlace: true, startDatePrecision: true, endDate: true },
orderBy: [{ displayOrder: 'asc' }, { createdAt: 'asc' }],
})
const unionIds = unions.map((union) => union.id)
const events = await app.prisma.lifeEvent.findMany({
where: { treeId, deletedAt: null, status: 'approved', OR: [{ personId }, ...(unionIds.length ? [{ unionId: { in: unionIds } }] : [])] },
select: {
id: true, personId: true, unionId: true, type: true, label: true, date: true, dateEnd: true, datePrecision: true,
dateText: true, place: true, description: true, notes: true, sources: true, mediaId: true, displayOrder: true,
},
orderBy: [{ date: 'asc' }, { displayOrder: 'asc' }, { createdAt: 'asc' }],
})
// Souvenirs rattachés à chaque moment (photo, vidéo, acte…), visibles seulement une fois acceptés
const attached = events.length
? await app.prisma.mediaItem.findMany({
where: { treeId, lifeEventId: { in: events.map((event) => event.id) }, status: 'approved', deletedAt: null },
select: { id: true, lifeEventId: true },
orderBy: [{ displayOrder: 'asc' }, { createdAt: 'asc' }],
})
: []
const mediaIdsByEvent = new Map<string, string[]>()
for (const media of attached) {
if (!media.lifeEventId) continue
mediaIdsByEvent.set(media.lifeEventId, [...(mediaIdsByEvent.get(media.lifeEventId) ?? []), media.id])
}
const day = (value: Date | null) => (value ? value.toISOString().slice(0, 10) : null)
return reply.send({
person: {
birthDatePrecision: person.birthDatePrecision,
birthDateEnd: day(person.birthDateEnd),
deathDatePrecision: person.deathDatePrecision,
deathDateEnd: day(person.deathDateEnd),
deathPlace: person.deathPlace,
nickname: person.nickname,
},
unions: unions.map((union) => ({
id: union.id,
partnerId: union.partner1PersonId === personId ? union.partner2PersonId : union.partner1PersonId,
startDate: day(union.startDate),
startPlace: union.startPlace,
startDatePrecision: union.startDatePrecision,
endDate: day(union.endDate),
})),
// L'ancienne colonne mediaId est reprise par media_items.lifeEventId (migration du 17/10)
events: events.map(({ mediaId: _legacy, ...event }) => ({
...event,
date: day(event.date),
dateEnd: day(event.dateEnd),
mediaIds: mediaIdsByEvent.get(event.id) ?? [],
})),
})
})
}
+660
View File
@@ -0,0 +1,660 @@
import { Prisma } from '../generated/prisma/client.js'
import type { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'
import path from 'node:path'
import { randomUUID } from 'node:crypto'
import { mkdir, writeFile } from 'node:fs/promises'
import { z } from 'zod'
import {
getImmichInstanceByApiUrl,
getImmichInstances,
getTreeImmichClient,
isImmichPersonId,
normalizeImmichApiUrl,
ImmichAuthError,
ImmichNotFoundError,
ImmichUnavailableError,
type ImmichPersonSummary,
} from '../lib/immich.js'
import { computeBlurhash } from '../lib/blurhash.js'
import { decryptImmichApiKey, encryptImmichApiKey } from '../lib/immich-secret.js'
import { IMAGE_MIME_TYPES, InvalidMediaError, isImmichAvatarPath, removeStoredFile, sniffMimeType } from '../lib/media-files.js'
import { exceedsStorageQuota, getStorageRoot, prepareUploadedFile } from './media.js'
// Reconnaissance des visages : associer une personne de l'arbre à une personne Immich, dont la
// vignette devient son portrait. Chaque arbre porte sa propre connexion Immich : une adresse
// choisie parmi celles autorisées par l'opérateur du serveur (IMMICH_ALLOWED_API_URLS), et une
// clé chiffrée en base. Toutes les routes sont rattachées à un arbre, réservées à ses
// administrateurs, et ne parlent jamais à l'instance d'un autre arbre.
const treeParamSchema = z.object({ id: z.string().min(1) })
const treePersonParamsSchema = z.object({ id: z.string().min(1), personId: z.string().min(1) })
const assignSchema = z.object({
personId: z.string().min(1),
immichPersonId: z.string().refine(isImmichPersonId, 'invalid_immich_person_id'),
// Remplacer un portrait déjà en place demande un accord explicite, quelle que soit son
// origine (posé à la main ou venu d'une association Immich précédente).
replaceAvatar: z.boolean().optional(),
})
const unassignSchema = z.object({
personId: z.string().min(1),
})
const thumbnailParamSchema = z.object({
id: z.string().min(1),
immichPersonId: z.string().refine(isImmichPersonId, 'invalid_immich_person_id'),
})
// Connexion Immich d'un arbre : instance choisie parmi celles autorisées par l'opérateur du
// serveur (IMMICH_ALLOWED_API_URLS), désignée par son identifiant opaque. L'adresse ne circule
// jamais vers le front. La clé se joint à la mise à jour ou, si le champ reste vide, celle en
// place est conservée — jamais relue par le front.
const configPayloadSchema = z.object({
instanceId: z.string().regex(/^[0-9a-f]{16}$/, 'invalid_instance_id'),
apiKey: z.string().min(1).max(512).optional(),
})
const MAX_THUMBNAIL_BYTES = 5 * 1024 * 1024
function replyImmichError(request: FastifyRequest, reply: FastifyReply, error: unknown): FastifyReply {
if (error instanceof ImmichNotFoundError) {
return reply.code(404).send({ error: 'immich_person_not_found' })
}
if (error instanceof ImmichAuthError) {
return reply.code(502).send({ error: 'immich_auth_failed' })
}
if (error instanceof ImmichUnavailableError) {
return reply.code(503).send({ error: 'immich_unreachable' })
}
request.log.error({ err: error }, 'Erreur inattendue pendant un appel à Immich')
return reply.code(500).send({ error: 'immich_api_error' })
}
// Métadonnées sûres d'une connexion Immich d'arbre : jamais la clé, jamais son chiffré, jamais
// l'adresse (l'instance se nomme par son libellé). Une adresse sortie de la liste de l'opérateur
// vaut « non configuré » : la connexion est inerte. Une clé qui ne se déchiffre plus (JWT_SECRET
// changé) vaut aussi « non configuré », avec keyNeedsReentry : l'administrateur doit la ressaisir.
function immichConfigSummary(config: { apiUrl: string; apiKeyEnc: string; updatedAt: Date } | null) {
const instance = config ? getImmichInstanceByApiUrl(config.apiUrl) : undefined
if (!config || !instance) {
return { configured: false, keyNeedsReentry: false, instanceId: null, label: null, updatedAt: null }
}
const keyReadable = decryptImmichApiKey(config.apiKeyEnc) !== null
return {
configured: keyReadable,
keyNeedsReentry: !keyReadable,
instanceId: instance.id,
label: instance.label,
updatedAt: config.updatedAt,
}
}
// Portraits venus d'Immich : même nommage que les envois manuels (« avatar-<uuid> »), avec le
// préfixe « immich- » qui dit au désassignement que le fichier nous appartient. Un portrait
// envoyé à la main n'est jamais touché par le désassignement.
function buildAvatarRelativePath(treeId: string, personId: string, extension: string): string {
return path.posix.join(treeId, personId, `immich-${randomUUID()}${extension}`)
}
async function membershipRole(app: FastifyInstance, userId: string, treeId: string) {
const membership = await app.prisma.treeMembership.findUnique({
where: { treeId_userId: { treeId, userId } },
select: { role: true, tree: { select: { deletedAt: true } } },
})
return membership && !membership.tree.deletedAt ? membership.role : null
}
// Immich passe par la clé du propriétaire, qui ouvre toute sa photothèque, pas seulement les photos de l'arbre :
// réservé au propriétaire, jamais aux co-gestionnaires (issue #145)
async function requireImmichOwner(app: FastifyInstance, userId: string, treeId: string): Promise<boolean> {
return (await membershipRole(app, userId, treeId)) === 'owner'
}
export const immichRoutes: FastifyPluginAsync = async (app) => {
// Lecture réservée aux administrateurs : la clé donne accès à la photothèque de l'arbre.
// Les fichiers sélectionnés passent ensuite par l'envoi de souvenirs habituel (quota,
// validation et rattachement au moment), sans exposer d'URL ni de clé Immich au navigateur.
app.get('/trees/:id/immich/assets', async (request, reply) => {
if (request.actor?.kind !== 'user') return reply.code(401).send({ error: 'authentication_required' })
const params = treeParamSchema.safeParse(request.params)
const query = z.object({
page: z.coerce.number().int().min(1).max(100000).default(1),
type: z.enum(['IMAGE', 'VIDEO']).default('IMAGE'),
q: z.string().trim().max(500).optional(),
personId: z.string().refine(isImmichPersonId).optional(),
}).safeParse(request.query)
if (!params.success || !query.success) return reply.code(400).send({ error: 'invalid_params' })
if (!(await requireImmichOwner(app, request.actor.userId, params.data.id))) return reply.code(403).send({ error: 'forbidden' })
const client = await getTreeImmichClient(app.prisma, params.data.id)
if (!client) return reply.code(503).send({ error: 'immich_not_configured' })
try {
reply.header('Cache-Control', 'no-store')
return reply.send(await client.listAssets(query.data.page, query.data.type, { query: query.data.q, personId: query.data.personId }))
} catch (error) { return replyImmichError(request, reply, error) }
})
app.get('/trees/:id/immich/assets/:assetId/:variant', async (request, reply) => {
if (request.actor?.kind !== 'user') return reply.code(401).send({ error: 'authentication_required' })
const params = z.object({ id: z.string().min(1), assetId: z.string().refine(isImmichPersonId), variant: z.enum(['thumbnail', 'preview', 'original']) }).safeParse(request.params)
if (!params.success) return reply.code(400).send({ error: 'invalid_params' })
if (!(await requireImmichOwner(app, request.actor.userId, params.data.id))) return reply.code(403).send({ error: 'forbidden' })
const client = await getTreeImmichClient(app.prisma, params.data.id)
if (!client) return reply.code(503).send({ error: 'immich_not_configured' })
try {
const thumbnail = params.data.variant !== 'original'
const bytes = await client.getAssetBytes(params.data.assetId, thumbnail, (thumbnail ? 5 : 20) * 1024 * 1024, params.data.variant === 'preview')
if (!bytes) return reply.code(413).send({ error: 'immich_asset_too_large' })
const mime = sniffMimeType(bytes, 'video')
if (!mime || !(mime.startsWith('image/') || (!thumbnail && mime.startsWith('video/')))) return reply.code(400).send({ error: 'invalid_immich_media' })
if (mime.startsWith('image/') && bytes.length > MAX_THUMBNAIL_BYTES) return reply.code(413).send({ error: 'immich_asset_too_large' })
return reply.header('Content-Type', mime).header('X-Content-Type-Options', 'nosniff').header('Cache-Control', 'no-store').send(bytes)
} catch (error) {
if (error instanceof ImmichNotFoundError) return reply.code(404).send({ error: 'immich_asset_not_found' })
return replyImmichError(request, reply, error)
}
})
// ── Connexion Immich de l'arbre : lecture, écriture, suppression. Réservées aux
// administrateurs de l'arbre ; la clé d'API ne quitte jamais le serveur.
app.get('/trees/:id/immich/config', async (request, reply) => {
if (!request.actor || request.actor.kind !== 'user') {
return reply.code(401).send({ error: 'authentication_required' })
}
const params = treeParamSchema.safeParse(request.params)
if (!params.success) {
return reply.code(400).send({ error: 'invalid_params' })
}
const role = await membershipRole(app, request.actor.userId, params.data.id)
if (role !== 'owner' && role !== 'admin') {
return reply.code(403).send({ error: 'forbidden' })
}
// Co-gestionnaire : la clé Immich est celle du propriétaire et ouvre toute sa photothèque. Il ne voit ni ne
// règle rien ; pour l'interface, Immich n'est pas configuré
if (role !== 'owner') {
return reply.send({ config: null, instances: [], ownerOnly: true })
}
const config = await app.prisma.treeImmichConfig.findUnique({
where: { treeId: params.data.id },
select: { apiUrl: true, apiKeyEnc: true, updatedAt: true },
})
return reply.send({
config: immichConfigSummary(config),
// Les administrateurs choisissent parmi ces instances : le front en fait un menu, pas un champ libre.
instances: getImmichInstances().map(({ id, label }) => ({ id, label })),
})
})
app.put('/trees/:id/immich/config', async (request, reply) => {
if (!request.actor || request.actor.kind !== 'user') {
return reply.code(401).send({ error: 'authentication_required' })
}
const params = treeParamSchema.safeParse(request.params)
const payload = configPayloadSchema.safeParse(request.body)
if (!params.success) {
return reply.code(400).send({ error: 'invalid_params' })
}
if (!payload.success) {
return reply.code(400).send({ error: 'invalid_payload', details: payload.error.flatten() })
}
if (!(await requireImmichOwner(app, request.actor.userId, params.data.id))) {
return reply.code(403).send({ error: 'forbidden' })
}
// L'instance doit faire partie de la liste de l'opérateur : le serveur ne devient jamais
// un proxy vers un service interne choisi librement (SSRF). L'adresse vient de la liste,
// jamais de la requête.
const instance = getImmichInstances().find((candidate) => candidate.id === payload.data.instanceId)
if (!instance) {
return reply.code(400).send({ error: 'instance_not_allowed' })
}
const treeId = params.data.id
const existing = await app.prisma.treeImmichConfig.findUnique({
where: { treeId },
select: { apiUrl: true, apiKeyEnc: true },
})
// Sans nouvelle clé, la clé en place est conservée — mais il en faut une pour une
// première configuration, et quand celle en place ne se déchiffre plus : on ne devine
// jamais d'identifiants ailleurs.
if (!payload.data.apiKey && (!existing || !decryptImmichApiKey(existing.apiKeyEnc))) {
return reply.code(400).send({ error: 'api_key_required' })
}
// Changer d'instance n'emmène jamais la clé de l'ancienne : elle ne vaut rien pour la
// nouvelle, et on ne l'envoie pas à un serveur qui n'est pas le sien.
if (existing && !payload.data.apiKey
&& normalizeImmichApiUrl(existing.apiUrl) !== instance.apiUrl) {
return reply.code(400).send({ error: 'api_key_required' })
}
const apiKeyEnc = payload.data.apiKey
? encryptImmichApiKey(payload.data.apiKey)
: existing!.apiKeyEnc
const config = await app.prisma.treeImmichConfig.upsert({
where: { treeId },
create: { treeId, apiUrl: instance.apiUrl, apiKeyEnc },
update: { apiUrl: instance.apiUrl, apiKeyEnc },
select: { apiUrl: true, apiKeyEnc: true, updatedAt: true },
})
// Journal d'audit : jamais la clé, seulement le fait qu'elle a changé.
await app.prisma.auditLog.create({
data: {
treeId,
actorType: 'user',
actorId: request.actor.userId,
action: 'immich_config_updated',
entityType: 'tree_immich_config',
entityId: treeId,
payloadJson: { instanceId: instance.id, apiKeyChanged: Boolean(payload.data.apiKey) },
},
})
return reply.send({ config: immichConfigSummary(config) })
})
app.delete('/trees/:id/immich/config', async (request, reply) => {
if (!request.actor || request.actor.kind !== 'user') {
return reply.code(401).send({ error: 'authentication_required' })
}
const params = treeParamSchema.safeParse(request.params)
if (!params.success) {
return reply.code(400).send({ error: 'invalid_params' })
}
if (!(await requireImmichOwner(app, request.actor.userId, params.data.id))) {
return reply.code(403).send({ error: 'forbidden' })
}
const treeId = params.data.id
const removed = await app.prisma.treeImmichConfig.deleteMany({ where: { treeId } })
if (removed.count > 0) {
await app.prisma.auditLog.create({
data: {
treeId,
actorType: 'user',
actorId: request.actor.userId,
action: 'immich_config_removed',
entityType: 'tree_immich_config',
entityId: treeId,
payloadJson: {},
},
})
}
return reply.send({ config: { configured: false, keyNeedsReentry: false, instanceId: null, label: null, updatedAt: null } })
})
app.get('/trees/:id/immich/status', async (request, reply) => {
if (!request.actor || request.actor.kind !== 'user') {
return reply.code(401).send({ error: 'authentication_required' })
}
const params = treeParamSchema.safeParse(request.params)
if (!params.success) {
return reply.code(400).send({ error: 'invalid_params' })
}
if (!(await requireImmichOwner(app, request.actor.userId, params.data.id))) {
return reply.code(403).send({ error: 'forbidden' })
}
const client = await getTreeImmichClient(app.prisma, params.data.id)
if (!client) {
return reply.send({ available: false, reason: 'not_configured' })
}
const available = await client.ping()
return reply.send({ available, reason: available ? null : 'unreachable' })
})
app.get('/trees/:id/immich/people', async (request, reply) => {
if (!request.actor || request.actor.kind !== 'user') {
return reply.code(401).send({ error: 'authentication_required' })
}
const params = treeParamSchema.safeParse(request.params)
if (!params.success) {
return reply.code(400).send({ error: 'invalid_params' })
}
if (!(await requireImmichOwner(app, request.actor.userId, params.data.id))) {
return reply.code(403).send({ error: 'forbidden' })
}
const client = await getTreeImmichClient(app.prisma, params.data.id)
if (!client) {
return reply.code(503).send({ error: 'immich_not_configured' })
}
try {
const people = await client.listPeople()
return reply.send({ people })
} catch (error) {
return replyImmichError(request, reply, error)
}
})
// Vignette d'une personne Immich, montrée dans le sélecteur. Passée par l'API : l'adresse
// d'Immich et sa clé ne quittent jamais le serveur.
app.get('/trees/:id/immich/thumbnail/:immichPersonId', async (request, reply) => {
if (!request.actor || request.actor.kind !== 'user') {
return reply.code(401).send({ error: 'authentication_required' })
}
const params = thumbnailParamSchema.safeParse(request.params)
if (!params.success) {
return reply.code(400).send({ error: 'invalid_immich_person_id' })
}
if (!(await requireImmichOwner(app, request.actor.userId, params.data.id))) {
return reply.code(403).send({ error: 'forbidden' })
}
const client = await getTreeImmichClient(app.prisma, params.data.id)
if (!client) {
return reply.code(503).send({ error: 'immich_not_configured' })
}
let thumbnail: Buffer | null
try {
thumbnail = await client.getPersonThumbnail(params.data.immichPersonId, MAX_THUMBNAIL_BYTES)
} catch (error) {
return replyImmichError(request, reply, error)
}
// Le type se lit dans les octets, pas dans ce qu'Immich déclare : la vignette finit dans une
// balise <img>. Vide, trop grande ou pas une image : rien à montrer.
const mimeType = thumbnail?.length ? sniffMimeType(thumbnail, 'photo') : null
if (!thumbnail || !mimeType || !(IMAGE_MIME_TYPES as readonly string[]).includes(mimeType)) {
request.log.warn('Vignette Immich vide, trop grande ou qui n’est pas une image')
return reply.code(404).send({ error: 'thumbnail_not_found' })
}
reply.header('Content-Type', mimeType)
reply.header('X-Content-Type-Options', 'nosniff')
reply.header('Cache-Control', 'private, max-age=3600')
return reply.send(thumbnail)
})
// Personne Immich déjà associée à une fiche, pour la marquer dans le sélecteur. Réservé aux
// administrateurs : l'identifiant n'entre jamais dans le graphe public.
app.get('/trees/:id/immich/links/:personId', async (request, reply) => {
if (!request.actor || request.actor.kind !== 'user') {
return reply.code(401).send({ error: 'authentication_required' })
}
const params = treePersonParamsSchema.safeParse(request.params)
if (!params.success) {
return reply.code(400).send({ error: 'invalid_params' })
}
if (!(await requireImmichOwner(app, request.actor.userId, params.data.id))) {
return reply.code(403).send({ error: 'forbidden' })
}
const person = await app.prisma.person.findFirst({
where: { id: params.data.personId, treeId: params.data.id, deletedAt: null },
select: { immichPersonId: true },
})
if (!person) {
return reply.code(404).send({ error: 'person_not_found' })
}
return reply.send({ immichPersonId: person.immichPersonId })
})
app.post('/trees/:id/immich/assign', async (request, reply) => {
if (!request.actor || request.actor.kind !== 'user') {
return reply.code(401).send({ error: 'authentication_required' })
}
const params = treeParamSchema.safeParse(request.params)
const payload = assignSchema.safeParse(request.body)
if (!params.success || !payload.success) {
return reply.code(400).send({ error: payload.success ? 'invalid_params' : 'invalid_immich_person_id' })
}
if (!(await requireImmichOwner(app, request.actor.userId, params.data.id))) {
return reply.code(403).send({ error: 'forbidden' })
}
const client = await getTreeImmichClient(app.prisma, params.data.id)
if (!client) {
return reply.code(503).send({ error: 'immich_not_configured' })
}
const { personId, immichPersonId } = payload.data
const treeId = params.data.id
const person = await app.prisma.person.findFirst({
where: { id: personId, treeId, deletedAt: null },
select: { id: true, avatarPath: true },
})
if (!person) {
return reply.code(404).send({ error: 'person_not_found' })
}
// La personne Immich doit exister : le 404 de la vignette ne le prouve pas (personne
// absente ou simple vignette manquante), on la vérifie donc explicitement. Un refus
// d'authentification ou une erreur serveur ne sont jamais pris pour une absence.
let immichPerson: ImmichPersonSummary
try {
immichPerson = await client.getPerson(immichPersonId)
} catch (error) {
return replyImmichError(request, reply, error)
}
// Vignette de la personne Immich : absente -> on associe sans toucher au portrait existant.
// Demandée seulement si Immich en annonce une : sans vignette, l'appel est inutile.
let thumbnail: Buffer | null = null
if (immichPerson.hasThumbnail) {
try {
thumbnail = await client.getPersonThumbnail(immichPersonId, MAX_THUMBNAIL_BYTES)
} catch (error) {
if (!(error instanceof ImmichNotFoundError)) {
return replyImmichError(request, reply, error)
}
// Vignette absente : l'association se fait sans portrait.
}
}
if (!thumbnail) {
await app.prisma.$transaction([
app.prisma.person.update({
where: { id: personId },
data: { immichPersonId, updatedBy: request.actor.userId },
}),
app.prisma.auditLog.create({
data: {
treeId,
actorType: 'user',
actorId: request.actor.userId,
action: 'immich_assign',
entityType: 'person',
entityId: personId,
payloadJson: { immichPersonId, avatarUpdated: false },
},
}),
])
return reply.send({ ok: true, avatarUpdated: false, immichPersonId })
}
// Un portrait déjà en place ne se remplace jamais en silence : sans accord explicite,
// l'association s'arrête avant d'écrire quoi que ce soit. Le front transforme ce refus
// en demande de confirmation puis rejoue la requête avec replaceAvatar.
if (person.avatarPath && !payload.data.replaceAvatar) {
return reply.code(409).send({ error: 'avatar_exists' })
}
// La vignette passe par le même traitement qu'un portrait envoyé : type lu dans les octets,
// métadonnées retirées, quota de l'arbre respecté.
let prepared: Awaited<ReturnType<typeof prepareUploadedFile>>
try {
prepared = await prepareUploadedFile(thumbnail, 'photo', `immich-${immichPersonId}.jpg`)
} catch (error) {
if (error instanceof InvalidMediaError) {
return reply.code(400).send({ error: 'invalid_avatar_image' })
}
throw error
}
if (await exceedsStorageQuota(app, treeId, prepared.buffer.length, false)) {
return reply.code(413).send({ error: 'storage_quota_exceeded' })
}
const relativePath = buildAvatarRelativePath(treeId, personId, prepared.extension)
const storageRoot = getStorageRoot()
const absolutePath = path.resolve(storageRoot, relativePath)
// Chemin hors du stockage : refusé, jamais écrit.
if (!absolutePath.startsWith(storageRoot + path.sep)) {
return reply.code(400).send({ error: 'invalid_media_path' })
}
// Fichier écrit avant la base : si la transaction échoue, on l'efface et rien n'a changé.
await mkdir(path.dirname(absolutePath), { recursive: true })
await writeFile(absolutePath, prepared.buffer)
try {
await app.prisma.$transaction([
app.prisma.person.update({
where: { id: personId },
data: {
avatarPath: relativePath,
avatarMimeType: prepared.mimeType,
// Nouveau portrait, nouveau cadrage à faire
avatarCrop: Prisma.DbNull,
avatarBlurhash: await computeBlurhash(prepared.buffer) ?? '',
immichPersonId,
updatedBy: request.actor.userId,
},
}),
app.prisma.auditLog.create({
data: {
treeId,
actorType: 'user',
actorId: request.actor.userId,
action: 'immich_assign',
entityType: 'person',
entityId: personId,
payloadJson: { immichPersonId, avatarUpdated: true, avatarPath: relativePath },
},
}),
])
} catch (error) {
await removeStoredFile(storageRoot, relativePath).catch(() => {})
request.log.error({ err: error }, 'Échec de l’association Immich : fichier de portrait retiré, base inchangée')
throw error
}
// L'ancien portrait ne reste pas sur le disque. Un échec du nettoyage n'invalide pas
// l'association, qui a déjà réussi : il est journalisé, rien de plus.
if (person.avatarPath && person.avatarPath !== relativePath) {
await removeStoredFile(storageRoot, person.avatarPath).catch((cleanupError) => {
request.log.error(
{ err: cleanupError, oldAvatar: person.avatarPath },
'Ancien portrait non supprimé après une association Immich',
)
})
}
return reply.send({ ok: true, avatarUpdated: true, immichPersonId })
})
app.post('/trees/:id/immich/unassign', async (request, reply) => {
if (!request.actor || request.actor.kind !== 'user') {
return reply.code(401).send({ error: 'authentication_required' })
}
const params = treeParamSchema.safeParse(request.params)
const payload = unassignSchema.safeParse(request.body)
if (!params.success || !payload.success) {
return reply.code(400).send({ error: 'invalid_params' })
}
if (!(await requireImmichOwner(app, request.actor.userId, params.data.id))) {
return reply.code(403).send({ error: 'forbidden' })
}
// Pas de garde « non configuré » ici : le retrait d'association ne parle jamais à Immich,
// et c'est le seul moyen de nettoyer des associations passées après une déconnexion.
const { personId } = payload.data
const treeId = params.data.id
const person = await app.prisma.person.findFirst({
where: { id: personId, treeId, deletedAt: null },
select: { id: true, avatarPath: true, immichPersonId: true },
})
if (!person) {
return reply.code(404).send({ error: 'person_not_found' })
}
// Le graphe ne dit plus qui est associé : le retrait se propose toujours côté front. Sans
// association en place, il ne fait rien — pas d'écriture, pas d'entrée d'audit.
if (!person.immichPersonId) {
return reply.send({ ok: true, avatarRemoved: false })
}
// Le portrait vient d'Immich uniquement si son fichier porte notre préfixe. Un portrait
// envoyé à la main reste en place : seule l'association est retirée.
const avatarFromImmich = isImmichAvatarPath(person.avatarPath)
const updateData: {
immichPersonId: null
updatedBy: string
avatarPath?: null
avatarMimeType?: null
avatarCrop?: typeof Prisma.DbNull
avatarBlurhash?: null
} = { immichPersonId: null, updatedBy: request.actor.userId }
if (avatarFromImmich) {
updateData.avatarPath = null
updateData.avatarMimeType = null
updateData.avatarCrop = Prisma.DbNull
updateData.avatarBlurhash = null
}
// Base d'abord, fichier ensuite : le portrait disparaît seulement une fois la base à jour.
await app.prisma.$transaction([
app.prisma.person.update({ where: { id: personId }, data: updateData }),
app.prisma.auditLog.create({
data: {
treeId,
actorType: 'user',
actorId: request.actor.userId,
action: 'immich_unassign',
entityType: 'person',
entityId: personId,
payloadJson: { previousImmichPersonId: person.immichPersonId, avatarRemoved: avatarFromImmich },
},
}),
])
if (avatarFromImmich) {
await removeStoredFile(getStorageRoot(), person.avatarPath).catch((cleanupError) => {
request.log.error(
{ err: cleanupError, avatarPath: person.avatarPath },
'Portrait Immich non supprimé après un désassignement (base à jour)',
)
})
}
return reply.send({ ok: true, avatarRemoved: avatarFromImmich })
})
}
+241
View File
@@ -0,0 +1,241 @@
import type { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'
import { z } from 'zod'
import { env } from '../config/env.js'
import { isOidcEnabled } from '../lib/oidc.js'
import {
INVITATION_TTL_MS,
MAX_PENDING_INVITATIONS,
acceptInvitation,
findPendingInvitationByToken,
hashInvitationToken,
maskEmail,
newInvitationToken,
normalizeEmail,
pendingInvitationWhere,
} from '../lib/tree-invitations.js'
// Co-gestion d'un arbre (issue #145). Un co-gestionnaire est un membre « admin » : il fait tout ce que fait le
// propriétaire, sauf inviter ou retirer un co-gestionnaire et supprimer l'arbre (DELETE /trees/:id, réservé au
// propriétaire), ni Immich, qui passe par la clé du propriétaire (routes/immich.ts). Les droits sont relus en base à
// chaque requête : un co-gestionnaire retiré perd la main aussitôt. Il a pu voir le mot de passe de partage :
// l'écran de retrait propose de le changer.
const treeIdParamSchema = z.object({ id: z.string().min(1) })
const invitationParamsSchema = z.object({ id: z.string().min(1), invitationId: z.string().min(1) })
const managerParamsSchema = z.object({ id: z.string().min(1), userId: z.string().min(1) })
const tokenParamSchema = z.object({ token: z.string().min(1).max(200) })
const inviteSchema = z.object({ email: z.string().trim().email().max(254) })
function requireUser(request: FastifyRequest, reply: FastifyReply): { userId: string } | null {
if (!request.actor || request.actor.kind !== 'user') {
reply.code(401).send({ error: 'authentication_required' })
return null
}
return { userId: request.actor.userId }
}
async function membershipOf(app: FastifyInstance, treeId: string, userId: string) {
const membership = await app.prisma.treeMembership.findUnique({
where: { treeId_userId: { treeId, userId } },
select: { role: true, tree: { select: { deletedAt: true, ownerUserId: true } } },
})
return membership && !membership.tree.deletedAt ? membership : null
}
function audit(app: FastifyInstance, treeId: string, actorId: string, action: string, payloadJson: Record<string, string>) {
return app.prisma.auditLog.create({
data: { treeId, actorType: 'user', actorId, action, entityType: 'tree', entityId: treeId, payloadJson },
})
}
export const managerRoutes: FastifyPluginAsync = async (app) => {
// Gestionnaires et invitations en attente, pour tout gestionnaire de l'arbre. canManage : le propriétaire.
app.get('/trees/:id/managers', async (request, reply) => {
const user = requireUser(request, reply)
if (!user) return
const params = treeIdParamSchema.safeParse(request.params)
if (!params.success) return reply.code(400).send({ error: 'invalid_tree_id' })
const membership = await membershipOf(app, params.data.id, user.userId)
if (!membership || (membership.role !== 'owner' && membership.role !== 'admin')) {
return reply.code(403).send({ error: 'forbidden' })
}
const [managers, invitations] = await Promise.all([
app.prisma.treeMembership.findMany({
where: { treeId: params.data.id, role: { in: ['owner', 'admin'] } },
select: { role: true, createdAt: true, user: { select: { id: true, email: true, firstName: true } } },
orderBy: { createdAt: 'asc' },
}),
app.prisma.treeInvitation.findMany({
where: { treeId: params.data.id, ...pendingInvitationWhere() },
select: { id: true, email: true, createdAt: true, expiresAt: true },
orderBy: { createdAt: 'asc' },
}),
])
return reply.send({
canManage: membership.role === 'owner',
managers: managers.map((m) => ({ userId: m.user.id, email: m.user.email, firstName: m.user.firstName, role: m.role, since: m.createdAt })),
invitations,
})
})
// Inviter une adresse. Une invitation déjà en attente pour cette adresse reçoit un nouveau lien (l'ancien ne
// sert plus). Le jeton n'est rendu qu'ici et par /link : la base n'en garde que l'empreinte.
app.post('/trees/:id/invitations', { config: { rateLimit: { max: 20, timeWindow: '10 minutes' } } }, async (request, reply) => {
const user = requireUser(request, reply)
if (!user) return
const params = treeIdParamSchema.safeParse(request.params)
if (!params.success) return reply.code(400).send({ error: 'invalid_tree_id' })
const payload = inviteSchema.safeParse(request.body)
if (!payload.success) return reply.code(400).send({ error: 'invalid_email' })
const membership = await membershipOf(app, params.data.id, user.userId)
if (!membership || membership.role !== 'owner') return reply.code(403).send({ error: 'forbidden' })
const email = normalizeEmail(payload.data.email)
const alreadyManager = await app.prisma.treeMembership.findFirst({
where: { treeId: params.data.id, role: { in: ['owner', 'admin'] }, user: { email } },
select: { id: true },
})
if (alreadyManager) return reply.code(409).send({ error: 'already_manager' })
const { token, tokenHash } = newInvitationToken()
const expiresAt = new Date(Date.now() + INVITATION_TTL_MS)
const existing = await app.prisma.treeInvitation.findFirst({
where: { treeId: params.data.id, email, ...pendingInvitationWhere() },
select: { id: true },
})
let invitation
if (existing) {
invitation = await app.prisma.treeInvitation.update({
where: { id: existing.id },
data: { tokenHash, expiresAt },
select: { id: true, email: true, createdAt: true, expiresAt: true },
})
} else {
const pending = await app.prisma.treeInvitation.count({ where: { treeId: params.data.id, ...pendingInvitationWhere() } })
if (pending >= MAX_PENDING_INVITATIONS) return reply.code(429).send({ error: 'too_many_invitations' })
invitation = await app.prisma.treeInvitation.create({
data: { treeId: params.data.id, email, tokenHash, expiresAt, invitedByUserId: user.userId },
select: { id: true, email: true, createdAt: true, expiresAt: true },
})
}
await audit(app, params.data.id, user.userId, 'manager_invited', { invitationId: invitation.id })
return reply.code(201).send({ invitation, token })
})
// « Copier le lien » : un nouveau jeton (l'ancien lien ne sert plus), la durée repart de zéro
app.post('/trees/:id/invitations/:invitationId/link', { config: { rateLimit: { max: 30, timeWindow: '10 minutes' } } }, async (request, reply) => {
const user = requireUser(request, reply)
if (!user) return
const params = invitationParamsSchema.safeParse(request.params)
if (!params.success) return reply.code(400).send({ error: 'invalid_params' })
const membership = await membershipOf(app, params.data.id, user.userId)
if (!membership || membership.role !== 'owner') return reply.code(403).send({ error: 'forbidden' })
const { token, tokenHash } = newInvitationToken()
const updated = await app.prisma.treeInvitation.updateMany({
where: { id: params.data.invitationId, treeId: params.data.id, ...pendingInvitationWhere() },
data: { tokenHash, expiresAt: new Date(Date.now() + INVITATION_TTL_MS) },
})
if (updated.count !== 1) return reply.code(404).send({ error: 'invitation_not_found' })
return reply.send({ token })
})
app.delete('/trees/:id/invitations/:invitationId', async (request, reply) => {
const user = requireUser(request, reply)
if (!user) return
const params = invitationParamsSchema.safeParse(request.params)
if (!params.success) return reply.code(400).send({ error: 'invalid_params' })
const membership = await membershipOf(app, params.data.id, user.userId)
if (!membership || membership.role !== 'owner') return reply.code(403).send({ error: 'forbidden' })
const revoked = await app.prisma.treeInvitation.updateMany({
where: { id: params.data.invitationId, treeId: params.data.id, ...pendingInvitationWhere() },
data: { revokedAt: new Date() },
})
if (revoked.count !== 1) return reply.code(404).send({ error: 'invitation_not_found' })
await audit(app, params.data.id, user.userId, 'manager_invitation_revoked', { invitationId: params.data.invitationId })
return reply.send({ ok: true })
})
// Retirer un co-gestionnaire (le propriétaire), ou quitter la gestion (le co-gestionnaire lui-même).
// Le propriétaire ne se retire jamais : son arbre part avec lui (suppression de l'arbre ou du compte).
app.delete('/trees/:id/managers/:userId', async (request, reply) => {
const user = requireUser(request, reply)
if (!user) return
const params = managerParamsSchema.safeParse(request.params)
if (!params.success) return reply.code(400).send({ error: 'invalid_params' })
const membership = await membershipOf(app, params.data.id, user.userId)
const leaving = params.data.userId === user.userId
if (!membership || (leaving ? membership.role !== 'admin' : membership.role !== 'owner')) {
return reply.code(403).send({ error: 'forbidden' })
}
// Un accès retenu par le mot de passe de partage (user_tree_accesses) part aussi : sinon le compte retiré
// lirait encore l'arbre
const removed = await app.prisma.$transaction(async (tx) => {
const deleted = await tx.treeMembership.deleteMany({
where: { treeId: params.data.id, userId: params.data.userId, role: 'admin' },
})
if (deleted.count === 1) {
await tx.userTreeAccess.deleteMany({ where: { treeId: params.data.id, userId: params.data.userId } })
}
return deleted
})
if (removed.count !== 1) return reply.code(404).send({ error: 'manager_not_found' })
await audit(app, params.data.id, user.userId, leaving ? 'manager_left' : 'manager_removed', { userId: params.data.userId })
return reply.send({ ok: true })
})
// Page du lien, sans compte : l'arbre, qui invite, l'adresse masquée, et les façons d'entrer sur ce serveur
app.get('/invitations/:token', { config: { rateLimit: { max: 30, timeWindow: '1 minute' } } }, async (request, reply) => {
const params = tokenParamSchema.safeParse(request.params)
if (!params.success) return reply.code(404).send({ error: 'invitation_invalid' })
const invitation = await findPendingInvitationByToken(app.prisma, params.data.token)
if (!invitation) {
// Lien déjà servi ou expiré : dit tel quel, pour que la page explique quoi faire. Rien d'autre ne sort.
const known = await app.prisma.treeInvitation.findUnique({
where: { tokenHash: hashInvitationToken(params.data.token) },
select: { acceptedAt: true, revokedAt: true, expiresAt: true },
})
const error = known?.acceptedAt ? 'invitation_used' : known && !known.revokedAt && known.expiresAt <= new Date() ? 'invitation_expired' : 'invitation_invalid'
return reply.code(404).send({ error })
}
return reply.send({
treeName: invitation.tree.name,
treeIcon: invitation.tree.icon,
invitedBy: invitation.tree.owner.firstName || null,
emailHint: maskEmail(invitation.email),
expiresAt: invitation.expiresAt,
passwordLogin: env.PASSWORD_LOGIN,
oidc: isOidcEnabled() ? { name: env.OIDC_PROVIDER_NAME } : null,
})
})
// Accepter avec le compte connecté : il doit avoir l'adresse invitée
app.post('/invitations/:token/accept', { config: { rateLimit: { max: 10, timeWindow: '1 minute' } } }, async (request, reply) => {
const user = requireUser(request, reply)
if (!user) return
const params = tokenParamSchema.safeParse(request.params)
if (!params.success) return reply.code(404).send({ error: 'invitation_invalid' })
const invitation = await findPendingInvitationByToken(app.prisma, params.data.token)
if (!invitation) return reply.code(404).send({ error: 'invitation_invalid' })
const account = await app.prisma.user.findUnique({ where: { id: user.userId }, select: { email: true } })
if (!account) return reply.code(401).send({ error: 'authentication_required' })
const result = await acceptInvitation(app.prisma, invitation.id, user.userId, account.email)
if (!result.ok) {
const code = result.error === 'invitation_invalid' ? 404 : result.error === 'already_owner' ? 409 : 403
return reply.code(code).send({ error: result.error })
}
return reply.send({ treeId: result.treeId, role: 'admin' })
})
}
+319 -34
View File
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto'
import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { Prisma } from '../generated/prisma/client.js'
import type { FastifyPluginAsync, FastifyRequest } from 'fastify'
import { z } from 'zod'
@@ -10,18 +11,24 @@ import {
applyMediaResponseHeaders,
extensionForMime,
InvalidMediaError,
isImmichAvatarPath,
isMimeAllowedForType,
removeStoredFile,
sanitizeImage,
scrubMp4Location,
sendFileWithRanges,
sniffMimeType,
storedFileSize,
type BinaryMediaType,
} from '../lib/media-files.js'
import { avatarCropInputSchema, readAvatarCrop, toStoredAvatarCrop } from '../lib/avatar-crop.js'
import { computeBlurhash } from '../lib/blurhash.js'
import { syncPersonMediaOrder } from '../lib/media-order.js'
import { resolveTreeMediaDirectory } from '../lib/tree-purge.js'
import { canReadTree, canReadTreeWithMediaToken } from '../lib/tree-access.js'
import type { MembershipRole } from '../types/auth.js'
import { ContributionRouteError, resolveSubmissionContext } from './contributions.js'
import { forgetPendingPhoto, reservePendingPhoto, sweepStalePendingPhotos } from '../lib/annotation-changes.js'
const MAX_IMAGE_BYTES = 5 * 1024 * 1024
const MAX_FILE_BYTES = 20 * 1024 * 1024
@@ -48,6 +55,8 @@ const downloadQuerySchema = z.object({
const uploadBinaryMediaSchema = z.object({
submittedByLabel: z.string().trim().min(1).max(120).optional(),
// Moment de l'onglet Vie auquel rattacher ce souvenir
lifeEventId: z.string().min(1).max(64).optional(),
type: z.enum(['photo', 'video', 'audio', 'document', 'geojson', 'gpx']),
fileName: z.string().min(1).max(255),
mimeType: z.string().min(1).max(120),
@@ -59,6 +68,8 @@ const uploadBinaryMediaSchema = z.object({
const uploadCitationMediaSchema = z.object({
submittedByLabel: z.string().trim().min(1).max(120).optional(),
// Moment de l'onglet Vie auquel rattacher ce souvenir
lifeEventId: z.string().min(1).max(64).optional(),
type: z.literal('citation'),
caption: z.string().trim().min(1).max(2000),
source: mediaSourceSchema,
@@ -68,6 +79,8 @@ const YOUTUBE_URL_RE = /^https:\/\/(www\.)?(youtube\.com|youtu\.be)\//
const uploadYoutubeVideoSchema = z.object({
submittedByLabel: z.string().trim().min(1).max(120).optional(),
// Moment de l'onglet Vie auquel rattacher ce souvenir
lifeEventId: z.string().min(1).max(64).optional(),
type: z.literal('video'),
mimeType: z.literal('video/youtube'),
youtubeUrl: z.string().max(500).regex(YOUTUBE_URL_RE),
@@ -88,11 +101,19 @@ const reorderMediaSchema = z.object({
orderedMediaIds: z.array(z.string().min(1)).min(1),
})
const MAX_CAPTION_LENGTH = 280
const MAX_CITATION_LENGTH = 2000
const updateMediaSchema = z.object({
caption: z.string().trim().min(1).max(MAX_CITATION_LENGTH),
lifeEventId: z.string().min(1).max(64).nullable().optional(),
})
function hasAdminRight(role: MembershipRole): boolean {
return role === 'owner' || role === 'admin'
}
function getStorageRoot(): string {
// Racine du stockage des médias : partagée avec les portraits venus d'Immich (routes/immich.ts).
export function getStorageRoot(): string {
return path.resolve(env.MEDIA_STORAGE_PATH)
}
@@ -116,7 +137,7 @@ function normalizeOptionalText(value: string | null | undefined): string | null
// Fichier décodé, reconnu à ses octets et nettoyé (photos sans métadonnées, vidéos sans position).
// Le type déclaré par le navigateur n'est plus qu'un indice pour les conteneurs ambigus.
async function prepareUploadedFile(
export async function prepareUploadedFile(
buffer: Buffer,
type: BinaryMediaType,
fileName: string,
@@ -167,7 +188,7 @@ async function treeDiskUsage(treeId: string): Promise<number> {
}
// Place disque d'un arbre : total, et part des envois en attente de relecture (limite l'envoi en boucle)
async function exceedsStorageQuota(app: FastifyRequest['server'], treeId: string, incomingBytes: number, isPending: boolean): Promise<boolean> {
export async function exceedsStorageQuota(app: FastifyRequest['server'], treeId: string, incomingBytes: number, isPending: boolean): Promise<boolean> {
const MB = 1024 * 1024
if ((await treeDiskUsage(treeId)) + incomingBytes > env.MEDIA_QUOTA_PER_TREE_MB * MB) {
return true
@@ -186,8 +207,11 @@ async function exceedsStorageQuota(app: FastifyRequest['server'], treeId: string
// Seules les routes d'envoi acceptent un corps de 12 Mo (fichier en base64), le reste de l'API est à 1 Mo (app.ts)
const UPLOAD_RATE_LIMIT = { bodyLimit: UPLOAD_BODY_LIMIT, config: { rateLimit: { max: 30, timeWindow: '1 minute' } } }
// Portraits : réservés au propriétaire, mais l'app peut en placer quelques centaines d'un coup (photos
// retrouvées après un import GEDCOM), envoyées une par une. Le quota de stockage de l'arbre s'applique.
const AVATAR_RATE_LIMIT = { bodyLimit: UPLOAD_BODY_LIMIT, config: { rateLimit: { max: 120, timeWindow: '1 minute' } } }
async function requireAdminMembership(app: FastifyRequest['server'], userId: string, treeId: string) {
export async function requireAdminMembership(app: FastifyRequest['server'], userId: string, treeId: string) {
const membership = await app.prisma.treeMembership.findUnique({
where: {
treeId_userId: {
@@ -220,6 +244,18 @@ async function canReadMedia(app: FastifyRequest['server'], treeId: string, reque
return token ? canReadTreeWithMediaToken(app, treeId, token) : false
}
// Compte qui demande un média : celui de la session, sinon celui du jeton médias (lien de partage : aucun)
async function mediaRequesterUserId(app: FastifyRequest['server'], request: FastifyRequest, token: string | undefined): Promise<string | null> {
if (request.actor?.kind === 'user') return request.actor.userId
if (!token) return null
try {
const payload = await app.jwt.verify<{ kind?: string; userId?: string }>(token)
return payload.kind === 'media' && payload.userId ? payload.userId : null
} catch {
return null
}
}
export const mediaRoutes: FastifyPluginAsync = async (app) => {
app.post('/trees/:id/persons/:personId/media', UPLOAD_RATE_LIMIT, async (request, reply) => {
const actor = request.actor
@@ -263,6 +299,7 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
select: {
id: true,
firstName: true,
lastName: true,
},
})
@@ -270,6 +307,33 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
return reply.code(404).send({ error: 'person_not_found' })
}
// Rattachement à un moment : un moment de cette personne (ou de son union). La famille ne rattache qu'à
// un moment qu'elle vient de proposer, encore en attente ; l'administrateur, à un moment visible.
const lifeEventId = payload.data.lifeEventId ?? null
let momentSessionId: string | null = null
if (lifeEventId) {
const event = await app.prisma.lifeEvent.findFirst({
where: { id: lifeEventId, treeId: params.data.id, deletedAt: null },
select: { personId: true, unionId: true, status: true, contributionSessionId: true },
})
let ownsEvent = event?.personId === person.id
if (event && !ownsEvent && event.unionId) {
const union = await app.prisma.union.findFirst({ where: { id: event.unionId, treeId: params.data.id }, select: { partner1PersonId: true, partner2PersonId: true } })
ownsEvent = union?.partner1PersonId === person.id || union?.partner2PersonId === person.id
}
const statusOk = contribution ? event?.status === 'pending' : event?.status === 'approved'
if (!event || !ownsEvent || !statusOk) {
return reply.code(400).send({ error: 'invalid_life_event_reference' })
}
if (contribution && event.contributionSessionId) {
const session = await app.prisma.contributionSession.findFirst({
where: { id: event.contributionSessionId, treeId: params.data.id, status: 'pending' },
select: { id: true },
})
momentSessionId = session?.id ?? null
}
}
// Fichier reconnu, nettoyé et compté AVANT toute écriture : un envoi refusé ne laisse ni contribution
// vide en attente ni fichier sur le disque
let prepared: { buffer: Buffer; mimeType: string; extension: string } | null = null
@@ -314,7 +378,10 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
// Une contribution d'une ligne, créée avant le média pour qu'il la référence
const now = new Date()
const contributionSession = contribution
// Fichier d'un moment proposé : il rejoint la relecture du moment au lieu d'en ouvrir une autre
const contributionSession = contribution && momentSessionId
? { id: momentSessionId }
: contribution
? await app.prisma.contributionSession.create({
data: {
treeId: params.data.id,
@@ -322,7 +389,7 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
submittedByLabel: payload.data.submittedByLabel?.trim() || contribution.submittedByLabel,
mode: contribution.mode,
status: contribution.autoStatus,
title: `Souvenir pour ${person.firstName}`.slice(0, 120),
title: `Souvenir pour ${person.firstName || person.lastName}`.slice(0, 120),
reviewedAt: contribution.autoStatus === 'approved' ? now : undefined,
reviewedBy: contribution.autoStatus === 'approved' ? contribution.submittedByUserId : undefined,
},
@@ -372,6 +439,7 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
uploadedBy: uploaderUserId,
status: isPending ? 'pending' : 'approved',
contributionSessionId: contributionSession?.id ?? null,
lifeEventId,
},
})
if (!isPending) {
@@ -433,6 +501,8 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
uploadedBy: uploaderUserId,
status: isPending ? 'pending' : 'approved',
contributionSessionId: contributionSession?.id ?? null,
lifeEventId,
blurhash: payload.data.type === 'photo' ? await computeBlurhash(prepared.buffer) ?? '' : null,
},
})
@@ -461,7 +531,7 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
return reply.code(201).send({ media, status: media.status })
})
app.post('/trees/:id/persons/:personId/avatar', UPLOAD_RATE_LIMIT, async (request, reply) => {
app.post('/trees/:id/persons/:personId/avatar', AVATAR_RATE_LIMIT, async (request, reply) => {
if (!request.actor || request.actor.kind !== 'user') {
return reply.code(401).send({ error: 'authentication_required' })
}
@@ -490,6 +560,7 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
select: {
id: true,
avatarPath: true,
immichPersonId: true,
},
})
@@ -549,7 +620,14 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
data: {
avatarPath: relativePath,
avatarMimeType: prepared.mimeType,
// Nouveau portrait : le cadrage de l'ancien ne s'applique pas à celui-ci
avatarCrop: Prisma.DbNull,
avatarBlurhash: await computeBlurhash(prepared.buffer) ?? '',
updatedBy: request.actor.userId,
// Un portrait déposé à la main remplace aussi le lien Immich : sans cela, l'association
// survivrait invisible en base (le graphe ne publie que l'origine du portrait) et ne
// pourrait plus être retirée depuis l'interface.
...(person.immichPersonId ? { immichPersonId: null } : {}),
},
})
@@ -564,6 +642,7 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
action: 'person_avatar_updated',
entityType: 'person',
entityId: params.data.personId,
...(person.immichPersonId ? { payloadJson: { immichLinkRemoved: true } } : {}),
},
})
@@ -599,6 +678,10 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
data: {
avatarPath: null,
avatarMimeType: null,
avatarCrop: Prisma.DbNull,
avatarBlurhash: null,
// Retirer le portrait retire aussi le lien Immich : l'association ne survit pas au portrait.
immichPersonId: null,
updatedBy: request.actor.userId,
},
})
@@ -623,6 +706,69 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
return reply.send({ ok: true })
})
// Cadrage du portrait : l'original reste en place, seul le cadrage est enregistré. Même droit que changer la photo.
app.put('/trees/:id/persons/:personId/avatar/crop', async (request, reply) => {
if (!request.actor || request.actor.kind !== 'user') {
return reply.code(401).send({ error: 'authentication_required' })
}
const params = treePersonParamsSchema.safeParse(request.params)
if (!params.success) {
return reply.code(400).send({ error: 'invalid_params' })
}
const payload = avatarCropInputSchema.safeParse(request.body)
if (!payload.success) {
return reply.code(400).send({ error: 'invalid_payload', details: payload.error.flatten() })
}
const membership = await requireAdminMembership(app, request.actor.userId, params.data.id)
if (!membership) {
return reply.code(403).send({ error: 'forbidden' })
}
const person = await app.prisma.person.findFirst({
where: { id: params.data.personId, treeId: params.data.id, deletedAt: null },
select: { avatarPath: true },
})
if (!person) {
return reply.code(404).send({ error: 'person_not_found' })
}
if (!person.avatarPath) {
return reply.code(404).send({ error: 'avatar_not_found' })
}
// Hors périmètre pour l'instant : les portraits venus d'Immich ne se recadrent pas
if (isImmichAvatarPath(person.avatarPath)) {
return reply.code(409).send({ error: 'avatar_from_immich' })
}
const avatarCrop = toStoredAvatarCrop(payload.data.crop)
await app.prisma.person.updateMany({
where: { id: params.data.personId, treeId: params.data.id, deletedAt: null },
data: {
avatarCrop: avatarCrop ?? Prisma.DbNull,
updatedBy: request.actor.userId,
},
})
await app.prisma.auditLog.create({
data: {
treeId: params.data.id,
actorType: 'user',
actorId: request.actor.userId,
action: 'person_avatar_cropped',
entityType: 'person',
entityId: params.data.personId,
},
})
return reply.send({ ok: true, avatarCrop: readAvatarCrop(avatarCrop) })
})
app.patch('/trees/:id/persons/:personId/media/reorder', async (request, reply) => {
if (!request.actor || request.actor.kind !== 'user') {
return reply.code(401).send({ error: 'authentication_required' })
@@ -668,38 +814,148 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
return reply.code(400).send({ error: 'invalid_media_order_payload' })
}
for (let index = 0; index < orderedIds.length; index += 1) {
await app.prisma.mediaItem.updateMany({
where: {
id: orderedIds[index],
treeId: params.data.id,
personId: params.data.personId,
deletedAt: null,
},
const actorId = request.actor.userId
await app.prisma.$transaction(async (tx) => {
for (let index = 0; index < orderedIds.length; index += 1) {
await tx.mediaItem.updateMany({
where: {
id: orderedIds[index],
treeId: params.data.id,
personId: params.data.personId,
deletedAt: null,
},
data: {
displayOrder: index + 1,
},
})
}
await syncPersonMediaOrder(tx, params.data.id, params.data.personId)
await tx.auditLog.create({
data: {
displayOrder: index + 1,
treeId: params.data.id,
actorType: 'user',
actorId,
action: 'media_reordered',
entityType: 'media_item',
entityId: params.data.personId,
payloadJson: {
personId: params.data.personId,
orderedMediaIds: payload.data.orderedMediaIds,
},
},
})
})
return reply.send({ ok: true })
})
// Modifier un souvenir : titre (caption) et moment de vie. Pour une citation, caption est aussi le texte
// du fichier citation.txt, réécrit ici pour que la lecture et le téléchargement restent identiques.
app.patch('/trees/:id/persons/:personId/media/:mediaId', async (request, reply) => {
if (!request.actor || request.actor.kind !== 'user') {
return reply.code(401).send({ error: 'authentication_required' })
}
await syncPersonMediaOrder(app.prisma, params.data.id, params.data.personId)
const params = z
.object({
id: z.string().min(1),
personId: z.string().min(1),
mediaId: z.string().min(1),
})
.safeParse(request.params)
if (!params.success) {
return reply.code(400).send({ error: 'invalid_params' })
}
const membership = await requireAdminMembership(app, request.actor.userId, params.data.id)
if (!membership) {
return reply.code(403).send({ error: 'forbidden' })
}
const payload = updateMediaSchema.safeParse(request.body)
if (!payload.success) {
return reply.code(400).send({ error: 'invalid_payload' })
}
const existingMedia = await app.prisma.mediaItem.findFirst({
where: { id: params.data.mediaId, treeId: params.data.id, personId: params.data.personId, deletedAt: null },
select: { id: true, type: true, filePath: true },
})
if (!existingMedia) {
return reply.code(404).send({ error: 'media_not_found' })
}
const isCitation = existingMedia.type === 'citation'
if (!isCitation && payload.data.caption.length > MAX_CAPTION_LENGTH) {
return reply.code(400).send({ error: 'caption_too_long' })
}
const lifeEventId = payload.data.lifeEventId
if (lifeEventId) {
const event = await app.prisma.lifeEvent.findFirst({
where: { id: lifeEventId, treeId: params.data.id, deletedAt: null },
select: { personId: true, unionId: true, status: true },
})
let ownsEvent = event?.personId === params.data.personId
if (event && !ownsEvent && event.unionId) {
const union = await app.prisma.union.findFirst({
where: { id: event.unionId, treeId: params.data.id },
select: { partner1PersonId: true, partner2PersonId: true },
})
ownsEvent = union?.partner1PersonId === params.data.personId || union?.partner2PersonId === params.data.personId
}
if (!event || !ownsEvent || event.status !== 'approved') {
return reply.code(400).send({ error: 'invalid_life_event_reference' })
}
}
const data: { caption: string; lifeEventId?: string | null; sizeBytes?: number } = {
caption: payload.data.caption,
}
if (lifeEventId !== undefined) data.lifeEventId = lifeEventId
if (isCitation) {
const buffer = Buffer.from(payload.data.caption, 'utf8')
const storageRoot = getStorageRoot()
const absolutePath = path.resolve(storageRoot, existingMedia.filePath)
if (!absolutePath.startsWith(storageRoot + path.sep)) {
return reply.code(400).send({ error: 'invalid_media_path' })
}
await writeFile(absolutePath, buffer)
data.sizeBytes = buffer.length
}
await app.prisma.mediaItem.updateMany({
where: { id: params.data.mediaId, treeId: params.data.id, personId: params.data.personId, deletedAt: null },
data,
})
await app.prisma.auditLog.create({
data: {
treeId: params.data.id,
actorType: 'user',
actorId: request.actor.userId,
action: 'media_reordered',
action: 'media_updated',
entityType: 'media_item',
entityId: params.data.personId,
entityId: params.data.mediaId,
payloadJson: {
personId: params.data.personId,
orderedMediaIds: payload.data.orderedMediaIds,
lifeEventChanged: lifeEventId !== undefined,
},
},
})
return reply.send({ ok: true })
return reply.send({
ok: true,
media: {
id: params.data.mediaId,
caption: payload.data.caption,
lifeEventId,
},
})
})
app.delete('/trees/:id/persons/:personId/media/:mediaId', async (request, reply) => {
@@ -745,8 +1001,9 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
return reply.code(404).send({ error: 'media_not_found' })
}
// Supprimé = effacé du disque (la ligne reste pour le journal)
// Supprimé = effacé du disque (la ligne reste pour le journal), et plus personne n'y est identifié
await removeStoredFile(getStorageRoot(), existingMedia?.filePath)
await app.prisma.mediaPersonTag.deleteMany({ where: { treeId: params.data.id, mediaId: params.data.mediaId } })
await syncPersonMediaOrder(app.prisma, params.data.id, params.data.personId)
@@ -840,6 +1097,8 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
select: {
filePath: true,
mimeType: true,
status: true,
uploadedBy: true,
},
})
@@ -847,6 +1106,16 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
return reply.code(404).send({ error: 'media_not_found' })
}
// Un souvenir qui attend la relecture ne se lit que par qui gère l'arbre ou par qui l'a envoyé, pas par toute la
// famille qui connaîtrait son identifiant. Même réponse qu'un média absent : rien n'indique qu'il existe.
if (media.status === 'pending') {
const userId = await mediaRequesterUserId(app, request, query.data.token)
const allowed = Boolean(userId) && (media.uploadedBy === userId || Boolean(await requireAdminMembership(app, userId as string, params.data.id)))
if (!allowed) {
return reply.code(404).send({ error: 'media_not_found' })
}
}
// YouTube videos: filePath is the YouTube URL, redirect the browser
if (media.mimeType === 'video/youtube') {
return reply.code(302).redirect(media.filePath)
@@ -858,16 +1127,13 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
return reply.code(400).send({ error: 'invalid_media_path' })
}
let fileBuffer: Buffer
try {
fileBuffer = await readFile(absolutePath)
} catch {
const size = await storedFileSize(absolutePath)
if (size === null) {
return reply.code(404).send({ error: 'media_file_missing' })
}
applyMediaResponseHeaders(reply, media.mimeType, path.basename(media.filePath))
return reply.send(fileBuffer)
return sendFileWithRanges(request, reply, absolutePath, size)
})
// ---- Annotation Photos ----
@@ -885,8 +1151,10 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
photoId: z.string().min(1),
})
// Photo posée sur l'arbre. Un gestionnaire la pose tout de suite ; la famille (lien de partage ou membre) la
// propose : le fichier attend la relecture, noté dans annotation_photo_uploads (lib/annotation-changes.ts).
app.post('/trees/:id/annotation-photos', UPLOAD_RATE_LIMIT, async (request, reply) => {
if (!request.actor || request.actor.kind !== 'user') {
if (!request.actor) {
return reply.code(401).send({ error: 'authentication_required' })
}
@@ -900,9 +1168,16 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
return reply.code(400).send({ error: 'invalid_payload', details: payload.error.flatten() })
}
const membership = await requireAdminMembership(app, request.actor.userId, params.data.id)
if (!membership) {
return reply.code(403).send({ error: 'forbidden' })
const isManager = request.actor.kind === 'user' && Boolean(await requireAdminMembership(app, request.actor.userId, params.data.id))
if (!isManager) {
try {
await resolveSubmissionContext(app, request.actor, params.data.id)
} catch (error) {
if (error instanceof ContributionRouteError) return reply.code(error.statusCode).send({ error: error.code })
throw error
}
const stale = await sweepStalePendingPhotos(app.prisma, params.data.id)
await Promise.all(stale.map((filePath) => removeStoredFile(getStorageRoot(), filePath)))
}
const fileBuffer = Buffer.from(stripDataUrlPrefix(payload.data.dataBase64), 'base64')
@@ -942,8 +1217,18 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
return reply.code(400).send({ error: 'invalid_photo_path' })
}
await mkdir(path.dirname(absolutePath), { recursive: true })
await writeFile(absolutePath, prepared.buffer)
// Famille : la place est réservée sous un verrou par arbre avant d'écrire, pour tenir le plafond même quand
// plusieurs envois arrivent ensemble
if (!isManager && !(await reservePendingPhoto(app.prisma, params.data.id, photoId, relativePath))) {
return reply.code(429).send({ error: 'too_many_pending_photos' })
}
try {
await mkdir(path.dirname(absolutePath), { recursive: true })
await writeFile(absolutePath, prepared.buffer)
} catch (error) {
if (!isManager) await forgetPendingPhoto(app.prisma, photoId)
throw error
}
return reply.code(201).send({
photoId,
+293
View File
@@ -0,0 +1,293 @@
import type { FastifyInstance, FastifyPluginAsync, FastifyReply } from 'fastify'
import { randomUUID } from 'node:crypto'
import { Prisma } from '../generated/prisma/client.js'
import { z } from 'zod'
import { env } from '../config/env.js'
import {
appBaseUrl,
finishAuthorization,
isOidcEnabled,
safeReturnTo,
startAuthorization,
type OidcIdentity,
type OidcPending,
} from '../lib/oidc.js'
import { confirmsWithPassword } from '../lib/auth.js'
import { createAccountIfAllowed } from '../lib/registration.js'
import { claimInvitationsForVerifiedEmail, hasPendingInvitationForEmail } from '../lib/tree-invitations.js'
import { signUserSession } from '../lib/user-session.js'
// Parcours : /auth/oidc/start pose un cookie signé (state, nonce, vérificateur PKCE) et part chez le fournisseur ;
// /auth/oidc/callback vérifie tout, trouve ou crée le compte, et renvoie vers l'app avec un jeton de passage dans le
// fragment (#oidc=…, jamais envoyé à un serveur ni écrit dans un journal) ; l'app l'échange une seule fois contre
// une session sur /auth/oidc/exchange. Le cookie lie le retour au navigateur qui a commencé la connexion.
const PENDING_COOKIE = 'parenthese_oidc'
const PENDING_MAX_AGE_S = 10 * 60
const HANDOFF_TTL_S = 60
type HandoffPayload = { kind: 'oidc_handoff'; sub: string; jti: string; exp?: number }
// Jetons de passage déjà échangés, jusqu'à leur expiration (une seule instance de l'API, comme les sessions révoquées)
const usedHandoffs = new Map<string, number>()
function consumeHandoff(jti: string, expSeconds: number): boolean {
const now = Date.now()
for (const [key, expiresAt] of usedHandoffs) {
if (expiresAt < now) usedHandoffs.delete(key)
}
if (usedHandoffs.has(jti)) return false
usedHandoffs.set(jti, expSeconds * 1000)
return true
}
const pendingSchema = z.object({
state: z.string().min(1),
nonce: z.string().min(1),
codeVerifier: z.string().min(43),
returnTo: z.string(),
linkUserId: z.string().min(1).optional(),
})
// Rattacher une identité du fournisseur à un compte à mot de passe. Le compte connecté demande un ticket (court, à
// usage unique), le départ OIDC le porte, et le retour relie l'identité à ce compte. La personne prouve les deux :
// son mot de passe (session) et son compte chez le fournisseur. C'est le chemin pour passer une instance en
// PASSWORD_LOGIN=false sans enfermer dehors les comptes à mot de passe : le rattachement automatique par email reste
// refusé pour eux (resolveAccount), parce que l'inscription par mot de passe ne vérifie pas l'adresse.
const LINK_TICKET_TTL_S = 120
type LinkTicketPayload = { kind: 'oidc_link'; sub: string; jti: string; exp?: number }
const usedLinkTickets = new Map<string, number>()
function consumeLinkTicket(jti: string, expSeconds: number): boolean {
const now = Date.now()
for (const [key, expiresAt] of usedLinkTickets) {
if (expiresAt < now) usedLinkTickets.delete(key)
}
if (usedLinkTickets.has(jti)) return false
usedLinkTickets.set(jti, expSeconds * 1000)
return true
}
type LinkResult = { userId: string } | { error: 'oidc_identity_taken' | 'oidc_failed' }
async function linkIdentity(app: FastifyInstance, userId: string, identity: OidcIdentity): Promise<LinkResult> {
const user = await app.prisma.user.findUnique({ where: { id: userId }, select: { id: true } })
if (!user) return { error: 'oidc_failed' }
const linked = await app.prisma.userIdentity.findUnique({
where: { issuer_subject: { issuer: identity.issuer, subject: identity.subject } },
select: { userId: true },
})
// Déjà reliée à ce compte : rien à faire. Reliée à un autre : jamais déplacée d'un compte à l'autre
if (linked) return linked.userId === userId ? { userId } : { error: 'oidc_identity_taken' }
await app.prisma.userIdentity.create({ data: { userId, issuer: identity.issuer, subject: identity.subject } })
return { userId }
}
const exchangeSchema = z.object({
code: z.string().min(1).max(4096),
})
type AccountResult = { userId: string } | { error: 'registration_closed' | 'oidc_email_missing' | 'oidc_email_unverified' | 'oidc_email_taken' }
// Compte relié à cette identité ; sinon le compte qui a cet email, si le fournisseur le garantit ; sinon un compte
// neuf, seulement si l'instance en accepte (ALLOW_REGISTRATION, ou instance encore vide).
async function resolveAccount(app: FastifyInstance, identity: OidcIdentity): Promise<AccountResult> {
const linked = await app.prisma.userIdentity.findUnique({
where: { issuer_subject: { issuer: identity.issuer, subject: identity.subject } },
select: { userId: true },
})
if (linked) return { userId: linked.userId }
if (!identity.email) return { error: 'oidc_email_missing' }
if (env.OIDC_REQUIRE_VERIFIED_EMAIL && !identity.emailVerified) return { error: 'oidc_email_unverified' }
const existing = await app.prisma.user.findUnique({
where: { email: identity.email },
select: { id: true, passwordHash: true, identities: { select: { id: true }, take: 1 } },
})
if (existing) {
// Relier un compte existant demande toujours un email vérifié, même avec OIDC_REQUIRE_VERIFIED_EMAIL=false :
// sinon quiconque déclare l'adresse d'un autre chez le fournisseur prendrait son compte
if (!identity.emailVerified) return { error: 'oidc_email_unverified' }
// Jamais vers un compte à mot de passe : l'inscription ne vérifie pas l'adresse, un tiers aurait pu créer ce
// compte avec l'email de la victime, et sa connexion par le fournisseur y serait reliée, lisible par le tiers
if (existing.passwordHash) return { error: 'oidc_email_taken' }
// Un compte déjà relié à une autre identité (un autre sujet) n'est jamais repris par un nouveau sujet qui
// présente le même email : le fournisseur a pu réattribuer l'adresse. Le compte reste à celui qui l'a relié.
if (existing.identities?.length) return { error: 'oidc_email_taken' }
await app.prisma.userIdentity.create({
data: { userId: existing.id, issuer: identity.issuer, subject: identity.subject },
})
return { userId: existing.id }
}
// Une invitation à co-gérer un arbre ouvre un compte même inscription fermée, si le fournisseur garantit l'adresse
const created = await createAccountIfAllowed(app.prisma, (tx) => tx.user.create({
data: {
email: identity.email as string,
firstName: identity.firstName,
passwordHash: null,
identities: { create: { issuer: identity.issuer, subject: identity.subject } },
},
select: { id: true },
}), { invited: identity.emailVerified && await hasPendingInvitationForEmail(app.prisma, identity.email) })
return created ? { userId: created.id } : { error: 'registration_closed' }
}
function redirectToApp(reply: FastifyReply, returnTo: string, fragment: string) {
reply.clearCookie(PENDING_COOKIE, { path: '/' })
return reply.redirect(`${appBaseUrl()}${returnTo}#${fragment}`, 302)
}
export const oidcRoutes: FastifyPluginAsync = async (app) => {
if (!isOidcEnabled()) return
const secureCookie = new URL(env.OIDC_REDIRECT_URI).protocol === 'https:'
app.get(
'/start',
{ config: { rateLimit: { max: 20, timeWindow: '1 minute' } } },
async (request, reply) => {
const query = (request.query as Record<string, unknown> | undefined) ?? {}
// Ticket de rattachement : vérifié et consommé ici, le compte voyage dans le cookie signé
let linkUserId: string | undefined
if (typeof query.link === 'string') {
let ticket: LinkTicketPayload | null = null
try { ticket = app.jwt.verify<LinkTicketPayload>(query.link) } catch { ticket = null }
if (!ticket || ticket.kind !== 'oidc_link' || typeof ticket.exp !== 'number' || !consumeLinkTicket(ticket.jti, ticket.exp)) {
return redirectToApp(reply, '/', 'oidc_error=oidc_link_expired')
}
linkUserId = ticket.sub
}
const returnTo = linkUserId ? '/' : safeReturnTo(query.returnTo)
let start: Awaited<ReturnType<typeof startAuthorization>>
try {
start = await startAuthorization(returnTo)
} catch (error) {
request.log.error({ err: error }, 'OIDC discovery failed')
return redirectToApp(reply, returnTo, 'oidc_error=oidc_unavailable')
}
reply.setCookie(PENDING_COOKIE, JSON.stringify(linkUserId ? { ...start.pending, linkUserId } : start.pending), {
path: '/',
httpOnly: true,
sameSite: 'lax', // envoyé au retour du fournisseur, qui est une navigation de premier niveau
secure: secureCookie,
signed: true,
maxAge: PENDING_MAX_AGE_S,
})
return reply.redirect(start.url.href, 302)
},
)
app.get(
'/callback',
{ config: { rateLimit: { max: 20, timeWindow: '1 minute' } } },
async (request, reply) => {
const raw = request.cookies[PENDING_COOKIE]
const unsigned = raw ? request.unsignCookie(raw) : null
let pending: OidcPending | null = null
if (unsigned?.valid && unsigned.value) {
try {
const parsed = pendingSchema.safeParse(JSON.parse(unsigned.value))
pending = parsed.success ? parsed.data : null
} catch {
pending = null
}
}
if (!pending) {
// Pas de connexion commencée dans ce navigateur (cookie absent, expiré ou modifié)
return redirectToApp(reply, '/', 'oidc_error=oidc_failed')
}
const returnTo = safeReturnTo(pending.returnTo)
const query = request.url.includes('?') ? request.url.slice(request.url.indexOf('?')) : ''
let identity: OidcIdentity
try {
identity = await finishAuthorization(query, pending)
} catch (error) {
// Refus chez le fournisseur, state ou nonce faux, ID token invalide (signature, émetteur, audience, date)
request.log.warn({ err: error }, 'OIDC callback rejected')
return redirectToApp(reply, returnTo, 'oidc_error=oidc_failed')
}
let result: AccountResult | LinkResult
try {
result = pending.linkUserId ? await linkIdentity(app, pending.linkUserId, identity) : await resolveAccount(app, identity)
} catch (error) {
// Deux retours simultanés pour la même identité : le second retrouve le lien créé par le premier
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
result = pending.linkUserId ? await linkIdentity(app, pending.linkUserId, identity) : await resolveAccount(app, identity)
} else {
throw error
}
}
if ('error' in result) {
return redirectToApp(reply, returnTo, `oidc_error=${result.error}`)
}
// Invitations à co-gérer un arbre : acceptées sans le lien, l'adresse étant garantie par le fournisseur (et
// devant être celle du compte, voir claimInvitationsForVerifiedEmail)
if (identity.email && identity.emailVerified) {
await claimInvitationsForVerifiedEmail(app.prisma, result.userId, identity.email)
}
const handoff = app.jwt.sign(
{ kind: 'oidc_handoff', sub: result.userId, jti: randomUUID() },
{ expiresIn: `${HANDOFF_TTL_S}s` },
)
return redirectToApp(reply, returnTo, `oidc=${encodeURIComponent(handoff)}`)
},
)
// Ticket de rattachement pour le compte connecté (session par en-tête) : la navigation vers /start ne peut pas
// porter l'en-tête, le ticket le remplace, deux minutes et une seule fois
app.post(
'/link',
{ config: { rateLimit: { max: 10, timeWindow: '1 minute' } } },
async (request, reply) => {
if (!request.actor || request.actor.kind !== 'user') {
return reply.code(401).send({ error: 'authentication_required' })
}
const ticket = app.jwt.sign(
{ kind: 'oidc_link', sub: request.actor.userId, jti: randomUUID() },
{ expiresIn: `${LINK_TICKET_TTL_S}s` },
)
return reply.send({ ticket })
},
)
app.post(
'/exchange',
{ config: { rateLimit: { max: 20, timeWindow: '1 minute' } } },
async (request, reply) => {
const parsed = exchangeSchema.safeParse(request.body)
if (!parsed.success) {
return reply.code(400).send({ error: 'invalid_payload' })
}
let payload: HandoffPayload
try {
payload = app.jwt.verify<HandoffPayload>(parsed.data.code)
} catch {
return reply.code(401).send({ error: 'invalid_handoff' })
}
if (payload.kind !== 'oidc_handoff' || typeof payload.exp !== 'number' || !consumeHandoff(payload.jti, payload.exp)) {
return reply.code(401).send({ error: 'invalid_handoff' })
}
const user = await app.prisma.user.findUnique({
where: { id: payload.sub },
select: { id: true, email: true, firstName: true, passwordHash: true },
})
if (!user) {
return reply.code(401).send({ error: 'invalid_handoff' })
}
return reply.send({
token: signUserSession(app, user),
user: { id: user.id, email: user.email, firstName: user.firstName, hasPassword: confirmsWithPassword(user.passwordHash) },
})
},
)
}
+59
View File
@@ -0,0 +1,59 @@
import type { FastifyPluginAsync } from 'fastify'
import { z } from 'zod'
import { canReadTree } from '../lib/tree-access.js'
import { enabledSearchSources, searchSource, SearchSourceUnavailableError } from '../lib/search-sources.js'
// Panneau « Chercher quelqu'un » : le serveur interroge les sources allumées par l'opérateur
// (SEARCH_SOURCES) au nom d'une personne qui lit l'arbre. Rattaché à un arbre pour ne pas faire de
// l'instance un relais ouvert vers ces services ; rien n'est enregistré, seule la question part.
const paramsSchema = z.object({ id: z.string().min(1), source: z.string().min(1).max(40) })
const querySchema = z.object({
firstName: z.string().trim().min(1).max(80),
lastName: z.string().trim().min(1).max(80),
birthYear: z.coerce.number().int().min(1500).max(2100).optional(),
place: z.string().trim().max(120).optional(),
})
export const searchSourceRoutes: FastifyPluginAsync = async (app) => {
// Sources que le front peut proposer : rien d'autre que leurs identifiants. Sous /trees comme le reste :
// nginx ne relaie vers l'API que /api, /auth, /trees et /health
app.get('/trees/:id/search-sources', async (request, reply) => {
const params = z.object({ id: z.string().min(1) }).safeParse(request.params)
if (!params.success) return reply.code(400).send({ error: 'invalid_params' })
if (!request.actor) return reply.code(401).send({ error: 'authentication_required' })
if (!(await canReadTree(app, params.data.id, request.actor))) return reply.code(403).send({ error: 'forbidden' })
return reply.send({ sources: enabledSearchSources() })
})
app.post(
'/trees/:id/search-sources/:source',
{ config: { rateLimit: { max: 30, timeWindow: '1 minute' } } },
async (request, reply) => {
const params = paramsSchema.safeParse(request.params)
if (!params.success) return reply.code(400).send({ error: 'invalid_params' })
if (!request.actor) return reply.code(401).send({ error: 'authentication_required' })
if (!(await canReadTree(app, params.data.id, request.actor))) return reply.code(403).send({ error: 'forbidden' })
if (!enabledSearchSources().includes(params.data.source)) return reply.code(404).send({ error: 'source_not_enabled' })
const body = querySchema.safeParse(request.body)
if (!body.success) return reply.code(400).send({ error: 'invalid_query' })
try {
const results = await searchSource(params.data.source, body.data)
return reply.send({ results })
} catch (error) {
if (error instanceof SearchSourceUnavailableError) {
request.log.warn({ source: params.data.source, reason: error.message }, 'search source unavailable')
// Quota : l'opérateur peut y remédier (MATCHID_TOKEN), le front le dit autrement qu'une panne
return reply.code(502).send({ error: error.message === 'quota' ? 'source_quota' : 'source_unavailable' })
}
throw error
}
},
)
}
+445 -11
View File
@@ -1,7 +1,7 @@
import path from 'node:path'
import type { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'
import { Prisma } from '@prisma/client'
import { Prisma } from '../generated/prisma/client.js'
import { z } from 'zod'
import { env } from '../config/env.js'
@@ -11,9 +11,14 @@ import { removeStoredFile } from '../lib/media-files.js'
import { matchSharePassword } from '../lib/password-variants.js'
import { decryptSharePassword, encryptSharePassword } from '../lib/share-password.js'
import { purgeTrees } from '../lib/tree-purge.js'
import { nextSiblingOrder } from '../lib/sibling-order.js'
import { reserveTreeUnlockAttempt, serializePasswordCheck } from '../lib/unlock-guard.js'
import type { MembershipRole } from '../types/auth.js'
import { findParentChildValidationError, findUnionValidationError } from '../utils/relationship-guards.js'
import { personPrecisionResets, unionPrecisionResets } from '../lib/date-precision.js'
import { hasAnyName } from '../lib/person-names.js'
import { sexSchema } from '../lib/person-sex.js'
import { ContributionRouteError, resolveSubmissionContext } from './contributions.js'
const MAX_TREES_PER_ACCOUNT = 10
@@ -35,6 +40,8 @@ const patchTreeSchema = z
slug: z.string().min(1).max(128).optional(),
description: z.string().max(500).optional().nullable(),
rootPersonId: z.string().min(1).optional().nullable(),
// Un emoji (graphème composé compris), ou null pour revenir à la première lettre
icon: z.string().trim().max(16).regex(/^\p{Extended_Pictographic}/u).optional().nullable(),
})
.refine((value) => Object.keys(value).length > 0, {
message: 'at_least_one_field_required',
@@ -81,31 +88,40 @@ const listAuditQuerySchema = z.object({
const isoDateRegex = /^\d{4}-\d{2}-\d{2}$/
const createPersonSchema = z.object({
firstName: z.string().min(1).max(120),
lastName: z.string().min(1).max(120),
firstName: z.string().trim().max(120),
lastName: z.string().trim().max(120),
birthName: z.string().max(120).optional().nullable(),
nickname: z.string().max(120).optional().nullable(),
birthDate: z.string().regex(isoDateRegex).optional().nullable(),
deathDate: z.string().regex(isoDateRegex).optional().nullable(),
isDeceased: z.boolean().optional(),
toVerify: z.boolean().optional(),
birthPlace: z.string().max(200).optional().nullable(),
deathPlace: z.string().max(200).optional().nullable(),
profession: z.string().max(200).optional().nullable(),
region: z.string().max(120).optional().nullable(),
nationality: z.string().max(120).optional().nullable(),
sex: z.string().max(20).optional().nullable(),
sex: sexSchema,
notes: z.string().max(2000).optional().nullable(),
})
}).refine((value) => hasAnyName(value.firstName, value.lastName), { message: 'name_required', path: ['lastName'] })
const patchPersonSchema = z
.object({
firstName: z.string().min(1).max(120).optional(),
lastName: z.string().min(1).max(120).optional(),
firstName: z.string().trim().max(120).optional(),
lastName: z.string().trim().max(120).optional(),
birthName: z.string().max(120).optional().nullable(),
nickname: z.string().max(120).optional().nullable(),
birthDate: z.string().regex(isoDateRegex).optional().nullable(),
deathDate: z.string().regex(isoDateRegex).optional().nullable(),
isDeceased: z.boolean().optional(),
// « À vérifier » : la famille n'est pas sûre de cette personne (retour d'Anaïs, 23/10)
toVerify: z.boolean().optional(),
birthPlace: z.string().max(200).optional().nullable(),
deathPlace: z.string().max(200).optional().nullable(),
profession: z.string().max(200).optional().nullable(),
region: z.string().max(120).optional().nullable(),
nationality: z.string().max(120).optional().nullable(),
sex: z.string().max(20).optional().nullable(),
sex: sexSchema,
notes: z.string().max(2000).optional().nullable(),
})
.refine((value) => Object.keys(value).length > 0, {
@@ -127,6 +143,9 @@ const patchUnionSchema = z
partner2PersonId: z.string().min(1).optional().nullable(),
unionType: z.string().max(50).optional().nullable(),
startDate: z.string().regex(isoDateRegex).optional().nullable(),
// Lieu et précision de la date du couple, saisis dans l'onglet Vie (« vers 1980 »)
startPlace: z.string().trim().max(300).optional().nullable(),
startDatePrecision: z.enum(['about', 'estimated', 'calculated', 'before', 'after']).optional().nullable(),
endDate: z.string().regex(isoDateRegex).optional().nullable(),
displayOrder: z.number().int().positive().optional(),
})
@@ -154,6 +173,54 @@ const patchParentChildLinkSchema = z
message: 'at_least_one_field_required',
})
// Moments de l'onglet Vie (« Ajouter un moment »). Un administrateur ajoute, modifie, supprime ; la famille
// propose, et son moment attend la relecture comme un souvenir. Naissance, décès et mariage gardent leur date
// et leur lieu sur la personne ou l'union : leur ligne ne porte que des sources, une note et des souvenirs.
const LIFE_EVENT_PRECISIONS = ['about', 'estimated', 'calculated', 'before', 'after', 'between', 'from', 'to', 'interpreted'] as const
const RESERVED_LIFE_EVENT_TYPES = new Set(['birth', 'death', 'marriage'])
const lifeEventDateRegex = /^\d{4}(-\d{2}-\d{2})?$/
const lifeEventPayloadSchema = z.object({
type: z.string().trim().min(1).max(40).regex(/^[a-z_]+$/).default('custom'),
unionId: z.string().min(1).nullable().optional(),
label: z.string().trim().max(120).nullable().optional(),
description: z.string().trim().max(1000).nullable().optional(),
date: z.string().regex(lifeEventDateRegex).nullable().optional(),
dateEnd: z.string().regex(lifeEventDateRegex).nullable().optional(),
datePrecision: z.enum(LIFE_EVENT_PRECISIONS).nullable().optional(),
place: z.string().trim().max(300).nullable().optional(),
notes: z.string().trim().max(4000).nullable().optional(),
sources: z.string().trim().max(4000).nullable().optional(),
// Souvenirs déjà dans la fiche à rattacher au moment (les nouveaux fichiers s'envoient ensuite avec lifeEventId)
mediaIds: z.array(z.string().min(1)).max(50).optional(),
submittedByLabel: z.string().trim().min(1).max(120).optional(),
})
const lifeEventPatchSchema = lifeEventPayloadSchema.omit({ type: true, unionId: true, submittedByLabel: true }).partial()
const lifeEventParamsSchema = z.object({ id: z.string().min(1), eventId: z.string().min(1) })
const lifeEventDate = (value: string | null | undefined) => {
if (value === undefined) return undefined
if (!value) return null
return new Date(`${value.length === 4 ? `${value}-01-01` : value}T00:00:00.000Z`)
}
const emptyToNull = (value: string | null | undefined) => (value === undefined ? undefined : value?.trim() ? value.trim() : null)
function lifeEventData(payload: z.infer<typeof lifeEventPatchSchema>, reserved: boolean) {
const common = { notes: emptyToNull(payload.notes), sources: emptyToNull(payload.sources) }
// Naissance, décès, mariage : rien d'autre que les sources et la note
if (reserved) return common
return {
...common,
label: emptyToNull(payload.label),
description: emptyToNull(payload.description),
date: lifeEventDate(payload.date),
dateEnd: lifeEventDate(payload.dateEnd),
datePrecision: payload.datePrecision,
// Saisi dans l'app : pas de texte GEDCOM d'origine à garder
dateText: null,
place: emptyToNull(payload.place),
}
}
const personParamsSchema = z.object({
id: z.string().min(1),
personId: z.string().min(1),
@@ -169,6 +236,19 @@ const linkParamsSchema = z.object({
linkId: z.string().min(1),
})
// Ordre d'une fratrie : pour chaque enfant, dans l'ordre voulu, les liens qui le rattachent à ses parents
const childrenOrderSchema = z.object({
order: z
.array(
z.object({
childId: z.string().min(1),
linkIds: z.array(z.string().min(1)).min(1).max(4),
}),
)
.min(2)
.max(200),
})
function requireUser(request: FastifyRequest, reply: FastifyReply): { userId: string; email: string } | null {
if (!request.actor || request.actor.kind !== 'user') {
reply.code(401).send({ error: 'authentication_required' })
@@ -317,6 +397,7 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
slug: true,
name: true,
description: true,
icon: true,
rootPersonId: true,
createdAt: true,
updatedAt: true,
@@ -338,6 +419,7 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
slug: true,
name: true,
description: true,
icon: true,
rootPersonId: true,
createdAt: true,
updatedAt: true,
@@ -367,20 +449,36 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
slug: string
name: string
description: string | null
icon: string | null
rootPersonId: string | null
createdAt: Date
updatedAt: Date
role: string
accessMode: 'member' | 'share'
lastAccessedAt: Date | null
coManagers?: string[]
}> = []
// Co-gestionnaires des arbres possédés : l'écran de suppression du compte les nomme (l'arbre part avec le compte)
const ownedIds = memberships.filter((m: { role: string }) => m.role === 'owner').map((m: { tree: { id: string } }) => m.tree.id)
const coManagerRows = ownedIds.length
? await app.prisma.treeMembership.findMany({
where: { treeId: { in: ownedIds }, role: 'admin' },
select: { treeId: true, user: { select: { firstName: true, email: true } } },
})
: []
for (const membership of memberships) {
mergedTrees.push({
...membership.tree,
role: membership.role,
accessMode: 'member',
lastAccessedAt: null,
...(membership.role === 'owner' ? {
coManagers: coManagerRows
.filter((row: { treeId: string }) => row.treeId === membership.tree.id)
.map((row: { user: { firstName: string | null; email: string } }) => row.user.firstName || row.user.email),
} : {}),
})
}
@@ -575,6 +673,7 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
name?: string
slug?: string
description?: string | null
icon?: string | null
rootPersonId?: string | null
} = {}
@@ -603,6 +702,10 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
updateData.description = payload.data.description
}
if (payload.data.icon !== undefined) {
updateData.icon = payload.data.icon
}
if (payload.data.rootPersonId !== undefined) {
if (payload.data.rootPersonId === null) {
updateData.rootPersonId = null
@@ -1244,9 +1347,14 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
firstName: payload.data.firstName.trim(),
lastName: payload.data.lastName.trim(),
birthName: toTrimmedStringOrNull(payload.data.birthName),
nickname: toTrimmedStringOrNull(payload.data.nickname),
birthDate: toDateOrNull(payload.data.birthDate),
deathDate: toDateOrNull(payload.data.deathDate),
// Une date de décès dit déjà que la personne est décédée : le drapeau ne sert que sans date
isDeceased: !payload.data.deathDate && Boolean(payload.data.isDeceased),
toVerify: Boolean(payload.data.toVerify),
birthPlace: toTrimmedStringOrNull(payload.data.birthPlace),
deathPlace: toTrimmedStringOrNull(payload.data.deathPlace),
profession: toTrimmedStringOrNull(payload.data.profession),
region: toTrimmedStringOrNull(payload.data.region),
nationality: toTrimmedStringOrNull(payload.data.nationality),
@@ -1296,13 +1404,28 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
return reply.code(403).send({ error: 'forbidden' })
}
// Au moins un nom : on regarde le résultat de la modification, pas seulement ce qu'elle envoie
if (payload.data.firstName !== undefined || payload.data.lastName !== undefined) {
const before = await app.prisma.person.findFirst({
where: { id: params.data.personId, treeId: params.data.id, deletedAt: null },
select: { firstName: true, lastName: true },
})
if (before && !hasAnyName(payload.data.firstName ?? before.firstName, payload.data.lastName ?? before.lastName)) {
return reply.code(400).send({ error: 'invalid_payload', details: 'name_required' })
}
}
const data: {
firstName?: string
lastName?: string
birthName?: string | null
nickname?: string | null
birthDate?: Date | null
deathDate?: Date | null
isDeceased?: boolean
toVerify?: boolean
birthPlace?: string | null
deathPlace?: string | null
profession?: string | null
region?: string | null
nationality?: string | null
@@ -1323,6 +1446,10 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
data.birthName = toTrimmedStringOrNull(payload.data.birthName)
}
if (payload.data.nickname !== undefined) {
data.nickname = toTrimmedStringOrNull(payload.data.nickname)
}
if (payload.data.birthDate !== undefined) {
data.birthDate = toDateOrNull(payload.data.birthDate)
}
@@ -1331,10 +1458,26 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
data.deathDate = toDateOrNull(payload.data.deathDate)
}
if (payload.data.isDeceased !== undefined) {
data.isDeceased = payload.data.isDeceased
}
// Avec une date de décès, le drapeau « sans date » n'a plus lieu d'être
if (data.deathDate) {
data.isDeceased = false
}
if (payload.data.toVerify !== undefined) {
data.toVerify = payload.data.toVerify
}
if (payload.data.birthPlace !== undefined) {
data.birthPlace = toTrimmedStringOrNull(payload.data.birthPlace)
}
if (payload.data.deathPlace !== undefined) {
data.deathPlace = toTrimmedStringOrNull(payload.data.deathPlace)
}
if (payload.data.profession !== undefined) {
data.profession = toTrimmedStringOrNull(payload.data.profession)
}
@@ -1357,13 +1500,21 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
data.updatedBy = user.userId
const datesChanged = data.birthDate !== undefined || data.deathDate !== undefined
const current = datesChanged
? await app.prisma.person.findFirst({
where: { id: params.data.personId, treeId: params.data.id, deletedAt: null },
select: { birthDate: true, deathDate: true },
})
: null
const updated = await app.prisma.person.updateMany({
where: {
id: params.data.personId,
treeId: params.data.id,
deletedAt: null,
},
data,
data: { ...data, ...personPrecisionResets(current, data) },
})
if (updated.count === 0) {
@@ -1572,6 +1723,7 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
select: {
partner1PersonId: true,
partner2PersonId: true,
startDate: true,
},
})
@@ -1622,6 +1774,7 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
return reply.code(400).send({ error: unionValidationError })
}
const startDate = payload.data.startDate !== undefined ? toDateOrNull(payload.data.startDate) : undefined
const updated = await app.prisma.union.updateMany({
where: {
id: params.data.unionId,
@@ -1629,10 +1782,13 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
deletedAt: null,
},
data: {
...unionPrecisionResets(existingUnion, { startDate }),
...(payload.data.startDatePrecision !== undefined ? { startDatePrecision: payload.data.startDatePrecision } : {}),
startPlace: payload.data.startPlace !== undefined ? payload.data.startPlace || null : undefined,
partner1PersonId: payload.data.partner1PersonId,
partner2PersonId: payload.data.partner2PersonId,
unionType: payload.data.unionType,
startDate: payload.data.startDate !== undefined ? toDateOrNull(payload.data.startDate) : undefined,
startDate,
endDate: payload.data.endDate !== undefined ? toDateOrNull(payload.data.endDate) : undefined,
displayOrder: payload.data.displayOrder,
},
@@ -1804,7 +1960,7 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
childPersonId: payload.data.childPersonId,
viaUnionId: payload.data.viaUnionId ?? null,
parentageType: payload.data.parentageType ?? 'biologique',
displayOrder: payload.data.displayOrder ?? 1,
displayOrder: payload.data.displayOrder ?? await nextSiblingOrder(app.prisma, params.data.id, payload.data.parentPersonId),
},
})
@@ -1979,6 +2135,284 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
return reply.send({ link })
})
// Souvenirs rattachables : approuvés, d'une de ces personnes, dans cet arbre. La famille ne prend
// que des souvenirs libres : elle ne détache pas un souvenir du moment d'un autre.
const validLifeEventMedia = async (treeId: string, ownerIds: string[], mediaIds: string[] | undefined, onlyFree = false) => {
if (!mediaIds?.length) return true
const unique = [...new Set(mediaIds)]
const count = await app.prisma.mediaItem.count({
where: { id: { in: unique }, treeId, personId: { in: ownerIds }, status: 'approved', deletedAt: null, ...(onlyFree ? { lifeEventId: null } : {}) },
})
return count === unique.length
}
// Rattache exactement ces souvenirs au moment (et détache ceux qui ne sont plus dans la liste)
const setLifeEventMedia = async (db: Prisma.TransactionClient, treeId: string, eventId: string, mediaIds: string[] | undefined) => {
if (mediaIds === undefined) return
const unique = [...new Set(mediaIds)]
await db.mediaItem.updateMany({ where: { treeId, lifeEventId: eventId, id: { notIn: unique } }, data: { lifeEventId: null } })
if (unique.length) await db.mediaItem.updateMany({ where: { treeId, id: { in: unique } }, data: { lifeEventId: eventId } })
}
app.post('/trees/:id/persons/:personId/life/events', async (request, reply) => {
const actor = request.actor
if (!actor) return reply.code(401).send({ error: 'authentication_required' })
const params = personParamsSchema.safeParse(request.params)
if (!params.success) return reply.code(400).send({ error: 'invalid_params' })
const payload = lifeEventPayloadSchema.safeParse(request.body ?? {})
if (!payload.success) return reply.code(400).send({ error: 'invalid_payload', details: payload.error.flatten() })
const reserved = RESERVED_LIFE_EVENT_TYPES.has(payload.data.type)
if (!reserved && payload.data.type === 'custom' && !payload.data.label?.trim() && !payload.data.description?.trim()) {
return reply.code(400).send({ error: 'invalid_payload' })
}
// Administrateur : visible tout de suite. Famille : une contribution relue, sauf si l'arbre applique tout de suite
const membership = actor.kind === 'user' ? await getAdminMembership(app, actor.userId, params.data.id) : null
const isAdmin = hasActiveAdminMembership(membership)
let contribution: Awaited<ReturnType<typeof resolveSubmissionContext>> | null = null
if (!isAdmin) {
// Les sources d'une naissance ou d'un mariage restent l'affaire de l'administrateur
if (reserved) return reply.code(403).send({ error: 'forbidden' })
try {
contribution = await resolveSubmissionContext(app, actor, params.data.id)
} catch (error) {
if (error instanceof ContributionRouteError) return reply.code(error.statusCode).send({ error: error.code })
throw error
}
if (contribution.autoStatus === 'pending') {
const pendingCount = await app.prisma.contributionSession.count({ where: { treeId: params.data.id, status: 'pending' } })
if (pendingCount >= 200) return reply.code(429).send({ error: 'too_many_pending_contributions' })
}
}
const person = await app.prisma.person.findFirst({
where: { id: params.data.personId, treeId: params.data.id, deletedAt: null },
select: { id: true, firstName: true, lastName: true },
})
if (!person) return reply.code(404).send({ error: 'person_not_found' })
// Naissance et décès : une seule ligne par personne ; mariage : une par union de cette personne
let unionId: string | null = null
let ownerIds = [person.id]
if (reserved) {
if (payload.data.type === 'marriage') {
const union = payload.data.unionId
? await app.prisma.union.findFirst({
where: { id: payload.data.unionId, treeId: params.data.id, deletedAt: null, OR: [{ partner1PersonId: person.id }, { partner2PersonId: person.id }] },
select: { id: true, partner1PersonId: true, partner2PersonId: true },
})
: null
if (!union) return reply.code(400).send({ error: 'invalid_union_reference' })
unionId = union.id
ownerIds = [union.partner1PersonId, union.partner2PersonId].filter((id): id is string => Boolean(id))
}
const existing = await app.prisma.lifeEvent.findFirst({
where: { treeId: params.data.id, deletedAt: null, type: payload.data.type, ...(unionId ? { unionId } : { personId: person.id }) },
select: { id: true },
})
if (existing) return reply.code(409).send({ error: 'event_exists', eventId: existing.id })
}
if (!(await validLifeEventMedia(params.data.id, ownerIds, payload.data.mediaIds, !isAdmin))) {
return reply.code(400).send({ error: 'invalid_media_reference' })
}
const isPending = contribution?.autoStatus === 'pending'
const title = payload.data.label?.trim() || payload.data.description?.trim() || payload.data.type
// Session, moment, souvenirs et trace de la contribution : tout ou rien
const event = await app.prisma.$transaction(async (tx) => {
const session = contribution
? await tx.contributionSession.create({
data: {
treeId: params.data.id,
submittedByUserId: contribution.submittedByUserId,
submittedByLabel: payload.data.submittedByLabel?.trim() || contribution.submittedByLabel,
mode: contribution.mode,
status: contribution.autoStatus,
title: `Moment pour ${person.firstName || person.lastName}`.slice(0, 120),
reviewedAt: isPending ? undefined : new Date(),
reviewedBy: isPending ? undefined : contribution.submittedByUserId,
},
})
: null
const event = await tx.lifeEvent.create({
data: {
treeId: params.data.id,
personId: unionId ? null : person.id,
unionId,
type: payload.data.type,
...lifeEventData(payload.data, reserved),
status: isPending ? 'pending' : 'approved',
contributionSessionId: session?.id ?? null,
},
})
await setLifeEventMedia(tx, params.data.id, event.id, payload.data.mediaIds)
if (session) {
await tx.contributionChange.create({
data: {
sessionId: session.id,
entityType: 'life_event',
entityId: event.id,
action: 'create',
afterJson: {
personId: person.id, type: event.type, title, date: payload.data.date ?? null, dateEnd: payload.data.dateEnd ?? null,
datePrecision: payload.data.datePrecision ?? null, place: event.place, notes: event.notes, sources: event.sources,
},
decision: isPending ? undefined : 'approved',
},
})
}
return event
})
return reply.code(201).send({ event: { id: event.id, status: event.status } })
})
app.patch('/trees/:id/life/events/:eventId', async (request, reply) => {
const user = requireUser(request, reply)
if (!user) return
const params = lifeEventParamsSchema.safeParse(request.params)
if (!params.success) return reply.code(400).send({ error: 'invalid_params' })
const payload = lifeEventPatchSchema.safeParse(request.body ?? {})
if (!payload.success) return reply.code(400).send({ error: 'invalid_payload', details: payload.error.flatten() })
const membership = await getAdminMembership(app, user.userId, params.data.id)
if (!hasActiveAdminMembership(membership)) return reply.code(403).send({ error: 'forbidden' })
const existing = await app.prisma.lifeEvent.findFirst({
where: { id: params.data.eventId, treeId: params.data.id, deletedAt: null, status: 'approved' },
select: { personId: true, unionId: true, type: true },
})
if (!existing) return reply.code(404).send({ error: 'event_not_found' })
// Souvenirs d'une ligne de mariage : ceux de l'un ou l'autre conjoint
let ownerIds: string[] = existing.personId ? [existing.personId] : []
if (existing.unionId) {
const union = await app.prisma.union.findFirst({ where: { id: existing.unionId, treeId: params.data.id }, select: { partner1PersonId: true, partner2PersonId: true } })
ownerIds = [union?.partner1PersonId, union?.partner2PersonId].filter((id): id is string => Boolean(id))
}
if (!(await validLifeEventMedia(params.data.id, ownerIds, payload.data.mediaIds))) {
return reply.code(400).send({ error: 'invalid_media_reference' })
}
await app.prisma.$transaction(async (tx) => {
await tx.lifeEvent.update({
where: { id: params.data.eventId },
data: lifeEventData(payload.data, RESERVED_LIFE_EVENT_TYPES.has(existing.type)),
})
await setLifeEventMedia(tx, params.data.id, params.data.eventId, payload.data.mediaIds)
})
return reply.send({ ok: true })
})
app.delete('/trees/:id/life/events/:eventId', async (request, reply) => {
const user = requireUser(request, reply)
if (!user) return
const params = lifeEventParamsSchema.safeParse(request.params)
if (!params.success) return reply.code(400).send({ error: 'invalid_params' })
const membership = await getAdminMembership(app, user.userId, params.data.id)
if (!hasActiveAdminMembership(membership)) return reply.code(403).send({ error: 'forbidden' })
const deletedCount = await app.prisma.$transaction(async (tx) => {
const deleted = await tx.lifeEvent.updateMany({
where: { id: params.data.eventId, treeId: params.data.id, deletedAt: null, personId: { not: null }, type: { notIn: [...RESERVED_LIFE_EVENT_TYPES] } },
data: { deletedAt: new Date() },
})
// Les souvenirs restent dans la fiche, simplement détachés du moment
if (deleted.count > 0) await tx.mediaItem.updateMany({ where: { treeId: params.data.id, lifeEventId: params.data.eventId }, data: { lifeEventId: null } })
return deleted.count
})
if (deletedCount === 0) return reply.code(404).send({ error: 'event_not_found' })
return reply.code(204).send()
})
app.put('/trees/:id/links/order', async (request, reply) => {
const user = requireUser(request, reply)
if (!user) {
return
}
const params = treeIdParamSchema.safeParse(request.params)
if (!params.success) {
return reply.code(400).send({ error: 'invalid_params' })
}
const payload = childrenOrderSchema.safeParse(request.body)
if (!payload.success) {
return reply.code(400).send({ error: 'invalid_payload', details: payload.error.flatten() })
}
const membership = await getAdminMembership(app, user.userId, params.data.id)
if (!hasActiveAdminMembership(membership)) {
return reply.code(403).send({ error: 'forbidden' })
}
const childIds = payload.data.order.map((entry) => entry.childId)
const linkIds = payload.data.order.flatMap((entry) => entry.linkIds)
if (new Set(childIds).size !== childIds.length || new Set(linkIds).size !== linkIds.length) {
return reply.code(400).send({ error: 'invalid_payload' })
}
const links = await app.prisma.parentChildLink.findMany({
where: { id: { in: linkIds }, treeId: params.data.id, deletedAt: null },
select: { id: true, parentPersonId: true, childPersonId: true, viaUnionId: true },
})
const linkById = new Map(links.map((link) => [link.id, link]))
if (links.length !== linkIds.length) {
return reply.code(404).send({ error: 'link_not_found' })
}
// Chaque lien appartient bien à son enfant, et les enfants partagent au moins un parent
const parentSets: Set<string>[] = []
for (const entry of payload.data.order) {
const parents = new Set<string>()
for (const linkId of entry.linkIds) {
const link = linkById.get(linkId)
if (!link || link.childPersonId !== entry.childId) {
return reply.code(400).send({ error: 'invalid_link_reference' })
}
parents.add(link.parentPersonId)
}
parentSets.push(parents)
}
const hasSharedParent = [...parentSets[0]].some((parentId) => parentSets.every((parents) => parents.has(parentId)))
if (!hasSharedParent) {
return reply.code(400).send({ error: 'not_siblings' })
}
// L'arbre ne montre qu'un lien par enfant et par union : le lien vers l'autre parent de la même union
// prend le même ordre, pour que les deux restent d'accord
await app.prisma.$transaction(
payload.data.order.map((entry, index) => {
const unionIds = [...new Set(entry.linkIds.map((id) => linkById.get(id)?.viaUnionId).filter((id): id is string => Boolean(id)))]
return app.prisma.parentChildLink.updateMany({
where: {
treeId: params.data.id,
deletedAt: null,
childPersonId: entry.childId,
OR: [{ id: { in: entry.linkIds } }, ...(unionIds.length ? [{ viaUnionId: { in: unionIds } }] : [])],
},
data: { displayOrder: index + 1 },
})
}),
)
await app.prisma.auditLog.create({
data: {
treeId: params.data.id,
actorType: 'user',
actorId: user.userId,
action: 'children_reordered',
entityType: 'parent_child_link',
entityId: payload.data.order[0].linkIds[0],
payloadJson: { childIds },
},
})
return reply.send({ ok: true })
})
app.delete('/trees/:id/links/:linkId', async (request, reply) => {
const user = requireUser(request, reply)
if (!user) {
+16 -1
View File
@@ -28,7 +28,22 @@ export type MediaJwtPayload = {
accessVersion?: number
}
export type AnyJwtPayload = UserJwtPayload | TreeAccessJwtPayload | MediaJwtPayload
// Passage d'une connexion OIDC à l'app (60 s, à usage unique, échangé sur /auth/oidc/exchange) : jamais une session
export type OidcHandoffJwtPayload = {
kind: 'oidc_handoff'
sub: string
jti: string
}
// Ticket de rattachement d'une identité OIDC au compte connecté (120 s, à usage unique, porté par /auth/oidc/start) :
// jamais une session, le décodeur de l'en-tête ne retient que les sortes user et tree_access
export type OidcLinkJwtPayload = {
kind: 'oidc_link'
sub: string
jti: string
}
export type AnyJwtPayload = UserJwtPayload | TreeAccessJwtPayload | MediaJwtPayload | OidcHandoffJwtPayload | OidcLinkJwtPayload
export type UserActor = {
kind: 'user'
+1 -1
View File
@@ -1,4 +1,4 @@
import type { PrismaClient } from '@prisma/client'
import type { PrismaClient } from '../generated/prisma/client.js'
import type { Actor, AnyJwtPayload } from './auth.js'
declare module '@fastify/jwt' {
+34 -2
View File
@@ -1,6 +1,9 @@
import type { Annotation, MediaItem, ParentChildLink, Person, Union } from '@prisma/client'
import type { Annotation, MediaItem, MediaPersonTag, ParentChildLink, Person, Union } from '../generated/prisma/client.js'
import { sanitizeAnnotationContentForRead } from '../lib/annotation-content.js'
import { readAvatarCrop, type AvatarCrop } from '../lib/avatar-crop.js'
import { isImmichAvatarPath } from '../lib/media-files.js'
import { sexOrNull } from '../lib/person-sex.js'
export type GraphPayload = {
persons: Array<{
@@ -8,15 +11,22 @@ export type GraphPayload = {
firstName: string
lastName: string
birthName: string | null
nickname: string | null
birthDate: string | null
deathDate: string | null
isDeceased: boolean
toVerify: boolean
birthPlace: string | null
deathPlace: string | null
profession: string | null
region: string | null
nationality: string | null
avatarUrlPath: string | null
sex: string | null
notes: string | null
avatarFromImmich: boolean
avatarCrop: AvatarCrop | null
avatarBlurhash: string | null
}>
unions: Array<{
id: string
@@ -45,6 +55,9 @@ export type GraphPayload = {
source: string | null
displayOrder: number
isFeatured: boolean
blurhash: string | null
// Personnes identifiées sur le souvenir, point facultatif sur le visage (0 à 1)
people: Array<{ personId: string; x: number | null; y: number | null }>
}>
annotations: Array<{
id: string
@@ -73,7 +86,17 @@ export function buildGraphPayload(
links: ParentChildLink[],
medias: MediaItem[] = [],
annotations: Annotation[] = [],
mediaPeople: Pick<MediaPersonTag, 'mediaId' | 'personId' | 'x' | 'y'>[] = [],
): GraphPayload {
// Une personne supprimée n'est plus montrée sur les photos où elle était identifiée
const livingIds = new Set(persons.map((person) => person.id))
const peopleByMedia = new Map<string, Array<{ personId: string; x: number | null; y: number | null }>>()
for (const tag of mediaPeople) {
if (!livingIds.has(tag.personId)) continue
const list = peopleByMedia.get(tag.mediaId) ?? []
list.push({ personId: tag.personId, x: tag.x, y: tag.y })
peopleByMedia.set(tag.mediaId, list)
}
// Keep DB order from route queries to preserve sibling and union ordering semantics.
return {
persons: persons.map((person) => ({
@@ -81,15 +104,22 @@ export function buildGraphPayload(
firstName: person.firstName,
lastName: person.lastName,
birthName: person.birthName,
nickname: person.nickname,
birthDate: formatDate(person.birthDate),
deathDate: formatDate(person.deathDate),
isDeceased: Boolean(person.isDeceased) && !person.deathDate,
toVerify: Boolean(person.toVerify),
birthPlace: person.birthPlace,
deathPlace: person.deathPlace,
profession: person.profession,
region: person.region,
nationality: person.nationality,
avatarUrlPath: person.avatarPath ? `/trees/${treeId}/persons/${person.id}/avatar` : null,
sex: person.sex,
sex: sexOrNull(person.sex),
notes: person.notes,
avatarFromImmich: isImmichAvatarPath(person.avatarPath),
avatarCrop: person.avatarPath ? readAvatarCrop(person.avatarCrop) : null,
avatarBlurhash: (person.avatarPath && person.avatarBlurhash) || null,
})),
unions: unions.map((union) => ({
id: union.id,
@@ -118,6 +148,8 @@ export function buildGraphPayload(
source: media.source,
displayOrder: media.displayOrder,
isFeatured: media.isFeatured,
blurhash: media.blurhash || null,
people: peopleByMedia.get(media.id) ?? [],
})),
annotations: annotations.map((ann) => ({
id: ann.id,
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Prisma, PrismaClient } from '@prisma/client'
import type { Prisma, PrismaClient } from '../generated/prisma/client.js'
type RelationReader = Pick<PrismaClient, 'parentChildLink' | 'union'>
+95
View File
@@ -0,0 +1,95 @@
import { mkdtempSync, readFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import type { FastifyInstance } from 'fastify'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
// Aperçus flous (lib/blurhash.ts), sur une vraie base : calculés à l'envoi d'une photo et d'un portrait,
// publiés dans le graphe, et rattrapés en tâche de fond pour ce qui n'en a pas (photos d'avant la migration,
// imports). Lancé seulement avec TEST_DATABASE_URL (base jetable, migrée, en UTF-8) :
// TEST_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/genealogy_test npx vitest run test/apercus-flous.db.test.ts
const TEST_DB = process.env.TEST_DATABASE_URL
let app: FastifyInstance
let env: { ALLOW_REGISTRATION: boolean; MEDIA_STORAGE_PATH: string }
let ip = 0
const nextIp = () => `10.4.${Math.floor(++ip / 250)}.${ip % 250 + 1}`
const run = Date.now().toString(36)
const storage = mkdtempSync(path.join(tmpdir(), 'pth-apercus-'))
const PHOTO_BYTES = readFileSync(fileURLToPath(new URL('../../frontend/public/demo/souvenir-mariage-1918.webp', import.meta.url)))
const PHOTO = PHOTO_BYTES.toString('base64')
async function call(method: string, url: string, token?: string, payload?: unknown) {
const res = await app.inject({
method: method as 'GET',
url,
payload: payload as Record<string, unknown>,
headers: token ? { authorization: `Bearer ${token}` } : {},
remoteAddress: nextIp(),
})
let body = null
try { body = res.body ? res.json() : null } catch { body = null }
return { status: res.statusCode, body }
}
describe.skipIf(!TEST_DB)('aperçus flous, vraie base', () => {
let token: string
let treeId: string
let personId: string
beforeAll(async () => {
process.env.NODE_ENV = 'test'
process.env.DATABASE_URL = TEST_DB
process.env.JWT_SECRET = 'test-secret-with-sufficient-length'
process.env.CORS_ORIGIN = 'http://localhost:5173'
process.env.MEDIA_STORAGE_PATH = storage
;({ env } = await import('../src/config/env.js'))
const { createApp } = await import('../src/app.js')
app = createApp()
env.ALLOW_REGISTRATION = true
env.MEDIA_STORAGE_PATH = storage
const owner = await call('POST', '/auth/register', undefined, { email: `apercus-${run}@exemple.fr`, password: 'mot-de-passe-solide', firstName: 'Patch' })
expect(owner.status).toBe(201)
token = owner.body.token
const tree = await call('POST', '/trees', token, { name: 'Aperçus', slug: `apercus-${run}`, visitorPassword: 'partage-demo-1', contributorPassword: 'partage-demo-1' })
treeId = tree.body.tree?.id ?? tree.body.id
const person = await call('POST', `/trees/${treeId}/persons`, token, { firstName: 'Odette', lastName: 'Duval' })
personId = person.body.person?.id ?? person.body.id
})
afterAll(async () => {
await app?.close()
})
const graph = async () => (await call('GET', `/trees/${treeId}/graph`, token)).body
it('une photo et un portrait envoyés ont leur aperçu tout de suite, publié dans le graphe', async () => {
const media = await call('POST', `/trees/${treeId}/persons/${personId}/media`, token, { type: 'photo', fileName: 'mariage.webp', mimeType: 'image/webp', sizeBytes: PHOTO_BYTES.length, dataBase64: PHOTO })
expect(media.status).toBe(201)
const avatar = await call('POST', `/trees/${treeId}/persons/${personId}/avatar`, token, { fileName: 'portrait.webp', mimeType: 'image/webp', sizeBytes: PHOTO_BYTES.length, dataBase64: PHOTO })
expect(avatar.status).toBeLessThan(300)
const g = await graph()
expect(g.graph?.medias?.[0]?.blurhash ?? g.medias?.[0]?.blurhash).toMatch(/^[0-9A-Za-z#$%*+,-.:;=?@[\]^_{|}~]{28}$/)
const person = (g.graph?.persons ?? g.persons).find((p: { id: string }) => p.id === personId)
expect(person.avatarBlurhash).toHaveLength(28)
})
it('la tâche de fond rattrape une photo et un portrait sans aperçu', async () => {
await app.prisma.mediaItem.updateMany({ where: { treeId }, data: { blurhash: null } })
await app.prisma.person.updateMany({ where: { id: personId }, data: { avatarBlurhash: null } })
const { fillMissingBlurhashes } = await import('../src/lib/blurhash.js')
const done = await fillMissingBlurhashes(app, path.resolve(storage))
expect(done).toBeGreaterThanOrEqual(2)
expect((await app.prisma.mediaItem.findFirst({ where: { treeId } }))?.blurhash).toHaveLength(28)
expect((await app.prisma.person.findUnique({ where: { id: personId } }))?.avatarBlurhash).toHaveLength(28)
})
it('un fichier illisible reçoit un aperçu vide, pour ne pas être relu à chaque passage', async () => {
const media = await app.prisma.mediaItem.findFirst({ where: { treeId } })
await app.prisma.mediaItem.update({ where: { id: media!.id }, data: { blurhash: null, filePath: `${treeId}/absent.webp` } })
const { fillMissingBlurhashes } = await import('../src/lib/blurhash.js')
await fillMissingBlurhashes(app, path.resolve(storage))
expect((await app.prisma.mediaItem.findUnique({ where: { id: media!.id } }))?.blurhash).toBe('')
})
})
+539
View File
@@ -1,9 +1,18 @@
import { randomUUID } from 'node:crypto'
import type { FastifyInstance } from 'fastify'
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { adoptedFamilies, familyExtras, personExtras } from '../src/lib/gedcom-events'
import { indexGedcomRecords } from '../src/lib/gedcom-photos'
import { parseGedcom } from '../src/routes/gedcom'
const { prismaMock } = vi.hoisted(() => ({
prismaMock: {
// Sessions fermées (lib/token-revocation.ts), rechargées au démarrage
revokedSession: {
upsert: vi.fn().mockResolvedValue({}),
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
user: {
findUnique: vi.fn(),
count: vi.fn().mockResolvedValue(1),
@@ -67,8 +76,19 @@ const { prismaMock } = vi.hoisted(() => ({
update: vi.fn(),
updateMany: vi.fn(),
},
lifeEvent: {
findFirst: vi.fn(),
create: vi.fn(),
update: vi.fn(),
findMany: vi.fn().mockResolvedValue([]),
createMany: vi.fn().mockResolvedValue({ count: 0 }),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
annotation: {
findMany: vi.fn(),
count: vi.fn().mockResolvedValue(0),
create: vi.fn(),
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
},
contributionSession: {
create: vi.fn(),
@@ -81,6 +101,11 @@ const { prismaMock } = vi.hoisted(() => ({
create: vi.fn(),
update: vi.fn(),
},
mediaPersonTag: {
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
createMany: vi.fn().mockResolvedValue({ count: 0 }),
},
auditLog: {
create: vi.fn(),
findMany: vi.fn(),
@@ -419,6 +444,112 @@ describe('Graph routes', () => {
await app.close()
})
it('frise de vie : lisible avec un accès à l’arbre, refusée pour un autre arbre, 404 pour une personne inconnue', async () => {
const app = createApp()
await app.ready()
const sign = (treeId: string) => (app as any).jwt.sign({
kind: 'tree_access',
sub: `tree:${treeId}:visitor`,
treeId,
role: 'visitor',
accessVersion: new Date('2026-02-11T10:00:00.000Z').getTime(),
})
prismaMock.tree.findFirst.mockResolvedValue({ id: 'tree-1', rootPersonId: null })
prismaMock.person.findFirst.mockResolvedValue({
id: 'p-1', birthDatePrecision: 'about', birthDateEnd: null, deathDatePrecision: null, deathDateEnd: null,
deathPlace: 'Nantes, France', nickname: 'Loulou',
})
prismaMock.union.findMany.mockResolvedValue([
{ id: 'u-1', partner1PersonId: 'p-1', partner2PersonId: 'p-2', startDate: new Date('1905-01-01T00:00:00Z'), startPlace: 'Limoges', startDatePrecision: 'estimated', endDate: null },
])
prismaMock.lifeEvent.findMany.mockResolvedValue([
{ id: 'e-1', personId: 'p-1', unionId: null, type: 'baptism', label: null, date: new Date('1879-03-09T00:00:00Z'), dateEnd: null, datePrecision: null, dateText: null, place: 'La Roche-sur-Yon', description: null, notes: null, sources: 'Registre', displayOrder: 1 },
])
const ok = await app.inject({ method: 'GET', url: '/trees/tree-1/persons/p-1/life', headers: { authorization: `Bearer ${sign('tree-1')}` } })
expect(ok.statusCode).toBe(200)
expect(ok.json()).toMatchObject({
person: { birthDatePrecision: 'about', deathPlace: 'Nantes, France', nickname: 'Loulou' },
unions: [{ id: 'u-1', partnerId: 'p-2', startDate: '1905-01-01', startPlace: 'Limoges', startDatePrecision: 'estimated' }],
events: [{ id: 'e-1', type: 'baptism', date: '1879-03-09', place: 'La Roche-sur-Yon', sources: 'Registre' }],
})
const other = await app.inject({ method: 'GET', url: '/trees/tree-1/persons/p-1/life', headers: { authorization: `Bearer ${sign('tree-2')}` } })
expect(other.statusCode).toBe(403)
prismaMock.person.findFirst.mockResolvedValue(null)
const missing = await app.inject({ method: 'GET', url: '/trees/tree-1/persons/p-404/life', headers: { authorization: `Bearer ${sign('tree-1')}` } })
expect(missing.statusCode).toBe(404)
const anonymous = await app.inject({ method: 'GET', url: '/trees/tree-1/persons/p-1/life' })
expect(anonymous.statusCode).toBe(401)
await app.close()
})
it('export GEDCOM : précisions, événements, sources et adoption relus à l’identique', async () => {
const app = createApp()
await app.ready()
const token = (app as any).jwt.sign({
kind: 'tree_access',
sub: 'tree:tree-1:visitor',
treeId: 'tree-1',
role: 'visitor',
accessVersion: new Date('2026-02-11T10:00:00.000Z').getTime(),
})
const base = { birthDateEnd: null, deathDateEnd: null, deathDatePrecision: null, deathPlace: null, nickname: null, notes: null, profession: null, deathDate: null }
prismaMock.tree.findFirst.mockResolvedValue({ id: 'tree-1', name: 'Ragot', rootPersonId: null })
prismaMock.person.findMany.mockResolvedValue([
{ ...base, id: 'p-1', firstName: 'Louis', lastName: 'Ragot', sex: 'male', birthDate: new Date('1879-01-01T00:00:00Z'), birthDatePrecision: 'about', birthPlace: 'La Roche-sur-Yon', deathDate: new Date('1950-05-02T00:00:00Z'), deathPlace: 'Nantes', nickname: 'Loulou', profession: 'Instituteur' },
{ ...base, id: 'p-2', firstName: 'Marie', lastName: 'Bodin', sex: 'female', birthDate: null, birthDatePrecision: null, birthPlace: null, isDeceased: true },
{ ...base, id: 'p-3', firstName: 'Jean', lastName: 'Ragot', sex: 'M', birthDate: null, birthDatePrecision: null, birthPlace: null },
])
prismaMock.union.findMany.mockResolvedValue([
{ id: 'u-1', partner1PersonId: 'p-1', partner2PersonId: 'p-2', startDate: new Date('1905-01-01T00:00:00Z'), startDatePrecision: 'estimated', startPlace: 'Limoges', endDate: null },
])
prismaMock.parentChildLink.findMany.mockResolvedValue([
{ id: 'l-1', parentPersonId: 'p-1', childPersonId: 'p-3', viaUnionId: 'u-1', parentageType: 'adoption' },
{ id: 'l-2', parentPersonId: 'p-2', childPersonId: 'p-3', viaUnionId: 'u-1', parentageType: 'adoption' },
])
const event = { label: null, dateEnd: null, datePrecision: null, dateText: null, place: null, description: null, notes: null, sources: null, displayOrder: 1 }
prismaMock.lifeEvent.findMany.mockResolvedValue([
{ ...event, id: 'e-1', personId: 'p-1', unionId: null, type: 'birth', date: null, sources: 'Registre des naissances, Acte n° 12' },
{ ...event, id: 'e-2', personId: 'p-1', unionId: null, type: 'baptism', date: new Date('1879-03-09T00:00:00Z'), place: 'La Roche-sur-Yon' },
{ ...event, id: 'e-3', personId: 'p-1', unionId: null, type: 'occupation', date: new Date('1900-01-01T00:00:00Z'), dateEnd: new Date('1935-01-01T00:00:00Z'), datePrecision: 'from', description: 'Instituteur' },
{ ...event, id: 'e-4', personId: 'p-1', unionId: null, type: 'custom', label: 'Militaire', date: new Date('1914-01-01T00:00:00Z'), dateEnd: new Date('1918-01-01T00:00:00Z'), datePrecision: 'between', notes: 'Blessé à Verdun.\nRentré en 1919.' },
{ ...event, id: 'e-6', personId: null, unionId: 'u-1', type: 'marriage', date: null, sources: 'Registre des mariages de Limoges' },
{ ...event, id: 'e-7', personId: null, unionId: 'u-1', type: 'divorce', date: new Date('1920-06-01T00:00:00Z') },
])
const response = await app.inject({ method: 'GET', url: '/trees/tree-1/export/gedcom', headers: { authorization: `Bearer ${token}` } })
expect(response.statusCode).toBe(200)
const text = response.body.replace(/\r\n/g, '\n')
expect(text).toContain('1 OCCU Instituteur\n2 DATE FROM 1900 TO 1935')
expect(text.match(/1 OCCU/g)).toHaveLength(1)
expect(text).toContain('2 PEDI adopted')
// Décédée sans date (issue #77) : la forme courte de la norme
expect(text).toContain('1 NAME Marie /Bodin/\n1 SEX F\n1 DEAT Y\n')
// Ancienne écriture « M » en base : exportée comme un homme (issue #114)
expect(text).toContain('1 NAME Jean /Ragot/\n1 SEX M\n')
const roots = parseGedcom(text)
const records = indexGedcomRecords(roots)
const louis = roots.find((n) => n.tag === 'INDI' && n.children.some((c) => c.tag === 'NAME' && c.value.startsWith('Louis')))!
const extras = personExtras(louis as never, records)
expect(extras).toMatchObject({ birthDatePrecision: 'about', deathPlace: 'Nantes', nickname: 'Loulou' })
expect(extras.events.map((e) => e.type)).toEqual(['birth', 'baptism', 'occupation', 'custom'])
expect(extras.events[0].sources).toBe('Registre des naissances, Acte n° 12')
expect(extras.events[3]).toMatchObject({ label: 'Militaire', datePrecision: 'between', notes: 'Blessé à Verdun.\nRentré en 1919.' })
const fam = roots.find((n) => n.tag === 'FAM')!
const family = familyExtras(fam as never, records)
expect(family).toMatchObject({ startPlace: 'Limoges', startDatePrecision: 'estimated' })
expect(family.events.map((e) => e.type)).toEqual(['marriage', 'divorce'])
const jean = roots.find((n) => n.tag === 'INDI' && n.children.some((c) => c.tag === 'NAME' && c.value.startsWith('Jean')))!
expect(adoptedFamilies(jean as never).size).toBe(1)
await app.close()
})
it('allows tree access token to fetch graph', async () => {
const app = createApp()
await app.ready()
@@ -518,6 +649,318 @@ describe('Graph routes', () => {
})
})
describe('Décès sans date (issue #77)', () => {
it('marque décédé sans date, et une date de décès retire le drapeau', async () => {
const app = createApp()
const token = await createUserToken(app)
const patch = (payload: object) => app.inject({ method: 'PATCH', url: '/trees/tree-1/persons/person-1', headers: { authorization: `Bearer ${token}` }, payload })
expect((await patch({ isDeceased: true })).statusCode).toBe(200)
expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ isDeceased: true })
expect((await patch({ deathDate: '1950-01-01', isDeceased: true })).statusCode).toBe(200)
expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ isDeceased: false, deathDate: new Date('1950-01-01T00:00:00.000Z') })
expect((await patch({ isDeceased: false, deathDate: null })).statusCode).toBe(200)
expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ isDeceased: false, deathDate: null })
await app.close()
})
})
describe('Moments de l’onglet Vie', () => {
it('un admin ajoute un moment avec deux souvenirs, le modifie sans changer son type, le supprime', async () => {
const app = createApp()
const token = await createUserToken(app)
const auth = { authorization: `Bearer ${token}` }
prismaMock.mediaItem.count.mockResolvedValue(2)
prismaMock.mediaItem.updateMany.mockResolvedValue({ count: 2 })
prismaMock.lifeEvent.create.mockResolvedValue({ id: 'e-1', status: 'approved' })
const created = await app.inject({
method: 'POST', url: '/trees/tree-1/persons/person-1/life/events', headers: auth,
payload: { label: 'Mariage à la mairie', date: '1940', datePrecision: 'about', place: 'Limoges', mediaIds: ['m-1', 'm-2'] },
})
expect(created.statusCode).toBe(201)
expect(created.json()).toEqual({ event: { id: 'e-1', status: 'approved' } })
expect(prismaMock.lifeEvent.create).toHaveBeenCalledWith({ data: expect.objectContaining({
treeId: 'tree-1', personId: 'person-1', type: 'custom', label: 'Mariage à la mairie', status: 'approved',
date: new Date('1940-01-01T00:00:00.000Z'), datePrecision: 'about', place: 'Limoges', contributionSessionId: null,
}) })
expect(prismaMock.mediaItem.updateMany).toHaveBeenCalledWith({ where: { treeId: 'tree-1', id: { in: ['m-1', 'm-2'] } }, data: { lifeEventId: 'e-1' } })
expect(prismaMock.contributionSession.create).not.toHaveBeenCalled()
prismaMock.lifeEvent.findFirst.mockResolvedValue({ personId: 'person-1', unionId: null, type: 'occupation' })
const patched = await app.inject({ method: 'PATCH', url: '/trees/tree-1/life/events/e-1', headers: auth, payload: { place: '', mediaIds: [] } })
expect(patched.statusCode).toBe(200)
expect(prismaMock.lifeEvent.update).toHaveBeenCalledWith({ where: { id: 'e-1' }, data: expect.objectContaining({ place: null }) })
expect(prismaMock.lifeEvent.update.mock.calls[0][0].data).not.toHaveProperty('type')
expect(prismaMock.mediaItem.updateMany).toHaveBeenCalledWith({ where: { treeId: 'tree-1', lifeEventId: 'e-1', id: { notIn: [] } }, data: { lifeEventId: null } })
prismaMock.lifeEvent.updateMany.mockResolvedValue({ count: 1 })
const deleted = await app.inject({ method: 'DELETE', url: '/trees/tree-1/life/events/e-1', headers: auth })
expect(deleted.statusCode).toBe(204)
await app.close()
})
it('naissance : un admin y accroche un acte et une source, une seule ligne par personne, sans date ni lieu', async () => {
const app = createApp()
const token = await createUserToken(app)
const auth = { authorization: `Bearer ${token}` }
prismaMock.mediaItem.count.mockResolvedValue(1)
prismaMock.lifeEvent.findFirst.mockResolvedValueOnce(null)
prismaMock.lifeEvent.create.mockResolvedValue({ id: 'e-b', status: 'approved' })
const post = (payload: object) => app.inject({ method: 'POST', url: '/trees/tree-1/persons/person-1/life/events', headers: auth, payload })
expect((await post({ type: 'birth', sources: 'Registre de Limoges', place: 'ignoré', mediaIds: ['acte'] })).statusCode).toBe(201)
const data = prismaMock.lifeEvent.create.mock.calls[0][0].data
expect(data).toMatchObject({ type: 'birth', personId: 'person-1', sources: 'Registre de Limoges' })
expect(data).not.toHaveProperty('place')
prismaMock.lifeEvent.findFirst.mockResolvedValueOnce({ id: 'e-b' })
expect((await post({ type: 'birth', sources: 'x' })).statusCode).toBe(409)
prismaMock.lifeEvent.findFirst.mockResolvedValue({ personId: 'person-1', unionId: null, type: 'birth' })
expect((await app.inject({ method: 'PATCH', url: '/trees/tree-1/life/events/e-b', headers: auth, payload: { place: 'x', sources: 'Acte 12' } })).statusCode).toBe(200)
expect(prismaMock.lifeEvent.update).toHaveBeenCalledWith({ where: { id: 'e-b' }, data: { notes: undefined, sources: 'Acte 12' } })
await app.close()
})
it('la famille propose un moment : en attente, dans une contribution relue', async () => {
const app = createApp()
const token = await createUserToken(app)
const auth = { authorization: `Bearer ${token}` }
prismaMock.treeMembership.findUnique.mockResolvedValue({ role: 'member', tree: { deletedAt: null, settings: null } })
prismaMock.contributionSession.count.mockResolvedValue(0)
prismaMock.contributionSession.create.mockResolvedValue({ id: 's-1' })
prismaMock.lifeEvent.create.mockResolvedValue({ id: 'e-2', status: 'pending', type: 'custom', place: 'Paris', notes: null, sources: null })
const created = await app.inject({
method: 'POST', url: '/trees/tree-1/persons/person-1/life/events', headers: auth,
payload: { label: 'Départ pour Paris', date: '1925', submittedByLabel: 'Hélène' },
})
expect(created.statusCode).toBe(201)
expect(created.json()).toEqual({ event: { id: 'e-2', status: 'pending' } })
expect(prismaMock.contributionSession.create).toHaveBeenCalledWith({ data: expect.objectContaining({ status: 'pending', submittedByLabel: 'Hélène' }) })
expect(prismaMock.lifeEvent.create).toHaveBeenCalledWith({ data: expect.objectContaining({ status: 'pending', contributionSessionId: 's-1' }) })
expect(prismaMock.contributionChange.create).toHaveBeenCalledWith({ data: expect.objectContaining({
sessionId: 's-1', entityType: 'life_event', entityId: 'e-2', action: 'create', afterJson: expect.objectContaining({ title: 'Départ pour Paris' }),
}) })
// La famille ne touche ni aux naissances ni aux moments existants
expect((await app.inject({ method: 'POST', url: '/trees/tree-1/persons/person-1/life/events', headers: auth, payload: { type: 'birth', sources: 'x' } })).statusCode).toBe(403)
expect((await app.inject({ method: 'PATCH', url: '/trees/tree-1/life/events/e-1', headers: auth, payload: { place: 'x' } })).statusCode).toBe(403)
expect((await app.inject({ method: 'DELETE', url: '/trees/tree-1/life/events/e-1', headers: auth })).statusCode).toBe(403)
await app.close()
})
it('refuse un souvenir d’une autre personne, un moment sans titre, un mariage sans union de la personne', async () => {
const app = createApp()
const token = await createUserToken(app)
const auth = { authorization: `Bearer ${token}` }
const post = (payload: object) => app.inject({ method: 'POST', url: '/trees/tree-1/persons/person-1/life/events', headers: auth, payload })
prismaMock.mediaItem.count.mockResolvedValue(0)
expect((await post({ label: 'Mariage', mediaIds: ['autre'] })).statusCode).toBe(400)
expect((await post({ label: ' ' })).statusCode).toBe(400)
prismaMock.union.findFirst.mockResolvedValue(null)
expect((await post({ type: 'marriage', unionId: 'u-x', sources: 'x' })).statusCode).toBe(400)
expect(prismaMock.lifeEvent.create).not.toHaveBeenCalled()
await app.close()
})
})
describe('Écritures en plusieurs temps : tout passe par une transaction', () => {
// Le client de la transaction est un autre objet que app.prisma : une écriture faite hors transaction se voit
const txClient = () => ({
annotation: { create: vi.fn().mockResolvedValue({ id: 'a' }), updateMany: vi.fn().mockResolvedValue({ count: 1 }) },
mediaItem: { updateMany: vi.fn().mockResolvedValue({ count: 1 }), findMany: vi.fn().mockResolvedValue([]), update: vi.fn() },
lifeEvent: { create: vi.fn().mockResolvedValue({ id: 'e-1', status: 'approved' }), update: vi.fn(), updateMany: vi.fn().mockResolvedValue({ count: 1 }) },
contributionSession: { create: vi.fn() },
contributionChange: { create: vi.fn() },
auditLog: { create: vi.fn() },
})
it('lot d’annotations : créations, modifications, suppressions et trace dans la même transaction', async () => {
const app = createApp()
const auth = { authorization: `Bearer ${await createUserToken(app)}` }
const tx = txClient()
prismaMock.$transaction.mockImplementationOnce(async (callback: (client: unknown) => Promise<unknown>) => callback(tx))
const response = await app.inject({
method: 'POST', url: '/trees/tree-1/annotations/batch', headers: auth,
payload: { create: [{ type: 'text', x: 0, y: 0, content: 'Été 1952' }, { type: 'text', x: 1, y: 1, content: 'Noël' }], update: [{ id: 'a-1', x: 5 }], delete: ['a-2'] },
})
expect(response.statusCode).toBe(200)
expect(response.json()).toEqual({ ok: true, created: 2, updated: 1, deleted: 1 })
expect(tx.annotation.create).toHaveBeenCalledTimes(2)
expect(tx.annotation.updateMany).toHaveBeenCalledTimes(2)
expect(tx.auditLog.create).toHaveBeenCalledTimes(1)
expect(prismaMock.annotation.create).not.toHaveBeenCalled()
expect(prismaMock.annotation.updateMany).not.toHaveBeenCalled()
await app.close()
})
it('lot d’annotations : une erreur au milieu fait échouer toute la transaction', async () => {
const app = createApp()
const auth = { authorization: `Bearer ${await createUserToken(app)}` }
const tx = txClient()
tx.annotation.create.mockResolvedValueOnce({ id: 'a' }).mockRejectedValueOnce(new Error('base indisponible'))
prismaMock.$transaction.mockImplementationOnce(async (callback: (client: unknown) => Promise<unknown>) => callback(tx))
const response = await app.inject({
method: 'POST', url: '/trees/tree-1/annotations/batch', headers: auth,
payload: { create: [{ type: 'text', x: 0, y: 0, content: 'a' }, { type: 'text', x: 1, y: 1, content: 'b' }] },
})
expect(response.statusCode).toBe(500)
expect(tx.auditLog.create).not.toHaveBeenCalled()
await app.close()
})
it('réordonner les souvenirs : positions, renumérotation et trace dans la même transaction', async () => {
const app = createApp()
const auth = { authorization: `Bearer ${await createUserToken(app)}` }
const tx = txClient()
prismaMock.mediaItem.findMany.mockResolvedValueOnce([{ id: 'm-1' }, { id: 'm-2' }])
prismaMock.$transaction.mockImplementationOnce(async (callback: (client: unknown) => Promise<unknown>) => callback(tx))
const response = await app.inject({
method: 'PATCH', url: '/trees/tree-1/persons/person-1/media/reorder', headers: auth,
payload: { orderedMediaIds: ['m-2', 'm-1'] },
})
expect(response.statusCode).toBe(200)
expect(tx.mediaItem.updateMany).toHaveBeenCalledTimes(2)
expect(tx.auditLog.create).toHaveBeenCalledTimes(1)
expect(prismaMock.mediaItem.updateMany).not.toHaveBeenCalled()
await app.close()
})
it('moment : création et rattachement des souvenirs dans la même transaction', async () => {
const app = createApp()
const auth = { authorization: `Bearer ${await createUserToken(app)}` }
const tx = txClient()
prismaMock.mediaItem.count.mockResolvedValue(1)
prismaMock.$transaction.mockImplementationOnce(async (callback: (client: unknown) => Promise<unknown>) => callback(tx))
const response = await app.inject({
method: 'POST', url: '/trees/tree-1/persons/person-1/life/events', headers: auth,
payload: { label: 'Départ pour Paris', mediaIds: ['m-1'] },
})
expect(response.statusCode).toBe(201)
expect(tx.lifeEvent.create).toHaveBeenCalledTimes(1)
expect(tx.mediaItem.updateMany).toHaveBeenCalledWith({ where: { treeId: 'tree-1', id: { in: ['m-1'] } }, data: { lifeEventId: 'e-1' } })
expect(prismaMock.lifeEvent.create).not.toHaveBeenCalled()
await app.close()
})
it('la famille ne prend que des souvenirs libres, l’administrateur peut déplacer un souvenir d’un moment à l’autre', async () => {
const app = createApp()
const auth = { authorization: `Bearer ${await createUserToken(app)}` }
const post = () => app.inject({ method: 'POST', url: '/trees/tree-1/persons/person-1/life/events', headers: auth, payload: { label: 'Communion', mediaIds: ['m-1'] } })
prismaMock.mediaItem.count.mockResolvedValue(1)
prismaMock.lifeEvent.create.mockResolvedValue({ id: 'e-1', status: 'approved' })
expect((await post()).statusCode).toBe(201)
expect(prismaMock.mediaItem.count.mock.calls.at(-1)[0].where).not.toHaveProperty('lifeEventId')
prismaMock.treeMembership.findUnique.mockResolvedValue({ role: 'member', tree: { deletedAt: null, settings: null } })
prismaMock.contributionSession.create.mockResolvedValue({ id: 's-1' })
expect((await post()).statusCode).toBe(201)
expect(prismaMock.mediaItem.count.mock.calls.at(-1)[0].where).toMatchObject({ lifeEventId: null })
await app.close()
})
})
describe('Import GEDCOM', () => {
it('une personne des deux côtés d’une union garde un seul partenaire, une boucle parent-enfant est refusée', async () => {
const app = createApp()
const auth = { authorization: `Bearer ${await createUserToken(app)}` }
prismaMock.person.findMany.mockResolvedValue([])
prismaMock.union.findMany.mockResolvedValue([])
prismaMock.parentChildLink.findMany.mockResolvedValue([])
prismaMock.parentChildLink.findFirst.mockResolvedValue(null)
prismaMock.person.create.mockImplementation(async ({ data }: { data: { firstName: string } }) => ({ id: `p-${data.firstName}` }))
let unionCount = 0
prismaMock.union.create.mockImplementation(async () => ({ id: `u-${++unionCount}` }))
const content = [
'0 HEAD',
'0 @I1@ INDI', '1 NAME Jean /Martin/',
'0 @I2@ INDI', '1 NAME Paul /Martin/',
// Jean marié à lui-même (fichier abîmé)
'0 @F1@ FAM', '1 HUSB @I1@', '1 WIFE @I1@',
// Paul enfant de Jean, puis Jean enfant de Paul : la seconde famille ferait une boucle
'0 @F2@ FAM', '1 HUSB @I1@', '1 CHIL @I2@',
'0 @F3@ FAM', '1 HUSB @I2@', '1 CHIL @I1@',
'0 TRLR',
].join('\n')
const response = await app.inject({ method: 'POST', url: '/trees/tree-1/import/gedcom', headers: auth, payload: { content } })
expect(response.statusCode).toBe(200)
expect(response.json()).toMatchObject({ personsCreated: 2, linksCreated: 1, relationsSkipped: 2 })
expect(prismaMock.union.create.mock.calls[0][0].data).toMatchObject({ partner1PersonId: 'p-Jean', partner2PersonId: null })
expect(prismaMock.parentChildLink.create).toHaveBeenCalledTimes(1)
expect(prismaMock.parentChildLink.create.mock.calls[0][0].data).toMatchObject({ parentPersonId: 'p-Jean', childPersonId: 'p-Paul' })
await app.close()
})
})
describe('Sexe dans le GEDCOM (issue #114)', () => {
const importer = async (app: FastifyInstance, content: string) => {
const auth = { authorization: `Bearer ${await createUserToken(app)}` }
prismaMock.person.findMany.mockResolvedValue([])
prismaMock.union.findMany.mockResolvedValue([])
prismaMock.parentChildLink.findMany.mockResolvedValue([])
prismaMock.person.create.mockImplementation(async ({ data }: { data: { firstName: string } }) => ({ id: `p-${data.firstName}` }))
prismaMock.person.create.mockClear()
const response = await app.inject({ method: 'POST', url: '/trees/tree-1/import/gedcom', headers: auth, payload: { content } })
expect(response.statusCode).toBe(200)
return Object.fromEntries(prismaMock.person.create.mock.calls.map((call: [{ data: { firstName: string; sex: string | null } }]) => [call[0].data.firstName, call[0].data.sex]))
}
it('import : M, F, X (GEDCOM 7), U et absent donnent male, female, other, null, null', async () => {
const app = createApp()
const content = [
'0 HEAD',
'0 @I1@ INDI', '1 NAME Jean /Martin/', '1 SEX M',
'0 @I2@ INDI', '1 NAME Marie /Martin/', '1 SEX F',
'0 @I3@ INDI', '1 NAME Camille /Martin/', '1 SEX X',
'0 @I4@ INDI', '1 NAME Dominique /Martin/', '1 SEX U',
'0 @I5@ INDI', '1 NAME Claude /Martin/',
'0 TRLR',
].join('\n')
expect(await importer(app, content)).toEqual({ Jean: 'male', Marie: 'female', Camille: 'other', Dominique: null, Claude: null })
await app.close()
})
it('aller-retour : other sort en SEX U (GEDCOM 5.5.1), non précisé sans ligne SEX, le reste se relit à l’identique', async () => {
const app = createApp()
await app.ready()
const token = (app as any).jwt.sign({ kind: 'tree_access', sub: 'tree:tree-1:visitor', treeId: 'tree-1', role: 'visitor', accessVersion: new Date('2026-02-11T10:00:00.000Z').getTime() })
const base = { lastName: 'Martin', birthDate: null, birthDatePrecision: null, birthPlace: null, birthDateEnd: null, deathDateEnd: null, deathDatePrecision: null, deathPlace: null, nickname: null, notes: null, profession: null, deathDate: null }
prismaMock.tree.findFirst.mockResolvedValue({ id: 'tree-1', name: 'Martin', rootPersonId: null })
prismaMock.person.findMany.mockResolvedValue([
{ ...base, id: 'p-1', firstName: 'Jean', sex: 'male' },
{ ...base, id: 'p-2', firstName: 'Marie', sex: 'female' },
{ ...base, id: 'p-3', firstName: 'Camille', sex: 'other' },
{ ...base, id: 'p-4', firstName: 'Claude', sex: null },
])
prismaMock.union.findMany.mockResolvedValue([])
prismaMock.parentChildLink.findMany.mockResolvedValue([])
prismaMock.lifeEvent.findMany.mockResolvedValue([])
const response = await app.inject({ method: 'GET', url: '/trees/tree-1/export/gedcom', headers: { authorization: `Bearer ${token}` } })
expect(response.statusCode).toBe(200)
const text = response.body.replace(/\r\n/g, '\n')
expect(text).toContain('1 NAME Camille /Martin/\n1 SEX U\n')
expect(text).not.toContain('SEX X')
expect(text).toMatch(/1 NAME Claude \/Martin\/\n(?!1 SEX)/)
// SEX U se relit comme non précisé : 5.5.1 n'a pas de valeur pour « autre ». SEX X reste lu à l'import (ligne 915).
expect(await importer(app, text)).toEqual({ Jean: 'male', Marie: 'female', Camille: null, Claude: null })
await app.close()
})
})
describe('Visit log', () => {
it('refuses the visit log to a non-admin member', async () => {
const app = createApp()
@@ -750,3 +1193,99 @@ describe('Relationship guardrails', () => {
await app.close()
})
})
describe('Surnom (nickname) en écriture directe', () => {
it('PATCH accepte le surnom, le coupe, et vide = null', async () => {
const app = createApp()
const token = await createUserToken(app)
const patch = (payload: object) => app.inject({ method: 'PATCH', url: '/trees/tree-1/persons/person-1', headers: { authorization: `Bearer ${token}` }, payload })
expect((await patch({ nickname: ' Loulou ' })).statusCode).toBe(200)
expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ nickname: 'Loulou' })
expect((await patch({ nickname: ' ' })).statusCode).toBe(200)
expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ nickname: null })
await app.close()
})
it('refuse un surnom de plus de 120 caractères', async () => {
const app = createApp()
const token = await createUserToken(app)
const res = await app.inject({ method: 'PATCH', url: '/trees/tree-1/persons/person-1', headers: { authorization: `Bearer ${token}` }, payload: { nickname: 'x'.repeat(121) } })
expect(res.statusCode).toBe(400)
await app.close()
})
})
describe('« À vérifier » en écriture directe (retour d’Anaïs, 23/10)', () => {
it('POST et PATCH posent et retirent le drapeau, absent = inchangé', async () => {
const app = createApp()
const token = await createUserToken(app)
const headers = { authorization: `Bearer ${token}` }
const created = await app.inject({ method: 'POST', url: '/trees/tree-1/persons', headers, payload: { firstName: 'Jeanne', lastName: 'Martin', toVerify: true } })
expect(created.statusCode).toBe(201)
expect(prismaMock.person.create.mock.calls.at(-1)[0].data).toMatchObject({ toVerify: true })
const patch = (payload: object) => app.inject({ method: 'PATCH', url: '/trees/tree-1/persons/person-1', headers, payload })
expect((await patch({ toVerify: true })).statusCode).toBe(200)
expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ toVerify: true })
expect((await patch({ toVerify: false })).statusCode).toBe(200)
expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ toVerify: false })
expect((await patch({ nickname: 'Loulou' })).statusCode).toBe(200)
expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).not.toHaveProperty('toVerify')
expect((await patch({ toVerify: 'oui' })).statusCode).toBe(400)
await app.close()
})
})
describe('Sexe d’une personne en écriture directe (issue #114)', () => {
it('PATCH ramène le sexe à male / female / other, null = non précisé, refuse une valeur inconnue', async () => {
const app = createApp()
const token = await createUserToken(app)
const patch = (payload: object) => app.inject({ method: 'PATCH', url: '/trees/tree-1/persons/person-1', headers: { authorization: `Bearer ${token}` }, payload })
expect((await patch({ sex: 'F' })).statusCode).toBe(200)
expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ sex: 'female' })
expect((await patch({ sex: 'male' })).statusCode).toBe(200)
expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ sex: 'male' })
expect((await patch({ sex: 'other' })).statusCode).toBe(200)
expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ sex: 'other' })
expect((await patch({ sex: null })).statusCode).toBe(200)
expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ sex: null })
const calls = prismaMock.person.updateMany.mock.calls.length
expect((await patch({ sex: 'robot' })).statusCode).toBe(400)
expect(prismaMock.person.updateMany.mock.calls.length).toBe(calls)
await app.close()
})
})
describe('Lieu de décès et filiation adoptive (écriture)', () => {
it('PATCH accepte le lieu de décès, le coupe, et vide = null', async () => {
const app = createApp()
const token = await createUserToken(app)
const patch = (payload: object) => app.inject({ method: 'PATCH', url: '/trees/tree-1/persons/person-1', headers: { authorization: `Bearer ${token}` }, payload })
expect((await patch({ deathPlace: ' Nantes, France ' })).statusCode).toBe(200)
expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ deathPlace: 'Nantes, France' })
expect((await patch({ deathPlace: ' ' })).statusCode).toBe(200)
expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ deathPlace: null })
await app.close()
})
it('PATCH d’un lien accepte le type de filiation (naissance ou adoption)', async () => {
const app = createApp()
const token = await createUserToken(app)
prismaMock.parentChildLink.findFirst.mockResolvedValue({ id: 'link-1', parentPersonId: 'person-2', childPersonId: 'person-1', viaUnionId: null, parentageType: 'biologique' })
prismaMock.parentChildLink.updateMany.mockResolvedValueOnce({ count: 1 })
const res = await app.inject({ method: 'PATCH', url: '/trees/tree-1/links/link-1', headers: { authorization: `Bearer ${token}` }, payload: { parentageType: 'adoption' } })
expect(res.statusCode).toBe(200)
expect(prismaMock.parentChildLink.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ parentageType: 'adoption' })
await app.close()
})
})
+198 -1
View File
@@ -4,6 +4,12 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
const { prismaMock } = vi.hoisted(() => ({
prismaMock: {
// Sessions fermées (lib/token-revocation.ts), rechargées au démarrage
revokedSession: {
upsert: vi.fn().mockResolvedValue({}),
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
user: {
findUnique: vi.fn(),
count: vi.fn().mockResolvedValue(1),
@@ -72,6 +78,11 @@ const { prismaMock } = vi.hoisted(() => ({
update: vi.fn(),
updateMany: vi.fn(),
},
lifeEvent: {
findMany: vi.fn().mockResolvedValue([]),
createMany: vi.fn().mockResolvedValue({ count: 0 }),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
annotation: {
findMany: vi.fn(),
},
@@ -86,6 +97,11 @@ const { prismaMock } = vi.hoisted(() => ({
create: vi.fn(),
update: vi.fn(),
},
mediaPersonTag: {
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
createMany: vi.fn().mockResolvedValue({ count: 0 }),
},
auditLog: {
create: vi.fn(),
findMany: vi.fn(),
@@ -285,7 +301,8 @@ describe('API security and authz', () => {
expect(response.statusCode).toBe(429)
await app.close()
})
// Onze connexions, chacune avec une vérification bcrypt : plus de 5 s sur les machines lentes de la CI
}, 20_000)
it('rate-limits register endpoint', async () => {
const app = createApp()
@@ -514,6 +531,130 @@ describe('API security and authz', () => {
await app.close()
})
describe('PUT /trees/:id/links/order', () => {
const siblingLinks = [
{ id: 'link-a1', parentPersonId: 'father', childPersonId: 'anne', viaUnionId: 'union-1' },
{ id: 'link-a2', parentPersonId: 'mother', childPersonId: 'anne', viaUnionId: 'union-1' },
{ id: 'link-h1', parentPersonId: 'father', childPersonId: 'hugo', viaUnionId: 'union-1' },
{ id: 'link-h2', parentPersonId: 'mother', childPersonId: 'hugo', viaUnionId: 'union-1' },
]
const order = [
{ childId: 'hugo', linkIds: ['link-h1', 'link-h2'] },
{ childId: 'anne', linkIds: ['link-a1', 'link-a2'] },
]
it('rewrites the order of a sibling group in one transaction', async () => {
const app = createApp()
const token = await createUserToken(app)
prismaMock.parentChildLink.findMany.mockResolvedValue(siblingLinks)
prismaMock.$transaction.mockResolvedValue([])
const response = await app.inject({
method: 'PUT',
url: '/trees/tree-1/links/order',
headers: { authorization: `Bearer ${token}` },
payload: { order },
})
expect(response.statusCode).toBe(200)
expect(prismaMock.$transaction).toHaveBeenCalledTimes(1)
expect(prismaMock.parentChildLink.updateMany).toHaveBeenNthCalledWith(1, {
where: {
treeId: 'tree-1', deletedAt: null, childPersonId: 'hugo',
OR: [{ id: { in: ['link-h1', 'link-h2'] } }, { viaUnionId: { in: ['union-1'] } }],
},
data: { displayOrder: 1 },
})
expect(prismaMock.parentChildLink.updateMany).toHaveBeenNthCalledWith(2, {
where: {
treeId: 'tree-1', deletedAt: null, childPersonId: 'anne',
OR: [{ id: { in: ['link-a1', 'link-a2'] } }, { viaUnionId: { in: ['union-1'] } }],
},
data: { displayOrder: 2 },
})
expect(prismaMock.auditLog.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ action: 'children_reordered', payloadJson: { childIds: ['hugo', 'anne'] } }),
}))
await app.close()
})
it('forbids a member who is not admin', async () => {
const app = createApp()
const token = await createUserToken(app)
prismaMock.treeMembership.findUnique.mockResolvedValue({
treeId: 'tree-1',
userId: 'user-1',
role: 'member',
tree: { deletedAt: null, rootPersonId: null },
})
const response = await app.inject({
method: 'PUT',
url: '/trees/tree-1/links/order',
headers: { authorization: `Bearer ${token}` },
payload: { order },
})
expect(response.statusCode).toBe(403)
expect(prismaMock.$transaction).not.toHaveBeenCalled()
await app.close()
})
it('refuses links that do not belong to the tree', async () => {
const app = createApp()
const token = await createUserToken(app)
prismaMock.parentChildLink.findMany.mockResolvedValue(siblingLinks.slice(0, 3))
const response = await app.inject({
method: 'PUT',
url: '/trees/tree-1/links/order',
headers: { authorization: `Bearer ${token}` },
payload: { order },
})
expect(response.statusCode).toBe(404)
expect(prismaMock.$transaction).not.toHaveBeenCalled()
await app.close()
})
it('refuses a link sent for the wrong child', async () => {
const app = createApp()
const token = await createUserToken(app)
prismaMock.parentChildLink.findMany.mockResolvedValue([siblingLinks[0], siblingLinks[2]])
const response = await app.inject({
method: 'PUT',
url: '/trees/tree-1/links/order',
headers: { authorization: `Bearer ${token}` },
payload: { order: [{ childId: 'hugo', linkIds: ['link-a1'] }, { childId: 'anne', linkIds: ['link-h1'] }] },
})
expect(response.statusCode).toBe(400)
expect(response.json()).toEqual({ error: 'invalid_link_reference' })
await app.close()
})
it('refuses children who share no parent', async () => {
const app = createApp()
const token = await createUserToken(app)
prismaMock.parentChildLink.findMany.mockResolvedValue([
{ id: 'link-x', parentPersonId: 'father', childPersonId: 'anne' },
{ id: 'link-y', parentPersonId: 'stranger', childPersonId: 'hugo' },
])
const response = await app.inject({
method: 'PUT',
url: '/trees/tree-1/links/order',
headers: { authorization: `Bearer ${token}` },
payload: { order: [{ childId: 'anne', linkIds: ['link-x'] }, { childId: 'hugo', linkIds: ['link-y'] }] },
})
expect(response.statusCode).toBe(400)
expect(response.json()).toEqual({ error: 'not_siblings' })
await app.close()
})
})
it('forbids member from patching person records', async () => {
const app = createApp()
const token = await createUserToken(app)
@@ -1016,6 +1157,33 @@ describe('API security and authz', () => {
await app.close()
})
it('« à vérifier » proposé par la famille : appliqué à l’approbation, en création comme en modification', async () => {
const app = createApp()
const token = await createUserToken(app)
prismaMock.contributionSession.findFirst.mockResolvedValue({
id: 'session-1',
treeId: 'tree-1',
status: 'pending',
changes: [
{ id: 'change-1', entityType: 'person', action: 'create', entityId: null, beforeJson: null, afterJson: { firstName: 'Alice', lastName: 'Martin', toVerify: true }, conflictState: 'none' },
{ id: 'change-2', entityType: 'person', action: 'update', entityId: 'person-1', beforeJson: { toVerify: false }, afterJson: { toVerify: true }, conflictState: 'none' },
],
})
const response = await app.inject({
method: 'POST',
url: '/trees/tree-1/contributions/sessions/session-1/review',
headers: { authorization: `Bearer ${token}` },
payload: { decision: 'approved' },
})
expect(response.statusCode).toBe(200)
expect(prismaMock.person.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ firstName: 'Alice', toVerify: true }) }))
expect(prismaMock.person.updateMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ id: 'person-1' }), data: expect.objectContaining({ toVerify: true }) }))
await app.close()
})
it('allows owner to upload media below image limit', async () => {
const app = createApp()
const token = await createUserToken(app)
@@ -1269,6 +1437,35 @@ describe('API security and authz', () => {
await app.close()
})
it('un moment proposé par la famille devient visible si accepté, disparaît si refusé', async () => {
const app = createApp()
const token = await createUserToken(app)
const session = (status = 'pending') => ({
id: 'session-1',
treeId: 'tree-1',
status,
changes: [
{ id: 'change-1', entityType: 'life_event', action: 'create', entityId: 'event-1', beforeJson: null, afterJson: { personId: 'person-1', title: 'Départ pour Paris' }, conflictState: 'none' },
],
})
const review = (decision: string) => app.inject({
method: 'POST',
url: '/trees/tree-1/contributions/sessions/session-1/review',
headers: { authorization: `Bearer ${token}` },
payload: { decision },
})
prismaMock.contributionSession.findFirst.mockResolvedValue(session())
prismaMock.lifeEvent.updateMany.mockResolvedValue({ count: 1 })
expect((await review('approved')).statusCode).toBe(200)
expect(prismaMock.lifeEvent.updateMany).toHaveBeenCalledWith({ where: { id: 'event-1', treeId: 'tree-1', deletedAt: null }, data: { status: 'approved' } })
prismaMock.contributionSession.findFirst.mockResolvedValue(session())
expect((await review('rejected')).statusCode).toBe(200)
expect(prismaMock.lifeEvent.updateMany).toHaveBeenCalledWith({ where: { id: 'event-1', treeId: 'tree-1' }, data: expect.objectContaining({ status: 'rejected' }) })
await app.close()
})
it('reviews a session containing a drawing without failing', async () => {
const app = createApp()
const token = await createUserToken(app)
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { hashPassword, verifyPassword } from '../src/lib/auth.js'
// bcrypt natif à la place de bcryptjs : les empreintes déjà en base restent valables
describe('mots de passe, bcrypt natif', () => {
it('lit une empreinte faite par bcryptjs', async () => {
const fromBcryptjs = '$2b$10$4uBVFoNEA8gO3uIGq/RqMueaq4w2Vl9VzPFiSj9v9dc0oI1DcR.YS'
expect(await verifyPassword('123soleil 🌞', fromBcryptjs)).toBe(true)
expect(await verifyPassword('123soleil', fromBcryptjs)).toBe(false)
})
it('fait une empreinte au coût 12 qui se relit', async () => {
const stored = await hashPassword('mot-de-passe-solide')
expect(stored).toMatch(/^\$2[aby]\$12\$/)
expect(await verifyPassword('mot-de-passe-solide', stored)).toBe(true)
expect(await verifyPassword('mot-de-passe-faux', stored)).toBe(false)
})
})
+125
View File
@@ -0,0 +1,125 @@
import type { FastifyInstance } from 'fastify'
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
// Prénom du compte : renvoyé à l'inscription, à la connexion et sur /me.
const { prismaMock } = vi.hoisted(() => ({
prismaMock: {
// Sessions fermées (lib/token-revocation.ts), rechargées au démarrage
revokedSession: {
upsert: vi.fn().mockResolvedValue({}),
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
user: {
findUnique: vi.fn(),
create: vi.fn(),
count: vi.fn(),
},
$disconnect: vi.fn(),
},
}))
vi.mock('../src/lib/prisma.js', () => ({
prisma: prismaMock,
}))
let app: FastifyInstance
beforeAll(async () => {
process.env.NODE_ENV = 'test'
process.env.PORT = '4000'
process.env.DATABASE_URL = 'postgresql://postgres:postgres@localhost:5432/genealogy'
process.env.JWT_SECRET = 'test-secret-with-sufficient-length'
process.env.JWT_EXPIRES_IN = '7d'
process.env.JWT_TREE_ACCESS_EXPIRES_IN = '24h'
process.env.CORS_ORIGIN = 'http://localhost:5173'
const { createApp } = await import('../src/app.js')
app = createApp()
})
afterAll(async () => {
await app?.close()
})
beforeEach(() => {
vi.clearAllMocks()
prismaMock.user.findUnique.mockResolvedValue(null)
prismaMock.user.count.mockResolvedValue(1)
})
describe('prénom du compte', () => {
it("l'inscription enregistre le prénom (sans espaces autour) et le renvoie", async () => {
prismaMock.user.create.mockResolvedValue({ id: 'user-1', email: 'anais@example.com', firstName: 'Anaïs' })
const res = await app.inject({
method: 'POST',
url: '/auth/register',
payload: { firstName: ' Anaïs ', email: 'anais@example.com', password: 'securepass123' },
})
expect(res.statusCode).toBe(201)
expect(prismaMock.user.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ firstName: 'Anaïs' }),
}))
expect(res.json().user).toEqual({ id: 'user-1', email: 'anais@example.com', firstName: 'Anaïs', hasPassword: true })
})
it("l'inscription sans prénom le garde vide, sans erreur", async () => {
prismaMock.user.create.mockResolvedValue({ id: 'user-2', email: 'sans@example.com', firstName: null })
const res = await app.inject({
method: 'POST',
url: '/auth/register',
payload: { email: 'sans@example.com', password: 'securepass123' },
})
expect(res.statusCode).toBe(201)
expect(prismaMock.user.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ firstName: null }),
}))
expect(res.json().user.firstName).toBeNull()
})
it('la connexion renvoie le prénom du compte', async () => {
const { hashPassword } = await import('../src/lib/auth.js')
prismaMock.user.findUnique.mockResolvedValue({
id: 'user-1',
email: 'anais@example.com',
firstName: 'Anaïs',
passwordHash: await hashPassword('securepass123'),
})
const res = await app.inject({
method: 'POST',
url: '/auth/login',
payload: { email: 'anais@example.com', password: 'securepass123' },
})
expect(res.statusCode).toBe(200)
expect(res.json().user).toEqual({ id: 'user-1', email: 'anais@example.com', firstName: 'Anaïs', hasPassword: true })
})
it('/me renvoie le profil du compte connecté, et refuse sans session', async () => {
const { hashPassword } = await import('../src/lib/auth.js')
prismaMock.user.findUnique.mockResolvedValue({
id: 'user-1',
email: 'anais@example.com',
firstName: 'Anaïs',
passwordHash: await hashPassword('securepass123'),
})
const login = await app.inject({
method: 'POST',
url: '/auth/login',
payload: { email: 'anais@example.com', password: 'securepass123' },
})
const token = login.json().token
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-1', email: 'anais@example.com', firstName: 'Anaïs', passwordHash: 'hash' })
const me = await app.inject({ method: 'GET', url: '/auth/me', headers: { authorization: `Bearer ${token}` } })
expect(me.statusCode).toBe(200)
expect(me.json().user).toEqual({ id: 'user-1', email: 'anais@example.com', firstName: 'Anaïs', hasPassword: true, oidcLinked: false })
const anonymous = await app.inject({ method: 'GET', url: '/auth/me' })
expect(anonymous.statusCode).toBe(401)
})
})
+241
View File
@@ -0,0 +1,241 @@
import { randomUUID } from 'node:crypto'
import { mkdtemp, rm } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { Prisma } from '../src/generated/prisma/client.js'
import type { FastifyInstance } from 'fastify'
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
const { prismaMock } = vi.hoisted(() => ({
prismaMock: {
// Sessions fermées (lib/token-revocation.ts), rechargées au démarrage
revokedSession: {
upsert: vi.fn().mockResolvedValue({}),
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
user: {
count: vi.fn(),
},
person: {
findFirst: vi.fn(),
updateMany: vi.fn(),
},
treeMembership: {
findUnique: vi.fn(),
},
mediaPersonTag: {
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
createMany: vi.fn().mockResolvedValue({ count: 0 }),
},
auditLog: {
create: vi.fn(),
},
$transaction: vi.fn(),
$disconnect: vi.fn(),
},
}))
vi.mock('../src/lib/prisma.js', () => ({
prisma: prismaMock,
}))
let createApp: () => FastifyInstance
let storageRoot: string
const TREE = 'tree-1'
const PERSON = 'person-1'
const url = `/trees/${TREE}/persons/${PERSON}/avatar/crop`
async function createUserToken(app: FastifyInstance, userId = 'user-1'): Promise<string> {
await app.ready()
return (app as any).jwt.sign(
{ kind: 'user', sub: userId, jti: randomUUID(), userId, email: 'user@example.com' },
{ expiresIn: '1h' },
)
}
function asAdmin(role: 'owner' | 'admin' | 'member' = 'owner') {
prismaMock.treeMembership.findUnique.mockResolvedValue({ role, tree: { deletedAt: null } })
}
beforeAll(async () => {
process.env.NODE_ENV = 'test'
process.env.PORT = '4000'
process.env.DATABASE_URL = 'postgresql://postgres:postgres@localhost:5432/genealogy'
process.env.JWT_SECRET = 'test-secret-with-sufficient-length'
process.env.JWT_EXPIRES_IN = '7d'
process.env.JWT_TREE_ACCESS_EXPIRES_IN = '24h'
process.env.CORS_ORIGIN = 'http://localhost:5173'
storageRoot = await mkdtemp(path.join(os.tmpdir(), 'parenthese-crop-'))
process.env.MEDIA_STORAGE_PATH = storageRoot
const module = await import('../src/app.js')
createApp = module.createApp
})
afterAll(async () => {
await rm(storageRoot, { recursive: true, force: true })
})
beforeEach(() => {
vi.clearAllMocks()
prismaMock.user.count.mockResolvedValue(1)
prismaMock.person.updateMany.mockResolvedValue({ count: 1 })
prismaMock.person.findFirst.mockResolvedValue({ avatarPath: `${TREE}/${PERSON}/avatar-a.jpg` })
asAdmin('owner')
})
describe('PUT /trees/:id/persons/:personId/avatar/crop', () => {
it('refuse sans session', async () => {
const app = createApp()
const response = await app.inject({ method: 'PUT', url, payload: { crop: null } })
expect(response.statusCode).toBe(401)
expect(prismaMock.person.updateMany).not.toHaveBeenCalled()
})
it('réserve le cadrage aux administrateurs de l arbre, comme le changement de photo', async () => {
const app = createApp()
const token = await createUserToken(app)
asAdmin('member')
const response = await app.inject({
method: 'PUT',
url,
headers: { authorization: `Bearer ${token}` },
payload: { crop: { x: 0.4, y: 0.4, zoom: 2 } },
})
expect(response.statusCode).toBe(403)
expect(prismaMock.person.updateMany).not.toHaveBeenCalled()
})
it('refuse un cadrage hors plage ou mal formé', async () => {
const app = createApp()
const token = await createUserToken(app)
const headers = { authorization: `Bearer ${token}` }
const tooZoomed = await app.inject({ method: 'PUT', url, headers, payload: { crop: { x: 0.5, y: 0.5, zoom: 5 } } })
const outside = await app.inject({ method: 'PUT', url, headers, payload: { crop: { x: 1.2, y: 0.5, zoom: 1 } } })
const missing = await app.inject({ method: 'PUT', url, headers, payload: { crop: { x: 0.5 } } })
const noBody = await app.inject({ method: 'PUT', url, headers, payload: {} })
expect(tooZoomed.statusCode).toBe(400)
expect(outside.statusCode).toBe(400)
expect(missing.statusCode).toBe(400)
expect(noBody.statusCode).toBe(400)
expect(prismaMock.person.updateMany).not.toHaveBeenCalled()
})
it('enregistre le cadrage arrondi et audite le changement', async () => {
const app = createApp()
const token = await createUserToken(app)
const response = await app.inject({
method: 'PUT',
url,
headers: { authorization: `Bearer ${token}` },
payload: { crop: { x: 0.123456, y: 0.7, zoom: 1.8 } },
})
expect(response.statusCode).toBe(200)
expect(response.json()).toEqual({ ok: true, avatarCrop: { x: 0.1235, y: 0.7, zoom: 1.8 } })
const call = prismaMock.person.updateMany.mock.calls[0][0]
expect(call.where).toEqual({ id: PERSON, treeId: TREE, deletedAt: null })
expect(call.data.avatarCrop).toEqual({ x: 0.1235, y: 0.7, zoom: 1.8 })
// La photo elle-même n'est jamais touchée
expect(call.data).not.toHaveProperty('avatarPath')
expect(prismaMock.auditLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({ action: 'person_avatar_cropped', entityId: PERSON, treeId: TREE }),
})
})
it('remet le cadrage par défaut à zéro (null) plutôt que de le stocker', async () => {
const app = createApp()
const token = await createUserToken(app)
const reset = await app.inject({
method: 'PUT',
url,
headers: { authorization: `Bearer ${token}` },
payload: { crop: null },
})
expect(reset.statusCode).toBe(200)
expect(prismaMock.person.updateMany.mock.calls[0][0].data.avatarCrop).toBe(Prisma.DbNull)
prismaMock.person.updateMany.mockClear()
await app.inject({
method: 'PUT',
url,
headers: { authorization: `Bearer ${token}` },
payload: { crop: { x: 0.5, y: 0.5, zoom: 1 } },
})
expect(prismaMock.person.updateMany.mock.calls[0][0].data.avatarCrop).toBe(Prisma.DbNull)
})
it('garde la forme choisie, même avec un cadrage centré, et refuse une forme inconnue', async () => {
const app = createApp()
const token = await createUserToken(app)
const put = (crop: unknown) => app.inject({ method: 'PUT', url, headers: { authorization: `Bearer ${token}` }, payload: { crop } })
const saved = await put({ x: 0.5, y: 0.5, zoom: 1, shape: 'round' })
expect(saved.statusCode).toBe(200)
expect(saved.json()).toEqual({ ok: true, avatarCrop: { x: 0.5, y: 0.5, zoom: 1, shape: 'round' } })
expect(prismaMock.person.updateMany.mock.calls[0][0].data.avatarCrop).toEqual({ x: 0.5, y: 0.5, zoom: 1, shape: 'round' })
expect((await put({ x: 0.5, y: 0.5, zoom: 1, shape: 'hexagone' })).statusCode).toBe(400)
})
it('répond 404 sans portrait, et 409 pour un portrait venu d Immich', async () => {
const app = createApp()
const token = await createUserToken(app)
const headers = { authorization: `Bearer ${token}` }
prismaMock.person.findFirst.mockResolvedValueOnce({ avatarPath: null })
const noAvatar = await app.inject({ method: 'PUT', url, headers, payload: { crop: { x: 0.4, y: 0.4, zoom: 2 } } })
expect(noAvatar.statusCode).toBe(404)
prismaMock.person.findFirst.mockResolvedValueOnce({ avatarPath: `${TREE}/${PERSON}/immich-abc.jpg` })
const fromImmich = await app.inject({ method: 'PUT', url, headers, payload: { crop: { x: 0.4, y: 0.4, zoom: 2 } } })
expect(fromImmich.statusCode).toBe(409)
expect(fromImmich.json()).toEqual({ error: 'avatar_from_immich' })
expect(prismaMock.person.updateMany).not.toHaveBeenCalled()
})
})
describe('DELETE avatar : le cadrage disparaît avec la photo', () => {
it('remet avatarCrop à null en même temps que le portrait', async () => {
const app = createApp()
const token = await createUserToken(app)
const response = await app.inject({
method: 'DELETE',
url: `/trees/${TREE}/persons/${PERSON}/avatar`,
headers: { authorization: `Bearer ${token}` },
})
expect(response.statusCode).toBe(200)
const data = prismaMock.person.updateMany.mock.calls[0][0].data
expect(data.avatarPath).toBeNull()
expect(data.avatarCrop).toBe(Prisma.DbNull)
})
})
describe('lecture du cadrage stocké', () => {
it('ignore toute valeur mal formée au lieu de déformer le portrait', async () => {
const { readAvatarCrop, toStoredAvatarCrop } = await import('../src/lib/avatar-crop.js')
expect(readAvatarCrop(null)).toBeNull()
expect(readAvatarCrop('oui')).toBeNull()
expect(readAvatarCrop({ x: 0.2, y: 0.3 })).toBeNull()
expect(readAvatarCrop({ x: 0.2, y: 0.3, zoom: 9 })).toBeNull()
expect(readAvatarCrop({ x: 0.2, y: 0.3, zoom: 2 })).toEqual({ x: 0.2, y: 0.3, zoom: 2 })
expect(toStoredAvatarCrop({ x: 0.5, y: 0.5, zoom: 1 })).toBeNull()
expect(toStoredAvatarCrop(null)).toBeNull()
})
})
+26
View File
@@ -0,0 +1,26 @@
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { decode, isBlurhashValid } from 'blurhash'
import { describe, expect, it } from 'vitest'
import { computeBlurhash } from '../src/lib/blurhash.js'
// Aperçu flou calculé à l'envoi d'une photo : une chaîne valide, aux couleurs de la photo
const demo = (name: string) => fileURLToPath(new URL(`../../frontend/public/demo/${name}`, import.meta.url))
describe('computeBlurhash', () => {
it('donne un aperçu valide pour une vraie photo', async () => {
const hash = await computeBlurhash(await readFile(demo('souvenir-mariage-1918.webp')))
expect(hash).toBeTruthy()
expect(isBlurhashValid(hash as string).result).toBe(true)
// 4 × 3 composantes : 4 + 2 × 4 × 3 - 2 = 28 caractères
expect(hash).toHaveLength(28)
// Photo en noir et blanc : l'aperçu reste gris (rouge, vert et bleu proches)
const px = decode(hash as string, 8, 8)
const [r, g, b] = [px[0], px[1], px[2]]
expect(Math.max(r, g, b) - Math.min(r, g, b)).toBeLessThan(24)
})
it('renvoie null pour un fichier qui n’est pas une image', async () => {
expect(await computeBlurhash(Buffer.from('pas une image'))).toBeNull()
})
})
+272
View File
@@ -0,0 +1,272 @@
import type { FastifyInstance } from 'fastify'
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
// Co-gestion d'un arbre (#145), sur une vraie base : une fonction de droits se vérifie avec de vraies requêtes,
// pas avec des mocks. Lancé seulement avec TEST_DATABASE_URL (base jetable, migrée, en UTF-8 pour les emoji) :
// TEST_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/genealogy_test npx vitest run test/co-gestion.db.test.ts
const TEST_DB = process.env.TEST_DATABASE_URL
const { oidcMock } = vi.hoisted(() => ({ oidcMock: { startAuthorization: vi.fn(), finishAuthorization: vi.fn() } }))
vi.mock('../src/lib/oidc.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../src/lib/oidc.js')>()),
...oidcMock,
}))
const ISSUER = 'https://id.example.org'
let app: FastifyInstance
let env: { ALLOW_REGISTRATION: boolean; PASSWORD_LOGIN: boolean }
let prisma: typeof import('../src/lib/prisma.js')['prisma']
let ip = 0
const nextIp = () => `10.1.${Math.floor(++ip / 250)}.${ip % 250 + 1}`
const run = Date.now().toString(36)
const mail = (name: string) => `${name}-${run}@exemple.fr`
async function call(method: string, url: string, token?: string, payload?: unknown) {
const res = await app.inject({
method: method as 'GET',
url,
payload: payload as Record<string, unknown>,
headers: token ? { authorization: `Bearer ${token}` } : {},
remoteAddress: nextIp(),
})
return { status: res.statusCode, body: res.body ? res.json() : null }
}
async function register(name: string, extra: Record<string, unknown> = {}) {
const res = await call('POST', '/auth/register', undefined, { email: mail(name), password: 'mot-de-passe-solide', firstName: name, ...extra })
return res
}
async function account(name: string) {
const res = await register(name)
expect(res.status).toBe(201)
return { token: res.body.token as string, id: res.body.user.id as string, email: mail(name) }
}
describe.skipIf(!TEST_DB)('co-gestion, vraie base', () => {
let owner: { token: string; id: string; email: string }
let treeId: string
beforeAll(async () => {
process.env.NODE_ENV = 'test'
process.env.DATABASE_URL = TEST_DB
process.env.JWT_SECRET = 'test-secret-with-sufficient-length'
process.env.CORS_ORIGIN = 'http://localhost:5173'
process.env.OIDC_ISSUER = ISSUER
process.env.OIDC_CLIENT_ID = 'parenthese'
process.env.OIDC_CLIENT_SECRET = 'secret'
process.env.OIDC_REDIRECT_URI = 'https://arbre.example.org/auth/oidc/callback'
;({ env } = await import('../src/config/env.js'))
;({ prisma } = await import('../src/lib/prisma.js'))
const { createApp } = await import('../src/app.js')
app = createApp()
env.ALLOW_REGISTRATION = true
owner = await account('proprio')
const tree = await call('POST', '/trees', owner.token, { name: 'Arbre de démo', slug: `demo-${run}`, visitorPassword: 'partage-demo-1', contributorPassword: 'partage-demo-1' })
expect(tree.status).toBe(201)
treeId = tree.body.tree?.id ?? tree.body.id
})
beforeEach(() => {
env.ALLOW_REGISTRATION = true
env.PASSWORD_LOGIN = true
})
afterAll(async () => {
await app?.close()
})
async function invite(email: string) {
const res = await call('POST', `/trees/${treeId}/invitations`, owner.token, { email })
expect(res.status).toBe(201)
return res.body as { token: string; invitation: { id: string } }
}
it("un compte sans lien avec l'arbre ne voit ni ne change les gestionnaires", async () => {
const stranger = await account('inconnu')
expect((await call('GET', `/trees/${treeId}/managers`, stranger.token)).status).toBe(403)
expect((await call('POST', `/trees/${treeId}/invitations`, stranger.token, { email: stranger.email })).status).toBe(403)
expect((await call('DELETE', `/trees/${treeId}/managers/${owner.id}`, stranger.token)).status).toBe(403)
expect((await call('GET', `/trees/${treeId}/managers`)).status).toBe(401)
})
it("le lien n'ouvre rien au mauvais compte, ne sert qu'une fois, et donne les droits d'administration", async () => {
const coEmail = mail('cogest')
const { token, invitation } = await invite(coEmail)
const preview = await call('GET', `/invitations/${token}`)
expect(preview.status).toBe(200)
expect(preview.body.treeName).toBe('Arbre de démo')
expect(preview.body.emailHint).not.toContain(coEmail.split('@')[0])
// Le lien entre de mauvaises mains : un compte à une autre adresse ne l'accepte pas
const thief = await account('voleur')
expect((await call('POST', `/invitations/${token}/accept`, thief.token)).body.error).toBe('invitation_email_mismatch')
expect((await call('PATCH', `/trees/${treeId}`, thief.token, { name: 'Volé' })).status).toBe(403)
const co = await account('cogest')
const accepted = await call('POST', `/invitations/${token}/accept`, co.token)
expect(accepted.status).toBe(200)
expect(accepted.body).toEqual({ treeId, role: 'admin' })
expect((await call('POST', `/invitations/${token}/accept`, co.token)).status).toBe(404)
expect((await call('GET', `/invitations/${token}`)).body).toEqual({ error: 'invitation_used' })
expect((await prisma.treeInvitation.findUnique({ where: { id: invitation.id } }))?.acceptedUserId).toBe(co.id)
// Co-gestionnaire : modifie l'arbre, voit la liste, mais n'invite pas, ne retire personne, ne supprime pas l'arbre
expect((await call('PATCH', `/trees/${treeId}`, co.token, { description: 'Par la co-gestionnaire' })).status).toBe(200)
const list = await call('GET', `/trees/${treeId}/managers`, co.token)
expect(list.body.canManage).toBe(false)
expect(list.body.managers.map((m: { role: string }) => m.role).sort()).toEqual(['admin', 'owner'])
expect((await call('POST', `/trees/${treeId}/invitations`, co.token, { email: mail('autre') })).status).toBe(403)
expect((await call('DELETE', `/trees/${treeId}/managers/${owner.id}`, co.token)).status).toBe(403)
expect((await call('DELETE', `/trees/${treeId}`, co.token, { confirmName: 'Arbre de démo' })).status).toBe(403)
// Le propriétaire le retire : son jeton de session ne sert plus à rien sur l'arbre, tout de suite
expect((await call('DELETE', `/trees/${treeId}/managers/${co.id}`, owner.token)).status).toBe(200)
expect((await call('PATCH', `/trees/${treeId}`, co.token, { description: 'Encore moi' })).status).toBe(403)
expect((await call('GET', `/trees/${treeId}/managers`, co.token)).status).toBe(403)
})
it('un co-gestionnaire quitte seul, et ne retire jamais un autre co-gestionnaire', async () => {
const a = await account('gestion-a')
const b = await account('gestion-b')
for (const who of [a, b]) {
const { token } = await invite(who.email)
expect((await call('POST', `/invitations/${token}/accept`, who.token)).status).toBe(200)
}
expect((await call('DELETE', `/trees/${treeId}/managers/${b.id}`, a.token)).status).toBe(403)
expect((await call('DELETE', `/trees/${treeId}/managers/${a.id}`, a.token)).status).toBe(200)
expect((await call('PATCH', `/trees/${treeId}`, a.token, { description: 'x' })).status).toBe(403)
expect((await call('PATCH', `/trees/${treeId}`, b.token, { description: 'y' })).status).toBe(200)
// Le propriétaire ne se retire pas lui-même
expect((await call('DELETE', `/trees/${treeId}/managers/${owner.id}`, owner.token)).status).toBe(403)
await call('DELETE', `/trees/${treeId}/managers/${b.id}`, owner.token)
})
it('« Copier le lien » remplace le lien ; annulé ou expiré, il ne sert plus', async () => {
const email = mail('rotation')
const first = await invite(email)
const rotated = await call('POST', `/trees/${treeId}/invitations/${first.invitation.id}/link`, owner.token)
expect(rotated.status).toBe(200)
expect((await call('GET', `/invitations/${first.token}`)).status).toBe(404)
expect((await call('GET', `/invitations/${rotated.body.token}`)).status).toBe(200)
expect((await call('DELETE', `/trees/${treeId}/invitations/${first.invitation.id}`, owner.token)).status).toBe(200)
const user = await account('rotation')
expect((await call('POST', `/invitations/${rotated.body.token}/accept`, user.token)).status).toBe(404)
const late = await invite(email)
await prisma.treeInvitation.update({ where: { id: late.invitation.id }, data: { expiresAt: new Date(Date.now() - 1000) } })
expect((await call('POST', `/invitations/${late.token}/accept`, user.token)).status).toBe(404)
expect((await call('GET', `/invitations/${late.token}`)).body).toEqual({ error: 'invitation_expired' })
expect((await call('GET', `/invitations/${rotated.body.token}`)).body).toEqual({ error: 'invitation_invalid' })
})
it("inscription fermée : l'invitation ouvre un compte, à l'adresse invitée seulement", async () => {
const email = mail('ferme')
const { token } = await invite(email)
env.ALLOW_REGISTRATION = false
expect((await register('sans-invitation')).body.error).toBe('registration_closed')
expect((await register('autre-adresse', { invitationToken: token })).body.error).toBe('invitation_email_mismatch')
expect((await register('x', { invitationToken: 'faux-jeton' })).status).toBe(410)
const created = await register('ferme', { invitationToken: token })
expect(created.status).toBe(201)
expect(created.body.joinedTreeId).toBe(treeId)
expect((await call('PATCH', `/trees/${treeId}`, created.body.token, { description: 'z' })).status).toBe(200)
// Le lien a servi : il n'ouvre pas un second compte
expect((await call('POST', '/auth/register', undefined, { email: mail('ferme2'), password: 'mot-de-passe-solide', invitationToken: token })).status).toBe(410)
await call('DELETE', `/trees/${treeId}/managers/${created.body.user.id}`, owner.token)
})
it("un co-gestionnaire n'utilise jamais Immich, qui passe par la clé du propriétaire", async () => {
const co = await account('immich')
const { token } = await invite(co.email)
expect((await call('POST', `/invitations/${token}/accept`, co.token)).status).toBe(200)
const config = await call('GET', `/trees/${treeId}/immich/config`, co.token)
expect(config.status).toBe(200)
expect(config.body).toEqual({ config: null, instances: [], ownerOnly: true })
for (const url of ['/immich/assets?page=1&q=plage', '/immich/people', '/immich/status', '/immich/assets/3f2a1b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b/thumbnail', '/immich/thumbnail/3f2a1b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b']) {
expect((await call('GET', `/trees/${treeId}${url}`, co.token)).status).toBe(403)
}
expect((await call('PUT', `/trees/${treeId}/immich/config`, co.token, { instanceId: '0123456789abcdef', apiKey: 'y' })).status).toBe(403)
expect((await call('DELETE', `/trees/${treeId}/immich/config`, co.token)).status).toBe(403)
await call('DELETE', `/trees/${treeId}/managers/${co.id}`, owner.token)
})
it("retiré, un co-gestionnaire qui avait ouvert l'arbre par le mot de passe de partage ne le lit plus", async () => {
const co = await account('partage')
const unlocked = await call('POST', `/trees/${treeId}/access/unlock`, co.token, { password: 'partage-demo-1' })
expect(unlocked.status).toBe(200)
expect((await call('GET', `/trees/${treeId}/graph`, co.token)).status).toBe(200)
const { token } = await invite(co.email)
expect((await call('POST', `/invitations/${token}/accept`, co.token)).status).toBe(200)
expect((await call('DELETE', `/trees/${treeId}/managers/${co.id}`, owner.token)).status).toBe(200)
expect((await call('GET', `/trees/${treeId}/graph`, co.token)).status).toBe(403)
})
it("l'icône de l'arbre : un emoji ou rien, posée par un gestionnaire, rendue dans la liste des arbres", async () => {
expect((await call('PATCH', `/trees/${treeId}`, owner.token, { icon: 'abc' })).status).toBe(400)
expect((await call('PATCH', `/trees/${treeId}`, owner.token, { icon: '🌳' })).status).toBe(200)
const list = await call('GET', '/trees', owner.token)
expect(list.body.trees.find((t: { id: string }) => t.id === treeId).icon).toBe('🌳')
expect((await call('PATCH', `/trees/${treeId}`, owner.token, { icon: null })).status).toBe(200)
})
describe('OIDC', () => {
let address = 0
async function oidcLogin(identity: { subject: string; email: string; emailVerified: boolean }) {
const remoteAddress = `10.9.0.${++address}`
oidcMock.startAuthorization.mockImplementation(async (returnTo: string) => ({
url: new URL(`${ISSUER}/authorize?state=s`),
pending: { state: 's', nonce: 'n', codeVerifier: 'v'.repeat(43), returnTo },
}))
oidcMock.finishAuthorization.mockResolvedValue({ issuer: ISSUER, firstName: null, ...identity })
const start = await app.inject({ method: 'GET', url: '/auth/oidc/start?returnTo=/', remoteAddress })
const cookie = String(start.headers['set-cookie']).split(';')[0]
const back = await app.inject({ method: 'GET', url: '/auth/oidc/callback?code=c&state=s', headers: { cookie }, remoteAddress })
return new URLSearchParams(String(back.headers.location).split('#')[1] ?? '')
}
it("email vérifié par le fournisseur : rattaché sans le lien, même inscription fermée", async () => {
const email = mail('oidc-ok')
await invite(email)
env.ALLOW_REGISTRATION = false
const fragment = await oidcLogin({ subject: `sub-ok-${run}`, email, emailVerified: true })
expect(fragment.get('oidc')).toBeTruthy()
const user = await prisma.user.findUnique({ where: { email }, select: { id: true } })
const membership = await prisma.treeMembership.findUnique({ where: { treeId_userId: { treeId, userId: user!.id } } })
expect(membership?.role).toBe('admin')
})
it("email non vérifié : jamais rattaché, et pas de compte sur une instance fermée", async () => {
const email = mail('oidc-non-verifie')
await invite(email)
env.ALLOW_REGISTRATION = false
const fragment = await oidcLogin({ subject: `sub-nv-${run}`, email, emailVerified: false })
expect(fragment.get('oidc_error')).toBe('oidc_email_unverified')
expect(await prisma.user.findUnique({ where: { email } })).toBeNull()
})
it("identité déjà reliée dont l'email a changé chez le fournisseur : pas d'invitation prise sans le lien", async () => {
const subject = `sub-change-${run}`
expect((await oidcLogin({ subject, email: mail('avant'), emailVerified: true })).get('oidc')).toBeTruthy()
const target = mail('cible')
await invite(target)
expect((await oidcLogin({ subject, email: target, emailVerified: true })).get('oidc')).toBeTruthy()
const user = await prisma.user.findUnique({ where: { email: mail('avant') }, select: { id: true } })
expect(await prisma.treeMembership.findUnique({ where: { treeId_userId: { treeId, userId: user!.id } } })).toBeNull()
})
it("email non vérifié sur un compte déjà relié : l'invitation reste en attente", async () => {
const email = mail('oidc-relie')
const subject = `sub-relie-${run}`
expect((await oidcLogin({ subject, email, emailVerified: true })).get('oidc')).toBeTruthy()
await invite(email)
expect((await oidcLogin({ subject, email, emailVerified: false })).get('oidc')).toBeTruthy()
const user = await prisma.user.findUnique({ where: { email }, select: { id: true } })
expect(await prisma.treeMembership.findUnique({ where: { treeId_userId: { treeId, userId: user!.id } } })).toBeNull()
})
})
})
+148 -1
View File
@@ -4,10 +4,16 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import type { FastifyInstance } from 'fastify'
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
const { prismaMock } = vi.hoisted(() => ({
prismaMock: {
// Sessions fermées (lib/token-revocation.ts), rechargées au démarrage
revokedSession: {
upsert: vi.fn().mockResolvedValue({}),
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
user: {
findUnique: vi.fn(),
count: vi.fn(),
@@ -28,6 +34,9 @@ const { prismaMock } = vi.hoisted(() => ({
contributionSession: {
updateMany: vi.fn(),
},
treeVisit: {
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
$transaction: vi.fn(),
$disconnect: vi.fn(),
},
@@ -50,6 +59,7 @@ let storageRoot = ''
let passwordHash = ''
let createApp: () => FastifyInstance
let resolveTreeMediaDirectory: (treeId: string, root?: string) => string | null
let env: { PASSWORD_LOGIN: boolean }
function applyTestEnv(): void {
process.env.NODE_ENV = 'test'
@@ -94,6 +104,7 @@ beforeAll(async () => {
createApp = (await import('../src/app.js')).createApp
resolveTreeMediaDirectory = (await import('../src/lib/tree-purge.js')).resolveTreeMediaDirectory
;({ env } = await import('../src/config/env.js'))
})
afterAll(async () => {
@@ -152,6 +163,138 @@ describe('DELETE /auth/me', () => {
await app.close()
})
describe("compte sans mot de passe (né d'une connexion OIDC)", () => {
const signedAt = async (app: FastifyInstance, minutesAgo: number) => {
await app.ready()
return (app as any).jwt.sign({
kind: 'user', sub: 'user-1', jti: randomUUID(), userId: 'user-1', email: 'user@example.com',
iat: Math.floor(Date.now() / 1000) - minutesAgo * 60,
}, { expiresIn: '1h' })
}
beforeEach(() => {
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-1', email: 'user@example.com', passwordHash: null })
})
it('se supprime avec son email retapé, juste après une connexion', async () => {
const app = createApp()
const response = await app.inject({
method: 'DELETE', url: '/auth/me',
headers: { authorization: `Bearer ${await signedAt(app, 2)}` },
payload: { confirmEmail: ' User@Example.com ' },
})
expect(response.statusCode).toBe(200)
expect(prismaMock.user.delete).toHaveBeenCalled()
await app.close()
})
it('refuse un autre email', async () => {
const app = createApp()
const response = await app.inject({
method: 'DELETE', url: '/auth/me',
headers: { authorization: `Bearer ${await signedAt(app, 2)}` },
payload: { confirmEmail: 'autre@example.com' },
})
expect(response.statusCode).toBe(403)
expect(response.json()).toEqual({ error: 'invalid_password' })
expect(prismaMock.user.delete).not.toHaveBeenCalled()
await app.close()
})
it('refuse une session de plus de 15 minutes, même avec le bon email', async () => {
const app = createApp()
const response = await app.inject({
method: 'DELETE', url: '/auth/me',
headers: { authorization: `Bearer ${await signedAt(app, 20)}` },
payload: { confirmEmail: 'user@example.com' },
})
expect(response.statusCode).toBe(403)
expect(response.json()).toEqual({ error: 'recent_sign_in_required' })
expect(prismaMock.user.delete).not.toHaveBeenCalled()
await app.close()
})
it("n'accepte pas un email à la place du mot de passe pour un compte qui en a un", async () => {
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-1', email: 'user@example.com', passwordHash })
const app = createApp()
const response = await app.inject({
method: 'DELETE', url: '/auth/me',
headers: { authorization: `Bearer ${await signedAt(app, 2)}` },
payload: { confirmEmail: 'user@example.com' },
})
expect(response.statusCode).toBe(403)
expect(prismaMock.user.delete).not.toHaveBeenCalled()
await app.close()
}, 15000)
})
describe('compte à mot de passe relié à OIDC, connexion par mot de passe fermée (PASSWORD_LOGIN=false)', () => {
const signedAt = async (app: FastifyInstance, minutesAgo: number) => {
await app.ready()
return (app as any).jwt.sign({
kind: 'user', sub: 'user-1', jti: randomUUID(), userId: 'user-1', email: 'user@example.com',
iat: Math.floor(Date.now() / 1000) - minutesAgo * 60,
}, { expiresIn: '1h' })
}
beforeEach(() => {
env.PASSWORD_LOGIN = false
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-1', email: 'user@example.com', passwordHash })
})
afterEach(() => {
env.PASSWORD_LOGIN = true
})
it('se supprime avec son email retapé, juste après une connexion (le mot de passe ne peut plus servir)', async () => {
const app = createApp()
const response = await app.inject({
method: 'DELETE', url: '/auth/me',
headers: { authorization: `Bearer ${await signedAt(app, 2)}` },
payload: { confirmEmail: 'user@example.com' },
})
expect(response.statusCode).toBe(200)
expect(prismaMock.user.delete).toHaveBeenCalled()
await app.close()
})
it('refuse une session de plus de 15 minutes, même avec le bon email', async () => {
const app = createApp()
const response = await app.inject({
method: 'DELETE', url: '/auth/me',
headers: { authorization: `Bearer ${await signedAt(app, 20)}` },
payload: { confirmEmail: 'user@example.com' },
})
expect(response.statusCode).toBe(403)
expect(response.json()).toEqual({ error: 'recent_sign_in_required' })
expect(prismaMock.user.delete).not.toHaveBeenCalled()
await app.close()
})
it('refuse un autre email', async () => {
const app = createApp()
const response = await app.inject({
method: 'DELETE', url: '/auth/me',
headers: { authorization: `Bearer ${await signedAt(app, 2)}` },
payload: { confirmEmail: 'autre@example.com' },
})
expect(response.statusCode).toBe(403)
expect(prismaMock.user.delete).not.toHaveBeenCalled()
await app.close()
})
it('dit à l’écran de demander l’email, pas un mot de passe (hasPassword=false)', async () => {
const app = createApp()
const response = await app.inject({
method: 'GET', url: '/auth/me',
headers: { authorization: `Bearer ${await signedAt(app, 2)}` },
})
expect(response.statusCode).toBe(200)
expect(response.json().user.hasPassword).toBe(false)
await app.close()
})
})
it('rejects a wrong password without deleting anything', async () => {
const ownedFile = await writeMediaFile('tree-owned', 'person-1/photo.jpg')
const app = createApp()
@@ -214,6 +357,10 @@ describe('DELETE /auth/me', () => {
where: { submittedByUserId: 'user-1' },
data: { submittedByLabel: 'Contributeur' },
})
// Ses visites d'autres arbres restent comptées, sans son identifiant
const visitUpdate = prismaMock.treeVisit.updateMany.mock.calls[0][0]
expect(visitUpdate.where).toEqual({ visitorKey: 'user:user-1' })
expect(visitUpdate.data.visitorKey).toMatch(/^[0-9a-f]{32}$/)
expect(prismaMock.user.delete).toHaveBeenCalledWith({ where: { id: 'user-1' } })
// Les arbres doivent partir avant l'utilisateur (relation propriétaire en Restrict).
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { personPrecisionResets, unionPrecisionResets } from '../src/lib/date-precision'
const d = (iso: string) => new Date(`${iso}T00:00:00.000Z`)
describe('précision d’une date corrigée à la main', () => {
const current = { birthDate: d('1879-01-01'), deathDate: d('1950-05-02') }
it('une date changée perd sa précision, une date renvoyée telle quelle la garde', () => {
expect(personPrecisionResets(current, { birthDate: d('1880-03-12'), deathDate: d('1950-05-02') }))
.toEqual({ birthDatePrecision: null, birthDateEnd: null })
expect(personPrecisionResets(current, { birthDate: d('1879-01-01') })).toEqual({})
expect(personPrecisionResets(current, { deathDate: null })).toEqual({ deathDatePrecision: null, deathDateEnd: null })
expect(personPrecisionResets(current, {})).toEqual({})
})
it('mariage', () => {
expect(unionPrecisionResets({ startDate: d('1905-01-01') }, { startDate: d('1906-01-01') })).toEqual({ startDatePrecision: null })
expect(unionPrecisionResets({ startDate: d('1905-01-01') }, { startDate: d('1905-01-01') })).toEqual({})
expect(unionPrecisionResets({ startDate: d('1905-01-01') }, {})).toEqual({})
})
})
+79
View File
@@ -0,0 +1,79 @@
import type { FastifyInstance } from 'fastify'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
// Une session fermée reste fermée après un redémarrage de l'API (un déploiement), sur une vraie base. Avant, la
// déconnexion ne vivait qu'en mémoire et le jeton redevenait valable jusqu'à 7 jours. Lancé seulement avec
// TEST_DATABASE_URL (base jetable, migrée) :
// TEST_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/genealogy_test npx vitest run test/deconnexion.db.test.ts
const TEST_DB = process.env.TEST_DATABASE_URL
let app: FastifyInstance
let ip = 0
const nextIp = () => `10.5.${Math.floor(++ip / 250)}.${ip % 250 + 1}`
const run = Date.now().toString(36)
async function call(method: string, url: string, token?: string, payload?: unknown) {
const res = await app.inject({
method: method as 'GET',
url,
payload: payload as Record<string, unknown>,
headers: token ? { authorization: `Bearer ${token}` } : {},
remoteAddress: nextIp(),
})
return { status: res.statusCode, body: res.body ? res.json() : null }
}
// Nouveau processus : la mémoire des déconnexions repart de zéro, puis l'API la recharge depuis la base
async function restart() {
await app.close()
const { forgetRevokedTokensInMemory } = await import('../src/lib/token-revocation.js')
forgetRevokedTokensInMemory()
const { createApp } = await import('../src/app.js')
app = createApp()
await app.ready()
}
async function login(name: string) {
const email = `${name}-${run}@exemple.fr`
const reg = await call('POST', '/auth/register', undefined, { email, password: 'mot-de-passe-solide', firstName: name })
expect(reg.status).toBe(201)
return reg.body.token as string
}
describe.skipIf(!TEST_DB)('déconnexion durable, vraie base', () => {
beforeAll(async () => {
process.env.NODE_ENV = 'test'
process.env.DATABASE_URL = TEST_DB
process.env.JWT_SECRET = 'test-secret-with-sufficient-length'
process.env.CORS_ORIGIN = 'http://localhost:5173'
const { env } = await import('../src/config/env.js')
env.ALLOW_REGISTRATION = true
const { createApp } = await import('../src/app.js')
app = createApp()
})
afterAll(async () => {
await app?.close()
})
it('un jeton déconnecté reste refusé après un redémarrage', async () => {
const token = await login('deconnecte')
const other = await login('reste')
expect((await call('GET', '/auth/me', token)).status).toBe(200)
expect((await call('POST', '/auth/logout', token)).status).toBe(200)
expect((await call('GET', '/auth/me', token)).status).toBe(401)
await restart()
expect((await call('GET', '/auth/me', token)).status).toBe(401)
// Une autre session, jamais fermée, passe toujours
expect((await call('GET', '/auth/me', other)).status).toBe(200)
})
it('une déconnexion expirée est effacée au démarrage', async () => {
const { prisma } = await import('../src/lib/prisma.js')
const jti = `expire-${run}`
await prisma.revokedSession.create({ data: { jti, expiresAt: new Date(Date.now() - 1000) } })
await restart()
expect(await prisma.revokedSession.findUnique({ where: { jti } })).toBeNull()
})
})
+141
View File
@@ -14,6 +14,12 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
const { prismaMock } = vi.hoisted(() => ({
prismaMock: {
// Sessions fermées (lib/token-revocation.ts), rechargées au démarrage
revokedSession: {
upsert: vi.fn().mockResolvedValue({}),
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
user: {
findUnique: vi.fn(),
count: vi.fn().mockResolvedValue(1),
@@ -77,6 +83,11 @@ const { prismaMock } = vi.hoisted(() => ({
update: vi.fn(),
updateMany: vi.fn(),
},
lifeEvent: {
findMany: vi.fn().mockResolvedValue([]),
createMany: vi.fn().mockResolvedValue({ count: 0 }),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
annotation: {
findMany: vi.fn(),
},
@@ -91,6 +102,11 @@ const { prismaMock } = vi.hoisted(() => ({
create: vi.fn(),
update: vi.fn(),
},
mediaPersonTag: {
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
createMany: vi.fn().mockResolvedValue({ count: 0 }),
},
auditLog: {
create: vi.fn(),
findMany: vi.fn(),
@@ -273,6 +289,7 @@ describe('Journey 1 — Owner creates account, tree, and adds family', () => {
},
})
expect(addSelfRes.statusCode).toBe(201)
expect(prismaMock.person.create.mock.calls.at(-1)[0].data).toMatchObject({ sex: 'male' })
// Step 4: Add parent
prismaMock.person.create.mockResolvedValue({ id: 'person-2', firstName: 'Jean', lastName: 'Martin' })
@@ -729,3 +746,127 @@ describe('Journey 4 — Admin reviews a contribution session', () => {
await app.close()
})
})
describe('Surnom (nickname) proposé par un contributeur', () => {
it('soumis en attente, puis appliqué par le propriétaire avec la valeur nettoyée', async () => {
const app = createApp()
await app.ready()
const contributorToken = (app as any).jwt.sign({
kind: 'tree_access',
sub: 'tree:tree-1:contributor',
treeId: 'tree-1',
role: 'contributor',
accessVersion: new Date('2026-02-11T10:00:00.000Z').getTime(),
})
prismaMock.tree.findFirst.mockResolvedValue({ id: 'tree-1', deletedAt: null, settings: { contributorPolicy: 'pending' } })
const submitRes = await app.inject({
method: 'POST',
url: '/trees/tree-1/contributions/sessions',
headers: { authorization: `Bearer ${contributorToken}` },
payload: {
title: 'Surnom de Robert',
submittedByLabel: 'Jeanette Martin',
changes: [
{
entityType: 'person',
action: 'update',
entityId: 'person-1',
before: { nickname: null },
after: { nickname: ' Loulou ' },
},
],
},
})
expect(submitRes.statusCode).toBe(201)
expect(submitRes.json().session.status).toBe('pending')
const adminToken = (app as any).jwt.sign(
{ kind: 'user', sub: 'user-1', jti: randomUUID(), userId: 'user-1', email: 'owner@example.com' },
{ expiresIn: '1h' },
)
prismaMock.contributionSession.findFirst.mockResolvedValue({
id: 'session-n',
treeId: 'tree-1',
status: 'pending',
title: 'Surnom de Robert',
changes: [
{
id: 'change-n',
entityType: 'person',
action: 'update',
entityId: 'person-1',
beforeJson: { nickname: null },
afterJson: { nickname: ' Loulou ' },
conflictState: 'none',
},
],
})
prismaMock.person.findFirst.mockResolvedValue({ id: 'person-1', treeId: 'tree-1', firstName: 'Robert', lastName: 'Dupont', nickname: null, deletedAt: null })
const reviewRes = await app.inject({
method: 'POST',
url: '/trees/tree-1/contributions/sessions/session-n/review',
headers: { authorization: `Bearer ${adminToken}` },
payload: { decision: 'approved' },
})
expect(reviewRes.statusCode).toBe(200)
expect(reviewRes.json().approvedCount).toBe(1)
expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ nickname: 'Loulou' })
await app.close()
})
})
describe('Sexe proposé par un contributeur (issue #114)', () => {
it('appliqué par le propriétaire avec la valeur normalisée', async () => {
const app = createApp()
await app.ready()
const adminToken = (app as any).jwt.sign(
{ kind: 'user', sub: 'user-1', jti: randomUUID(), userId: 'user-1', email: 'owner@example.com' },
{ expiresIn: '1h' },
)
prismaMock.contributionSession.findFirst.mockResolvedValue({
id: 'session-s',
treeId: 'tree-1',
status: 'pending',
title: 'Sexe de Camille',
changes: [
{
id: 'change-s1',
entityType: 'person',
action: 'update',
entityId: 'person-1',
beforeJson: { sex: null },
afterJson: { sex: 'F' },
conflictState: 'none',
},
{
id: 'change-s2',
entityType: 'person',
action: 'create',
entityId: null,
beforeJson: null,
afterJson: { firstName: 'Léon', lastName: 'Martin', sex: 'male' },
conflictState: 'none',
},
],
})
prismaMock.person.findFirst.mockResolvedValue({ id: 'person-1', treeId: 'tree-1', firstName: 'Camille', lastName: 'Martin', sex: null, deletedAt: null })
const reviewRes = await app.inject({
method: 'POST',
url: '/trees/tree-1/contributions/sessions/session-s/review',
headers: { authorization: `Bearer ${adminToken}` },
payload: { decision: 'approved' },
})
expect(reviewRes.statusCode).toBe(200)
expect(reviewRes.json().approvedCount).toBe(2)
expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ sex: 'female' })
expect(prismaMock.person.create.mock.calls.at(-1)[0].data).toMatchObject({ sex: 'male' })
await app.close()
})
})
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest'
import { parseGedcomDate } from '../src/lib/gedcom-dates'
const iso = (value: string) => parseGedcomDate(value)?.iso ?? null
describe('dates GEDCOM', () => {
it('dates simples', () => {
expect(iso('15 JAN 1900')).toBe('1900-01-15')
expect(iso('JAN 1900')).toBe('1900-01')
expect(iso('1900')).toBe('1900')
expect(iso('856')).toBe('0856')
expect(iso('3 mar 1879')).toBe('1879-03-03')
})
it('précision gardée', () => {
expect(parseGedcomDate('ABT 1900')).toMatchObject({ iso: '1900', precision: 'about' })
expect(parseGedcomDate('BEF 12 MAY 1920')).toMatchObject({ iso: '1920-05-12', precision: 'before' })
expect(parseGedcomDate('AFT 1850')).toMatchObject({ iso: '1850', precision: 'after' })
expect(parseGedcomDate('EST 1700')).toMatchObject({ precision: 'estimated' })
expect(parseGedcomDate('CAL 1801')).toMatchObject({ precision: 'calculated' })
})
it('intervalles', () => {
expect(parseGedcomDate('BET 1794 AND 1796')).toEqual({ iso: '1794', endIso: '1796', precision: 'between', text: 'BET 1794 AND 1796' })
expect(parseGedcomDate('FROM 1914 TO 1918')).toMatchObject({ iso: '1914', endIso: '1918', precision: 'from' })
})
it('date interprétée et texte entre parenthèses', () => {
expect(parseGedcomDate('INT 1900 (vers la fin du siècle)')).toMatchObject({ iso: '1900', precision: 'interpreted' })
expect(iso('(pendant la guerre)')).toBeNull()
})
it('double année', () => {
expect(iso('12 FEB 1700/01')).toBe('1701-02-12')
expect(iso('1699/00')).toBe('1700')
})
it('calendrier julien : la date reste celle écrite', () => {
expect(iso('@#DJULIAN@ 1563')).toBe('1563')
expect(iso('@#DJULIAN@ 3 SEP 1599')).toBe('1599-09-03')
})
it('calendrier républicain converti', () => {
expect(iso('@#DFRENCH R@ 18 BRUM 8')).toBe('1799-11-09')
expect(iso('@#DFRENCH R@ 1 VEND 1')).toBe('1792-09-22')
expect(iso('@#DFRENCH R@ 9 THER 2')).toBe('1794-07-27')
expect(iso('@#DFRENCH R@ GERM 3')).toBe('1795-03')
expect(iso('@#DFRENCH R@ 5')).toBe('1796')
})
it('illisible : rien', () => {
expect(iso('')).toBeNull()
expect(iso('vers 1900')).toBeNull()
expect(iso('@#DHEBREW@ 5660')).toBeNull()
expect(iso('32 JAN 1900')).toBeNull()
})
})
+98
View File
@@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest'
import { adoptedFamilies, familyExtras, personExtras } from '../src/lib/gedcom-events'
import { indexGedcomRecords } from '../src/lib/gedcom-photos'
import { parseGedcom } from '../src/routes/gedcom'
const text = [
'0 @I1@ INDI',
'1 NAME Louis /RAGOT/',
'2 NICK Loulou',
'1 BIRT',
'2 DATE ABT 1879',
'2 PLAC La Roche-sur-Yon,85191,Vendée,Pays de la Loire,France,',
'2 SOUR @S1@',
'3 PAGE Acte n° 12',
'1 BAPM',
'2 DATE 9 MAR 1879',
'2 PLAC La Roche-sur-Yon,85191,Vendée,,France,',
'1 OCCU Instituteur',
'2 DATE FROM 1900 TO 1935',
'2 PLAC Limoges',
'1 OCCU Maire',
'2 DATE 1936',
'1 RESI',
'2 ADDR 12 rue des Écoles',
'3 CITY Limoges',
'1 EVEN',
'2 TYPE Militaire',
'2 DATE BET 1914 AND 1918',
'2 NOTE Blessé à Verdun.',
'1 DEAT',
'2 DATE @#DFRENCH R@ 18 BRUM 8',
'2 PLAC Nantes,44109,Loire-Atlantique,Pays de la Loire,France,',
'1 BURI',
'2 PLAC Cimetière de Miséricorde, Nantes',
'1 FAMC @F0@',
'2 PEDI adopted',
'1 FAMC @F9@',
'0 @F1@ FAM',
'1 HUSB @I1@',
'1 MARR',
'2 DATE EST 1905',
'2 PLAC Limoges,87085,Haute-Vienne,Nouvelle-Aquitaine,France,',
'2 SOUR Registre des mariages de Limoges',
'1 DIV Y',
'0 @S1@ SOUR',
'1 TITL Registre des naissances',
].join('\n')
const roots = parseGedcom(text)
const records = indexGedcomRecords(roots)
const indi = roots.find((n) => n.tag === 'INDI')!
const fam = roots.find((n) => n.tag === 'FAM')!
describe('événements de vie d’une personne', () => {
const extras = personExtras(indi as never, records)
const byType = (type: string) => extras.events.filter((e) => e.type === type)
it('précision des dates, lieu de décès, surnom', () => {
expect(extras.birthDatePrecision).toBe('about')
expect(extras.deathDatePrecision).toBeNull()
expect(extras.deathPlace).toBe('Nantes, Loire-Atlantique, Pays de la Loire, France')
expect(extras.nickname).toBe('Loulou')
})
it('naissance : seulement ses sources, sans recopier la date ni le lieu', () => {
expect(byType('birth')).toEqual([expect.objectContaining({ sources: 'Registre des naissances, Acte n° 12', date: null, place: null })])
})
it('baptême, métiers successifs, résidence, inhumation', () => {
expect(byType('baptism')[0]).toMatchObject({ place: 'La Roche-sur-Yon, Vendée, France', datePrecision: null })
expect(byType('baptism')[0].date?.toISOString().slice(0, 10)).toBe('1879-03-09')
expect(byType('occupation').map((e) => e.description)).toEqual(['Instituteur', 'Maire'])
expect(byType('occupation')[0]).toMatchObject({ datePrecision: 'from', dateText: 'FROM 1900 TO 1935', place: 'Limoges' })
expect(byType('occupation')[0].dateEnd?.getUTCFullYear()).toBe(1935)
expect(byType('residence')[0].place).toBe('12 rue des Écoles, Limoges')
expect(byType('burial')[0].place).toBe('Cimetière de Miséricorde, Nantes')
})
it('événement libre (Heredis « Militaire ») avec sa note', () => {
expect(byType('custom')[0]).toMatchObject({ label: 'Militaire', datePrecision: 'between', notes: 'Blessé à Verdun.' })
})
it('adoption déclarée par PEDI', () => {
expect([...adoptedFamilies(indi as never)]).toEqual(['F0'])
})
})
describe('événements d’un couple', () => {
const extras = familyExtras(fam as never, records)
it('lieu et précision du mariage, sources du mariage, divorce', () => {
expect(extras.startPlace).toBe('Limoges, Haute-Vienne, Nouvelle-Aquitaine, France')
expect(extras.startDatePrecision).toBe('estimated')
expect(extras.events.map((e) => e.type)).toEqual(['marriage', 'divorce'])
expect(extras.events[0]).toMatchObject({ sources: 'Registre des mariages de Limoges', place: null })
})
})
+232
View File
@@ -0,0 +1,232 @@
import { describe, expect, it } from 'vitest'
import { indexGedcomRecords, localMediaPath, mediaRefsForPerson, photoPathForPerson, plainNoteText } from '../src/lib/gedcom-photos'
import { parseGedcom } from '../src/routes/gedcom'
// Pour chaque personne : son portrait et ses souvenirs, tels que l'import les renvoie
function mediaOf(text: string) {
const roots = parseGedcom(text)
const records = indexGedcomRecords(roots)
const out: Record<string, { portrait: string | null; media: ReturnType<typeof mediaRefsForPerson> }> = {}
for (const indi of roots.filter((n) => n.tag === 'INDI' && n.xref)) {
const portrait = photoPathForPerson(indi, records.obje)
out[indi.xref!] = { portrait, media: mediaRefsForPerson(indi, records, portrait) }
}
return out
}
describe('souvenirs cités par un GEDCOM', () => {
it('Heredis PC : média sous la source citée d\u2019un événement, titre dans la note', () => {
const text = [
'0 HEAD',
'1 SOUR Heredis PC2023',
'0 @I1@ INDI',
'1 NAME Louis Eugène Lucien /RAGOT/',
'1 BIRT',
'2 DATE 1879',
'2 SOUR @S1@',
'3 PAGE Acte n° 12',
'3 OBJE @O1@',
'0 @S1@ SOUR',
'1 TITL Registre des naissances, Vendée',
'0 @O1@ OBJE',
'1 FILE C:/Users/Moi/Documents/Heredis/Médias/Naissance Ragot Louis Eugène Lucien.jpg',
'2 FORM JPG',
'1 NOTE Naissance Ragot Louis Eugène Lucien',
].join('\n')
expect(mediaOf(text).I1).toEqual({
portrait: null,
media: [{
file: 'C:/Users/Moi/Documents/Heredis/Médias/Naissance Ragot Louis Eugène Lucien.jpg',
caption: 'Naissance Ragot Louis Eugène Lucien',
source: 'Registre des naissances, Vendée, Acte n° 12',
}],
})
})
it('Heredis : média accroché à la fiche de la source elle-même', () => {
const text = [
'0 @I1@ INDI',
'1 NAME Louis /RAGOT/',
'1 EVEN',
'2 TYPE Militaire',
'2 SOUR @S2@',
'0 @S2@ SOUR',
'1 TITL Fiche matriculaire',
'1 OBJE @O2@',
'0 @O2@ OBJE',
'1 FILE D:\\Généalogie\\Ragot Louis fiche matriculaire.pdf',
'2 FORM PDF',
].join('\n')
expect(mediaOf(text).I1.media).toEqual([{
file: 'D:\\Généalogie\\Ragot Louis fiche matriculaire.pdf',
caption: 'Ragot Louis fiche matriculaire',
source: 'Fiche matriculaire',
}])
})
it('Heredis Mac : note de titre en RTF', () => {
expect(plainNoteText([
'{\\rtf1\\ansi\\ansicpg1252\\cocoartf1561\\cocoasubrtf400',
'{\\fonttbl\\f0\\fswiss\\fcharset0 Helvetica;}',
'{\\colortbl;\\red255\\green255\\blue255;}',
'{\\*\\expandedcolortbl;;}',
'\\pard\\tx560\\tx1120\\pardirnatural\\partightenfactor0',
'',
"\\f0\\fs24 \\cf0 acte mariage Th\\'e9ophane V\\'c9NIEN avec Emma JEANDOT}",
].join('\n'))).toBe('acte mariage Théophane VÉNIEN avec Emma JEANDOT')
})
it('Gramps : renvoi sous un événement, titre sous FILE (GEDCOM 5.5.1)', () => {
const text = [
'0 @I1@ INDI',
'1 NAME Marie /Martin/',
'1 OBJE @M1@',
'1 MARR',
'2 OBJE @M2@',
'0 @M1@ OBJE',
'1 FILE photos/marie.jpg',
'2 FORM jpg',
'0 @M2@ OBJE',
'1 FILE photos/mariage 1950.png',
'2 FORM png',
'2 TITL Mariage de Marie et Paul',
].join('\n')
expect(mediaOf(text).I1).toEqual({
portrait: 'photos/marie.jpg',
media: [{ file: 'photos/mariage 1950.png', caption: 'Mariage de Marie et Paul', source: null }],
})
})
it('RootsMagic : OBJE écrit sous la personne, le portrait _PRIM n\u2019est pas repris en souvenir', () => {
const text = [
'0 @I1@ INDI',
'1 OBJE',
'2 FILE C:\\RootsMagic\\Media\\jean portrait.jpg',
'2 FORM jpg',
'2 TITL Jean',
'2 _PRIM Y',
'1 OBJE',
'2 FILE C:\\RootsMagic\\Media\\recensement 1906.jpg',
'2 FORM jpg',
'2 TITL Recensement de 1906',
'2 _TYPE PHOTO',
].join('\n')
expect(mediaOf(text).I1).toEqual({
portrait: 'C:\\RootsMagic\\Media\\jean portrait.jpg',
media: [{ file: 'C:\\RootsMagic\\Media\\recensement 1906.jpg', caption: 'Recensement de 1906', source: null }],
})
})
it('Family Tree Maker : _PHOTO désigne le portrait, les autres deviennent des souvenirs', () => {
const text = [
'0 @I1@ INDI',
'1 _PHOTO @O2@',
'1 OBJE @O1@',
'1 OBJE @O2@',
'0 @O1@ OBJE',
'1 FILE C:\\FTM\\Media\\groupe 1930.jpg',
'2 FORM jpg',
'2 TITL Toute la famille en 1930',
'0 @O2@ OBJE',
'1 FILE C:\\FTM\\Media\\portrait.jpg',
'2 FORM jpg',
].join('\n')
expect(mediaOf(text).I1).toEqual({
portrait: 'C:\\FTM\\Media\\portrait.jpg',
media: [{ file: 'C:\\FTM\\Media\\groupe 1930.jpg', caption: 'Toute la famille en 1930', source: null }],
})
})
it('Ancestry, MyHeritage : les adresses web ne sont pas des fichiers de l\u2019ordinateur', () => {
const text = [
'0 @I1@ INDI',
'1 OBJE',
'2 FILE https://mediasvc.ancestry.com/v2/image/namespaces/1093/media/abc.jpg',
'2 FORM jpg',
'1 OBJE',
'2 FORM url',
'2 FILE www.exemple.org/acte',
].join('\n')
expect(mediaOf(text).I1.media).toEqual([])
})
it('GEDCOM 7 : chemin en file://, type MIME et titre sous FILE', () => {
const text = [
'0 HEAD',
'1 GEDC',
'2 VERS 7.0',
'0 @I1@ INDI',
'1 OBJE @O1@',
'0 @O1@ OBJE',
'1 FILE file:///C:/Archives/acte%20de%20d%C3%A9c%C3%A8s.pdf',
'2 FORM application/pdf',
'2 TITL Acte de décès',
].join('\n')
expect(mediaOf(text).I1.media).toEqual([{ file: 'C:/Archives/acte de décès.pdf', caption: 'Acte de décès', source: null }])
})
it('acte de mariage accroché à la famille : souvenir des deux conjoints', () => {
const text = [
'0 @I1@ INDI',
'1 NAME Paul /Durand/',
'1 FAMS @F1@',
'0 @I2@ INDI',
'1 NAME Jeanne /Petit/',
'1 FAMS @F1@',
'0 @I3@ INDI',
'1 NAME Enfant /Durand/',
'1 FAMC @F1@',
'0 @F1@ FAM',
'1 HUSB @I1@',
'1 WIFE @I2@',
'1 CHIL @I3@',
'1 MARR',
'2 DATE 1922',
'2 SOUR Registre des mariages de Lille',
'3 OBJE',
'4 FILE mariages/durand-petit 1922.jpg',
'4 FORM jpg',
].join('\n')
const out = mediaOf(text)
const expected = [{ file: 'mariages/durand-petit 1922.jpg', caption: 'durand-petit 1922', source: 'Registre des mariages de Lille' }]
expect(out.I1.media).toEqual(expected)
expect(out.I2.media).toEqual(expected)
expect(out.I3.media).toEqual([])
})
it('un fichier cité deux fois n\u2019est gardé qu\u2019une fois', () => {
const text = [
'0 @I1@ INDI',
'1 BIRT',
'2 OBJE @O1@',
'1 BAPM',
'2 OBJE @O1@',
'1 OBJE',
'2 FILE C:\\medias\\ACTE.PDF',
'0 @O1@ OBJE',
'1 FILE C:\\medias\\acte.pdf',
].join('\n')
expect(mediaOf(text).I1.media.map((m) => m.file)).toEqual(['C:\\medias\\acte.pdf'])
})
})
describe('localMediaPath', () => {
it('garde les chemins locaux de fichiers connus, écarte le reste', () => {
expect(localMediaPath('C:\\Photos\\jean.JPG')).toBe('C:\\Photos\\jean.JPG')
expect(localMediaPath('/Users/moi/Heredis/acte.pdf')).toBe('/Users/moi/Heredis/acte.pdf')
expect(localMediaPath('file:///home/moi/voix.mp3')).toBe('/home/moi/voix.mp3')
expect(localMediaPath('https://exemple.org/photo.jpg')).toBeNull()
expect(localMediaPath('C:\\Photos\\arbre.ged')).toBeNull()
expect(localMediaPath('')).toBeNull()
})
it('fichier piégé : sources en chaîne et niveaux trop profonds, sans débordement ni lenteur', () => {
const chain = Array.from({ length: 20000 }, (_, i) => [`0 @S${i}@ SOUR`, `1 SOUR @S${i + 1}@`]).flat()
const deep = Array.from({ length: 5000 }, (_, i) => `${i + 2} NOTE niveau`)
const text = ['0 @I1@ INDI', '1 NAME A /B/', '1 SOUR @S0@', '1 BIRT', ...deep, ...chain, '0 @S20000@ SOUR', '1 OBJE', '2 FILE C:/fin.jpg'].join('\n')
const started = Date.now()
expect(() => mediaOf(text)).not.toThrow()
expect(Date.now() - started).toBeLessThan(2000)
})
})
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import { indexGedcomRecords } from '../src/lib/gedcom-photos'
import { parseGedcom, personNotes } from '../src/routes/gedcom'
function notesOf(text: string) {
const roots = parseGedcom(text)
const records = indexGedcomRecords(roots)
const indi = roots.find((n) => n.tag === 'INDI')!
return personNotes(indi as never, records)
}
describe('notes d’une personne', () => {
it('note écrite sous la personne, sur plusieurs lignes (CONT) et coupée (CONC)', () => {
expect(notesOf([
'0 @I1@ INDI',
'1 NOTE Instituteur à Limoges pendant',
'2 CONC trente-cinq ans.',
'2 CONT Il aimait la pêche.',
].join('\n'))).toBe('Instituteur à Limoges pendant trente-cinq ans.\nIl aimait la pêche.')
})
it('note partagée par renvoi (Gramps, Geneatique, Ancestry)', () => {
expect(notesOf([
'0 @I1@ INDI',
'1 NOTE @N7@',
'0 @N7@ NOTE Revenu du front en 1919.',
'1 CONT Blessé à Verdun.',
].join('\n'))).toBe('Revenu du front en 1919.\nBlessé à Verdun.')
})
it('plusieurs notes, séparées par une ligne vide', () => {
expect(notesOf([
'0 @I1@ INDI',
'1 NOTE Première note.',
'1 NOTE @N1@',
'0 @N1@ NOTE Deuxième note.',
].join('\n'))).toBe('Première note.\n\nDeuxième note.')
})
it('aucune note, ou renvoi cassé', () => {
expect(notesOf('0 @I1@ INDI\n1 NAME Jean /Dupont/')).toBeNull()
expect(notesOf('0 @I1@ INDI\n1 NOTE @N404@')).toBeNull()
})
})
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest'
import { indexObjeRecords, photoPathForPerson } from '../src/lib/gedcom-photos'
import { parseGedcom } from '../src/routes/gedcom'
function photosOf(text: string): Record<string, string | null> {
const roots = parseGedcom(text)
const objes = indexObjeRecords(roots)
const out: Record<string, string | null> = {}
for (const indi of roots.filter((n) => n.tag === 'INDI' && n.xref)) {
out[indi.xref!] = photoPathForPerson(indi, objes)
}
return out
}
describe('photos citées par un GEDCOM', () => {
it('lit un OBJE écrit sous la personne (GEDCOM 5.5)', () => {
const text = [
'0 @I1@ INDI',
'1 NAME Jean /Dupont/',
'1 OBJE',
'2 FORM jpg',
'2 FILE C:\\Genealogie\\Photos\\jean.jpg',
].join('\n')
expect(photosOf(text)).toEqual({ I1: 'C:\\Genealogie\\Photos\\jean.jpg' })
})
it('suit un renvoi vers un enregistrement OBJE (Gramps, GEDCOM 5.5.1)', () => {
const text = [
'0 @I1@ INDI',
'1 NAME Marie /Martin/',
'1 OBJE @M7@',
'0 @M7@ OBJE',
'1 FILE photos/marie 1920.png',
'2 FORM png',
].join('\n')
expect(photosOf(text)).toEqual({ I1: 'photos/marie 1920.png' })
})
it('préfère la photo marquée principale, sinon la première image', () => {
const text = [
'0 @I1@ INDI',
'1 OBJE',
'2 FILE acte-naissance.pdf',
'1 OBJE',
'2 FILE groupe.jpg',
'1 OBJE',
'2 FILE portrait.jpg',
'2 _PRIM Y',
'0 @I2@ INDI',
'1 OBJE',
'2 FILE acte.pdf',
'1 OBJE',
'2 FILE premier.JPEG',
'1 OBJE',
'2 FILE second.jpg',
].join('\n')
expect(photosOf(text)).toEqual({ I1: 'portrait.jpg', I2: 'premier.JPEG' })
})
it('ignore les adresses web, les documents et les renvois cassés', () => {
const text = [
'0 @I1@ INDI',
'1 OBJE',
'2 FILE https://exemple.org/photo.jpg',
'1 OBJE @M404@',
'1 OBJE',
'2 FILE registre.pdf',
'0 @I2@ INDI',
'1 NAME Sans /Photo/',
].join('\n')
expect(photosOf(text)).toEqual({ I1: null, I2: null })
})
})
Loaded 100 of 403 files, more files were not shown because too many files have changed in this diff. Show more