Parenthèse

Galaxie familiale : personnes, unions et filiations, photos, vidéos et voix sur chaque personne,
partage par lien avec consultation sans compte et contributions validées par le propriétaire.
Auto-hébergeable avec Docker. Licence PolyForm Noncommercial 1.0.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5 committed 2026-09-14 15:49:05 +02:00
commit e8e2485dcd
244 files changed
+54104

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
# Contexte de build de Dockerfile.web : seuls le front et la configuration nginx sont utiles
*
!frontend
!deploy/nginx.docker.conf
frontend/node_modules
frontend/dist
frontend/.env
frontend/.env.*
!frontend/.env.example
+41
View File
@@ -0,0 +1,41 @@
# Parenthèse, configuration de l'auto-hébergement (docker compose).
# Copiez ce fichier en .env et remplissez les deux valeurs marquées "à remplir".
# Le fichier .env est ignoré par git : vos secrets restent sur votre machine.
#
# NODE_ENV, PORT, DATABASE_URL et MEDIA_STORAGE_PATH sont fixés par docker-compose.yml,
# inutile de les définir ici.
# ── Base de données (conteneur db, non exposé hors du réseau Docker) ──────────
POSTGRES_USER=parenthese
POSTGRES_DB=parenthese
# À remplir. Lettres et chiffres uniquement : la valeur est insérée dans une URL de connexion.
# Fixé au premier démarrage : pour le changer ensuite il faut recréer le volume db-data.
POSTGRES_PASSWORD=
# ── API ───────────────────────────────────────────────────────────────────────
# À remplir, 16 caractères minimum. Générer avec :
# openssl rand -base64 48
# ou : node -e "console.log(require('crypto').randomBytes(48).toString('base64'))"
# L'API refuse de démarrer en production avec la valeur de développement.
JWT_SECRET=
# Durée de vie des sessions (compte) et des accès par lien partagé.
JWT_EXPIRES_IN=7d
JWT_TREE_ACCESS_EXPIRES_IN=24h
# Adresse à laquelle vos proches ouvrent Parenthèse, sans slash final.
# En local : http://localhost (ou http://localhost:8080 si WEB_PORT=8080).
# Derrière un reverse proxy HTTPS : https://arbre.votre-domaine.fr
# Plusieurs adresses : séparées par des virgules.
CORS_ORIGIN=http://localhost
# ── Web ───────────────────────────────────────────────────────────────────────
# Port publié sur la machine hôte (80 = http://localhost sans préciser de port).
WEB_PORT=80
# ── Liste d'attente par e-mail (Resend), optionnel ────────────────────────────
# Route /waitlist de l'API, inutile à l'application. Laissez vide en auto-hébergement.
RESEND_API_KEY=
RESEND_WAITLIST_SEGMENT_ID=
RESEND_WAITLIST_AUDIENCE_ID=
RESEND_WAITLIST_FROM=
RESEND_WAITLIST_REPLY_TO=
RESEND_WAITLIST_UNSUBSCRIBE_URL=
+2
View File
@@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto
+79
View File
@@ -0,0 +1,79 @@
name: Deploy Hostinger
# Déploie Parenthèse sur le VPS à chaque push sur main. Le travail est fait sur le serveur par
# deploy/deploy.sh, incrémental et verrouillé : seules les parties modifiées sont reconstruites.
on:
push:
branches:
- main
# Rien à déployer quand seuls la doc, l'outillage ou l'auto-hébergement Docker changent
paths-ignore:
- '**/*.md'
- 'LICENSE'
- 'tools/**'
- 'docker-compose.yml'
- 'Dockerfile.web'
- 'backend/Dockerfile'
- '**/.dockerignore'
- '.env.example'
- 'deploy/nginx.docker.conf'
workflow_dispatch:
inputs:
full:
description: 'Tout réinstaller et tout reconstruire'
type: boolean
default: false
permissions:
contents: read
# Un déploiement à la fois. Un push arrivé pendant un déploiement attend son tour au lieu de le couper
# (couper un npm ci en cours laisse node_modules à moitié vide).
concurrency:
group: hostinger-production
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- name: Start SSH agent
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.HOSTINGER_SSH_PRIVATE_KEY }}
- name: Add Hostinger host key (best-effort)
env:
HOSTINGER_HOST: ${{ secrets.HOSTINGER_HOST }}
HOSTINGER_PORT: ${{ secrets.HOSTINGER_PORT }}
run: |
set -euo pipefail
PORT="${HOSTINGER_PORT:-22}"
mkdir -p ~/.ssh
# keyscan can fail if the host firewall blocks GitHub runners; make it non-fatal
ssh-keyscan -T 5 -p "$PORT" -H "$HOSTINGER_HOST" >> ~/.ssh/known_hosts || true
- name: Deploy on Hostinger
env:
HOSTINGER_HOST: ${{ secrets.HOSTINGER_HOST }}
HOSTINGER_PORT: ${{ secrets.HOSTINGER_PORT }}
HOSTINGER_USER: ${{ secrets.HOSTINGER_USER }}
FORCE_FULL: ${{ inputs.full && '1' || '0' }}
run: |
set -euo pipefail
PORT="${HOSTINGER_PORT:-22}"
# backend/.env vit sur le serveur, jamais dans GitHub (dépôt public)
ssh -p "$PORT" -o ServerAliveInterval=30 "$HOSTINGER_USER@$HOSTINGER_HOST" "FORCE_FULL=$FORCE_FULL bash -s" <<'EOF'
set -euo pipefail
cd /var/www/parenthese
# Sous le même verrou que deploy.sh : récupération du code, puis déploiement
flock -w 900 /tmp/parenthese-deploy.lock env PARENTHESE_DEPLOY_LOCKED=1 FORCE_FULL="$FORCE_FULL" bash -c '
set -euo pipefail
git fetch -q origin main
git reset -q --hard origin/main
bash deploy/deploy.sh
'
EOF
+35
View File
@@ -0,0 +1,35 @@
# Dependencies
**/node_modules/
# Build outputs
**/dist/
**/build/
**/.next/
**/coverage/
# Environment files
**/.env
**/.env.local
**/.env.*.local
**/.env*.prod-proxy
# Logs
**/*.log
# OS/editor files
.DS_Store
Thumbs.db
.idea/
# Configuration locale d'outils de développement
.claude/
# Windows reserved device-file alias accidentally listed by some shells
NUL
# Runtime uploads
backend/storage/media/
# Clés SSH / certificats
*.pem
id_*
+25
View File
@@ -0,0 +1,25 @@
# syntax=docker/dockerfile:1
# Front Parenthèse (React + Vite) servi par nginx. Contexte de build : la racine du dépôt.
# nginx sert frontend/dist et relaie l'API vers le service `api` (voir deploy/nginx.docker.conf).
FROM node:24-alpine AS build
WORKDIR /app
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
# Même origine : le front appelle /api, /trees, /auth, /health sur son propre hôte.
# Une variable vide prime sur un éventuel frontend/.env (exclu du contexte de toute façon).
ENV VITE_API_BASE_URL=""
RUN npm run build
FROM nginx:1.27-alpine
COPY deploy/nginx.docker.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
+133
View File
@@ -0,0 +1,133 @@
Required Notice: Copyright Ilies Allali (https://parenthese.io)
# PolyForm Noncommercial License 1.0.0
<https://polyformproject.org/licenses/noncommercial/1.0.0>
## Acceptance
In order to get any license under these terms, you must agree
to them as both strict obligations and conditions to all
your licenses.
## Copyright License
The licensor grants you a copyright license for the
software to do everything you might do with the software
that would otherwise infringe the licensor's copyright
in it for any permitted purpose. However, you may
only distribute the software according to [Distribution
License](#distribution-license) and make changes or new works
based on the software according to [Changes and New Works
License](#changes-and-new-works-license).
## Distribution License
The licensor grants you an additional copyright license
to distribute copies of the software. Your license
to distribute covers distributing the software with
changes and new works permitted by [Changes and New Works
License](#changes-and-new-works-license).
## Notices
You must ensure that anyone who gets a copy of any part of
the software from you also gets a copy of these terms or the
URL for them above, as well as copies of any plain-text lines
beginning with `Required Notice:` that the licensor provided
with the software. For example:
> Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
## Changes and New Works License
The licensor grants you an additional copyright license to
make changes and new works based on the software for any
permitted purpose.
## Patent License
The licensor grants you a patent license for the software that
covers patent claims the licensor can license, or becomes able
to license, that you would infringe by using the software.
## Noncommercial Purposes
Any noncommercial purpose is a permitted purpose.
## Personal Uses
Personal use for research, experiment, and testing for
the benefit of public knowledge, personal study, private
entertainment, hobby projects, amateur pursuits, or religious
observance, without any anticipated commercial application,
is use for a permitted purpose.
## Noncommercial Organizations
Use by any charitable organization, educational institution,
public research organization, public safety or health
organization, environmental protection organization,
or government institution is use for a permitted purpose
regardless of the source of funding or obligations resulting
from the funding.
## Fair Use
You may have "fair use" rights for the software under the
law. These terms do not limit them.
## No Other Rights
These terms do not allow you to sublicense or transfer any of
your licenses to anyone else, or prevent the licensor from
granting licenses to anyone else. These terms do not imply
any other licenses.
## Patent Defense
If you make any written claim that the software infringes or
contributes to infringement of any patent, your patent license
for the software granted under these terms ends immediately. If
your company makes such a claim, your patent license ends
immediately for work on behalf of your company.
## Violations
The first time you are notified in writing that you have
violated any of these terms, or done anything with the software
not covered by your licenses, your licenses can nonetheless
continue if you come into full compliance with these terms,
and take practical steps to correct past violations, within
32 days of receiving notice. Otherwise, all your licenses
end immediately.
## No Liability
***As far as the law allows, the software comes as is, without
any warranty or condition, and the licensor will not be liable
to you for any damages arising out of these terms or the use
or nature of the software, under any kind of legal claim.***
## Definitions
The **licensor** is the individual or entity offering these
terms, and the **software** is the software the licensor makes
available under these terms.
**You** refers to the individual or entity agreeing to these
terms.
**Your company** is any legal entity, sole proprietorship,
or other kind of organization that you work for, plus all
organizations that have control over, are under the control of,
or are under common control with that organization. **Control**
means ownership of substantially all the assets of an entity,
or the power to direct its management and policies by vote,
contract, or otherwise. Control can be direct or indirect.
**Your licenses** are all the licenses granted to you for the
software under these terms.
**Use** means anything you do with the software requiring one
of your licenses.
+138
View File
@@ -0,0 +1,138 @@
# Parenthèse
Une galaxie familiale plutôt qu'un arbre à cases. Chaque personne est un point, les unions et les filiations les relient, et l'on navigue dedans comme dans une carte.
Site et démo : [parenthese.io](https://parenthese.io)
## Ce que fait l'application
- Une galaxie par famille : personnes, unions, liens parent-enfant, mise en page automatique.
- Des médias sur chaque personne : photos, vidéos (fichier ou lien YouTube), enregistrements audio pour garder une voix, documents, traces GPS.
- Des annotations libres sur la galaxie : dessins, stickers, textes, photos posées où l'on veut.
- Un partage par lien : chaque galaxie a une adresse `/arbre/<nom>` protégée par un mot de passe visiteur et un mot de passe contributeur.
- Une consultation sans compte : un proche qui reçoit le lien et le mot de passe ouvre la galaxie directement.
- Des contributions validées : le propriétaire choisit si les modifications des contributeurs s'appliquent tout de suite ou passent par une file de relecture, où il approuve ou rejette chaque changement.
- Import et export GEDCOM pour venir d'un autre logiciel ou en repartir (l'export contient les personnes et leurs liens, pas les fichiers médias).
## La promesse
- Gratuit, et le restera.
- Vos données restent les vôtres : pas de publicité, pas de revente. Ce que l'application enregistre et pourquoi est détaillé sur la page [Données et vie privée](https://parenthese.io/donnees-et-vie-privee/).
- Auto-hébergeable : le code est ouvert, une famille peut faire tourner sa propre instance sur son serveur ou son NAS sans dépendre de personne.
## Auto-héberger
Prérequis : Docker avec le plugin Compose (`docker compose version` doit répondre).
```bash
git clone https://github.com/IliesAllali/parenthese.git
cd parenthese
cp .env.example .env
```
Ouvrez `.env` et remplissez les deux valeurs vides :
- `POSTGRES_PASSWORD` : un mot de passe pour la base (lettres et chiffres).
- `JWT_SECRET` : une clé longue et aléatoire. Pour la générer : `openssl rand -base64 48` ou `node -e "console.log(require('crypto').randomBytes(48).toString('base64'))"`.
Puis :
```bash
docker compose up -d
```
Le premier démarrage construit les images, 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.
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`.
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.
Commandes utiles :
```bash
docker compose logs -f api # journal de l'API
docker compose ps # état des conteneurs
docker compose down # arrêt, les données sont conservées
```
### Où sont les données
Deux volumes Docker, conservés entre les redémarrages et les mises à jour :
- `parenthese_db-data` : la base PostgreSQL (personnes, liens, comptes, contributions).
- `parenthese_media` : les fichiers envoyés (photos, vidéos, audio, documents).
### Sauvegardes
Avec Docker, une sauvegarde tient en deux commandes, à lancer depuis le dossier du dépôt :
```bash
docker compose exec -T db pg_dump -U parenthese -Fc parenthese > parenthese-db-$(date +%F).dump
docker run --rm -v parenthese_media:/data -v "$PWD":/backup alpine tar -czf /backup/parenthese-media-$(date +%F).tar.gz -C /data .
```
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`.
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`.
### Mettre à jour
```bash
git pull && docker compose up -d --build
```
Les migrations de base de données s'appliquent toutes seules au démarrage de l'API. Faites une sauvegarde avant, par habitude.
## Développer
Le dépôt contient trois projets Node : `backend/` (Fastify, Prisma, PostgreSQL, TypeScript), `frontend/` (React, Vite) et `landing/` (le site vitrine, inutile pour faire tourner l'application). Les images Docker utilisent Node 24.
### Backend
```bash
npm --prefix backend install
npm --prefix backend run db:bootstrap
npm --prefix backend run dev
```
`db:bootstrap` démarre un PostgreSQL portable sous Windows (base `genealogy`, utilisateur `postgres`, port 5432) : les binaires doivent avoir été extraits dans `backend/.local/postgresql_full/pgsql`, le script ne les télécharge pas. Sur un autre système, n'importe quel PostgreSQL local convient : renseignez `DATABASE_URL` dans `backend/.env` (modèle dans `backend/.env.example`) et créez le schéma avec `npm --prefix backend run prisma:migrate`.
L'API écoute sur [http://localhost:4000](http://localhost:4000) et répond `{ "ok": true }` sur `/health`.
### Frontend
Créez `frontend/.env.development.local` :
```
VITE_API_BASE_URL=
VITE_DEV_PROXY_TARGET=http://127.0.0.1:4000
VITE_DEV_PROXY_STRIP_API=1
```
Puis :
```bash
npm --prefix frontend install
npm --prefix frontend run dev
```
Le serveur Vite relaie `/api`, `/trees`, `/auth` et `/health` vers l'API locale en retirant le préfixe `/api`, comme le fait nginx en production. Sans ce fichier, le front appelle `http://localhost:4000` en direct et les liens partagés `/api/arbre/...` répondent 404.
### Tests
```bash
npm --prefix frontend test
npm --prefix backend test
```
## Licence
Le code est publié sous [PolyForm Noncommercial 1.0.0](LICENSE). En clair :
- Vous pouvez lire le code, le modifier et l'auto-héberger pour un usage non commercial, pour votre famille ou une association par exemple.
- Vous ne pouvez pas monter un service payant à partir de ce code.
- Le nom et le logo Parenthèse restent la propriété d'Ilies Allali.
Le texte de la licence fait foi.
+10
View File
@@ -0,0 +1,10 @@
node_modules
dist
.local
storage
test
openapi
.env
.env.*
!.env.example
*.log
+16
View File
@@ -0,0 +1,16 @@
NODE_ENV=development
PORT=4000
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/genealogy"
JWT_SECRET="change-me-in-production"
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"
# Resend waitlist (optional)
RESEND_API_KEY=""
RESEND_WAITLIST_SEGMENT_ID=""
RESEND_WAITLIST_AUDIENCE_ID=""
RESEND_WAITLIST_FROM=""
RESEND_WAITLIST_REPLY_TO=""
RESEND_WAITLIST_UNSUBSCRIBE_URL=""
+16
View File
@@ -0,0 +1,16 @@
NODE_ENV=production
PORT=4000
DATABASE_URL="postgresql://genealogy_user:STRONG_PASSWORD_HERE@localhost:5432/genealogy_prod"
JWT_SECRET="GENERATE_WITH: openssl rand -base64 48"
JWT_EXPIRES_IN="7d"
JWT_TREE_ACCESS_EXPIRES_IN="24h"
CORS_ORIGIN="https://app.parenthese.io,https://parenthese.io,https://www.parenthese.io"
MEDIA_STORAGE_PATH="/var/www/genealogy/backend/storage/media"
# Resend waitlist
RESEND_API_KEY="re_xxxxxxxxxxxxxxxxx"
RESEND_WAITLIST_SEGMENT_ID="2e8b6bc6-04fa-4bf8-8218-9a73a0db7071"
RESEND_WAITLIST_AUDIENCE_ID=""
RESEND_WAITLIST_FROM="Parenthese <waitlist@parenthese.io>"
RESEND_WAITLIST_REPLY_TO="hello@parenthese.io"
RESEND_WAITLIST_UNSUBSCRIBE_URL="https://parenthese.io/waitlist/unsubscribe"
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
.env
.local/
*.log
+58
View File
@@ -0,0 +1,58 @@
# 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 2 : image finale avec les seules dépendances de production + la CLI Prisma
# (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).
RUN apt-get update \
&& apt-get install -y --no-install-recommends openssl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package.json package-lock.json ./
COPY prisma ./prisma
RUN npm ci
RUN npx prisma generate
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
FROM node:24-bookworm-slim AS runtime
RUN apt-get update \
&& apt-get install -y --no-install-recommends openssl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Dépendances de production seules, puis la CLI Prisma épinglée sur la version
# du client déjà installé (prisma est une devDependency dans package.json).
COPY package.json package-lock.json ./
RUN npm ci --omit=dev \
&& npm install --no-save --omit=dev "prisma@$(node -p "require('@prisma/client/package.json').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`.
COPY prisma ./prisma
COPY --from=build /app/dist ./dist
RUN mkdir -p /app/storage/media && chown -R node:node /app/storage
ENV NODE_ENV=production \
PORT=4000 \
MEDIA_STORAGE_PATH=/app/storage/media
USER node
EXPOSE 4000
# Applique les migrations en attente puis démarre l'API (docker-compose.yml
# reprend la même commande explicitement).
CMD ["sh", "-c", "./node_modules/.bin/prisma migrate deploy && exec node dist/server.js"]
File diff suppressed because it is too large. Load diff
+2728
View File
File diff suppressed because it is too large. Load diff
+37
View File
@@ -0,0 +1,37 @@
{
"name": "backend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"db:bootstrap": "node scripts/db-local.mjs bootstrap",
"db:start": "node scripts/db-local.mjs start",
"db:stop": "node scripts/db-local.mjs stop",
"db:status": "node scripts/db-local.mjs status",
"dev": "tsx watch src/server.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/server.js",
"test": "vitest run",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:deploy": "prisma migrate deploy"
},
"dependencies": {
"@fastify/cors": "^10.1.0",
"@fastify/jwt": "^9.1.0",
"@fastify/rate-limit": "^10.3.0",
"@prisma/client": "^6.15.0",
"bcryptjs": "^3.0.2",
"dotenv": "^16.6.1",
"fastify": "^5.6.1",
"resend": "^6.9.2",
"zod": "^4.1.5"
},
"devDependencies": {
"@types/node": "^24.3.0",
"prisma": "^6.15.0",
"tsx": "^4.20.5",
"typescript": "^5.9.2",
"vitest": "^3.2.4"
}
}
@@ -0,0 +1,150 @@
-- Create enums
CREATE TYPE "MembershipRole" AS ENUM ('owner', 'admin', 'member');
CREATE TYPE "ContributionPolicy" AS ENUM ('direct', 'pending');
CREATE TYPE "VisitorPolicy" AS ENUM ('read_only');
-- Create tables
CREATE TABLE "users" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "trees" (
"id" TEXT NOT NULL,
"ownerUserId" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"rootPersonId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deletedAt" TIMESTAMP(3),
CONSTRAINT "trees_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "tree_access_passwords" (
"treeId" TEXT NOT NULL,
"visitorHash" TEXT NOT NULL,
"contributorHash" TEXT NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "tree_access_passwords_pkey" PRIMARY KEY ("treeId")
);
CREATE TABLE "tree_memberships" (
"id" TEXT NOT NULL,
"treeId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"role" "MembershipRole" NOT NULL,
"contributionModeOverride" "ContributionPolicy",
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "tree_memberships_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "tree_settings" (
"treeId" TEXT NOT NULL,
"memberContributionPolicy" "ContributionPolicy" NOT NULL DEFAULT 'pending',
"visitorPolicy" "VisitorPolicy" NOT NULL DEFAULT 'read_only',
"contributorPolicy" "ContributionPolicy" NOT NULL DEFAULT 'pending',
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "tree_settings_pkey" PRIMARY KEY ("treeId")
);
CREATE TABLE "persons" (
"id" TEXT NOT NULL,
"treeId" TEXT NOT NULL,
"firstName" TEXT NOT NULL,
"lastName" TEXT NOT NULL,
"birthDate" TIMESTAMP(3),
"deathDate" TIMESTAMP(3),
"sex" TEXT,
"notes" TEXT,
"createdBy" TEXT,
"updatedBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deletedAt" TIMESTAMP(3),
CONSTRAINT "persons_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "unions" (
"id" TEXT NOT NULL,
"treeId" TEXT NOT NULL,
"partner1PersonId" TEXT NOT NULL,
"partner2PersonId" TEXT,
"unionType" TEXT,
"startDate" TIMESTAMP(3),
"endDate" TIMESTAMP(3),
"displayOrder" INTEGER NOT NULL DEFAULT 1,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deletedAt" TIMESTAMP(3),
CONSTRAINT "unions_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "parent_child_links" (
"id" TEXT NOT NULL,
"treeId" TEXT NOT NULL,
"parentPersonId" TEXT NOT NULL,
"childPersonId" TEXT NOT NULL,
"viaUnionId" TEXT,
"parentageType" TEXT,
"displayOrder" INTEGER NOT NULL DEFAULT 1,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deletedAt" TIMESTAMP(3),
CONSTRAINT "parent_child_links_pkey" PRIMARY KEY ("id")
);
-- Create indexes
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
CREATE UNIQUE INDEX "trees_slug_key" ON "trees"("slug");
CREATE INDEX "trees_ownerUserId_idx" ON "trees"("ownerUserId");
CREATE UNIQUE INDEX "tree_memberships_treeId_userId_key" ON "tree_memberships"("treeId", "userId");
CREATE INDEX "tree_memberships_userId_idx" ON "tree_memberships"("userId");
CREATE INDEX "persons_treeId_idx" ON "persons"("treeId");
CREATE INDEX "unions_treeId_idx" ON "unions"("treeId");
CREATE INDEX "parent_child_links_treeId_idx" ON "parent_child_links"("treeId");
-- Foreign keys
ALTER TABLE "trees"
ADD CONSTRAINT "trees_ownerUserId_fkey"
FOREIGN KEY ("ownerUserId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "tree_access_passwords"
ADD CONSTRAINT "tree_access_passwords_treeId_fkey"
FOREIGN KEY ("treeId") REFERENCES "trees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "tree_memberships"
ADD CONSTRAINT "tree_memberships_treeId_fkey"
FOREIGN KEY ("treeId") REFERENCES "trees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "tree_memberships"
ADD CONSTRAINT "tree_memberships_userId_fkey"
FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "tree_settings"
ADD CONSTRAINT "tree_settings_treeId_fkey"
FOREIGN KEY ("treeId") REFERENCES "trees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "persons"
ADD CONSTRAINT "persons_treeId_fkey"
FOREIGN KEY ("treeId") REFERENCES "trees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "unions"
ADD CONSTRAINT "unions_treeId_fkey"
FOREIGN KEY ("treeId") REFERENCES "trees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "parent_child_links"
ADD CONSTRAINT "parent_child_links_treeId_fkey"
FOREIGN KEY ("treeId") REFERENCES "trees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,81 @@
-- Create enums for contribution workflow
CREATE TYPE "ContributionSessionStatus" AS ENUM ('pending', 'approved', 'rejected');
CREATE TYPE "ContributionActorMode" AS ENUM ('user', 'tree_access');
CREATE TYPE "ContributionEntityType" AS ENUM ('person', 'union', 'parent_child_link');
CREATE TYPE "ContributionAction" AS ENUM ('create', 'update', 'delete', 'reorder');
CREATE TYPE "ConflictState" AS ENUM ('none', 'needs_review');
CREATE TYPE "ContributionDecision" AS ENUM ('approved', 'rejected');
-- Create contribution sessions table
CREATE TABLE "contribution_sessions" (
"id" TEXT NOT NULL,
"treeId" TEXT NOT NULL,
"submittedByUserId" TEXT,
"submittedByLabel" TEXT NOT NULL,
"mode" "ContributionActorMode" NOT NULL,
"status" "ContributionSessionStatus" NOT NULL DEFAULT 'pending',
"title" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"reviewedAt" TIMESTAMP(3),
"reviewedBy" TEXT,
CONSTRAINT "contribution_sessions_pkey" PRIMARY KEY ("id")
);
-- Create contribution changes table
CREATE TABLE "contribution_changes" (
"id" TEXT NOT NULL,
"sessionId" TEXT NOT NULL,
"entityType" "ContributionEntityType" NOT NULL,
"entityId" TEXT,
"action" "ContributionAction" NOT NULL,
"beforeJson" JSONB,
"afterJson" JSONB,
"conflictState" "ConflictState" NOT NULL DEFAULT 'none',
"decision" "ContributionDecision",
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "contribution_changes_pkey" PRIMARY KEY ("id")
);
-- Create audit log table
CREATE TABLE "audit_log" (
"id" TEXT NOT NULL,
"treeId" TEXT NOT NULL,
"actorType" TEXT NOT NULL,
"actorId" TEXT,
"action" TEXT NOT NULL,
"entityType" TEXT NOT NULL,
"entityId" TEXT NOT NULL,
"payloadJson" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "audit_log_pkey" PRIMARY KEY ("id")
);
-- Create indexes
CREATE INDEX "contribution_sessions_treeId_status_createdAt_idx" ON "contribution_sessions"("treeId", "status", "createdAt");
CREATE INDEX "contribution_sessions_submittedByUserId_idx" ON "contribution_sessions"("submittedByUserId");
CREATE INDEX "contribution_changes_sessionId_idx" ON "contribution_changes"("sessionId");
CREATE INDEX "audit_log_treeId_createdAt_idx" ON "audit_log"("treeId", "createdAt");
-- Add foreign keys
ALTER TABLE "contribution_sessions"
ADD CONSTRAINT "contribution_sessions_treeId_fkey"
FOREIGN KEY ("treeId") REFERENCES "trees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "contribution_sessions"
ADD CONSTRAINT "contribution_sessions_submittedByUserId_fkey"
FOREIGN KEY ("submittedByUserId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "contribution_sessions"
ADD CONSTRAINT "contribution_sessions_reviewedBy_fkey"
FOREIGN KEY ("reviewedBy") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "contribution_changes"
ADD CONSTRAINT "contribution_changes_sessionId_fkey"
FOREIGN KEY ("sessionId") REFERENCES "contribution_sessions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "audit_log"
ADD CONSTRAINT "audit_log_treeId_fkey"
FOREIGN KEY ("treeId") REFERENCES "trees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,25 @@
CREATE TABLE "media_items" (
"id" TEXT NOT NULL,
"treeId" TEXT NOT NULL,
"personId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"mimeType" TEXT NOT NULL,
"filePath" TEXT NOT NULL,
"thumbPath" TEXT,
"caption" TEXT,
"sizeBytes" INTEGER NOT NULL,
"displayOrder" INTEGER NOT NULL DEFAULT 1,
"isFeatured" BOOLEAN NOT NULL DEFAULT false,
"uploadedBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deletedAt" TIMESTAMP(3),
CONSTRAINT "media_items_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "media_items_treeId_personId_displayOrder_idx" ON "media_items"("treeId", "personId", "displayOrder");
ALTER TABLE "media_items"
ADD CONSTRAINT "media_items_treeId_fkey"
FOREIGN KEY ("treeId") REFERENCES "trees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,20 @@
-- AlterTable
ALTER TABLE "media_items" ALTER COLUMN "updatedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "parent_child_links" ALTER COLUMN "updatedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "persons" ALTER COLUMN "updatedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "tree_access_passwords" ALTER COLUMN "updatedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "tree_settings" ALTER COLUMN "updatedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "trees" ALTER COLUMN "updatedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "unions" ALTER COLUMN "updatedAt" DROP DEFAULT;
@@ -0,0 +1,29 @@
-- CreateEnum
CREATE TYPE "AnnotationType" AS ENUM ('drawing', 'sticker', 'text');
-- AlterEnum
ALTER TYPE "ContributionEntityType" ADD VALUE 'annotation';
-- CreateTable
CREATE TABLE "annotations" (
"id" TEXT NOT NULL,
"treeId" TEXT NOT NULL,
"type" "AnnotationType" NOT NULL,
"x" DOUBLE PRECISION NOT NULL,
"y" DOUBLE PRECISION NOT NULL,
"content" TEXT NOT NULL,
"style" JSONB NOT NULL DEFAULT '{}',
"zIndex" INTEGER NOT NULL DEFAULT 0,
"createdBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"deletedAt" TIMESTAMP(3),
CONSTRAINT "annotations_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "annotations_treeId_idx" ON "annotations"("treeId");
-- AddForeignKey
ALTER TABLE "annotations" ADD CONSTRAINT "annotations_treeId_fkey" FOREIGN KEY ("treeId") REFERENCES "trees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,5 @@
ALTER TABLE "persons"
ADD COLUMN "birthPlace" TEXT,
ADD COLUMN "profession" TEXT,
ADD COLUMN "region" TEXT,
ADD COLUMN "nationality" TEXT;
@@ -0,0 +1,3 @@
ALTER TABLE "persons"
ADD COLUMN "avatarPath" TEXT,
ADD COLUMN "avatarMimeType" TEXT;
@@ -0,0 +1,2 @@
ALTER TABLE "persons"
ADD COLUMN "birthName" TEXT;
@@ -0,0 +1,37 @@
-- Create enum for persisted shared access role
CREATE TYPE "SharedAccessRole" AS ENUM ('visitor', 'contributor');
-- Alter users table with last opened tree preference
ALTER TABLE "users"
ADD COLUMN "lastOpenedTreeId" TEXT,
ADD COLUMN "lastOpenedAccessMode" TEXT,
ADD COLUMN "lastOpenedRole" TEXT,
ADD COLUMN "lastOpenedAt" TIMESTAMP(3);
-- Create table to persist per-user shared tree access
CREATE TABLE "user_tree_accesses" (
"id" TEXT NOT NULL,
"treeId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"role" "SharedAccessRole" NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "user_tree_accesses_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "user_tree_accesses_treeId_userId_key" ON "user_tree_accesses"("treeId", "userId");
CREATE INDEX "user_tree_accesses_userId_updatedAt_idx" ON "user_tree_accesses"("userId", "updatedAt");
CREATE INDEX "users_lastOpenedAt_idx" ON "users"("lastOpenedAt");
ALTER TABLE "users"
ADD CONSTRAINT "users_lastOpenedTreeId_fkey"
FOREIGN KEY ("lastOpenedTreeId") REFERENCES "trees"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "user_tree_accesses"
ADD CONSTRAINT "user_tree_accesses_treeId_fkey"
FOREIGN KEY ("treeId") REFERENCES "trees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "user_tree_accesses"
ADD CONSTRAINT "user_tree_accesses_userId_fkey"
FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "AnnotationType" ADD VALUE 'photo';
@@ -0,0 +1,13 @@
-- CreateTable
CREATE TABLE "waitlist_entries" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"prenom" TEXT,
"source" TEXT NOT NULL DEFAULT 'direct',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "waitlist_entries_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "waitlist_entries_email_key" ON "waitlist_entries"("email");
@@ -0,0 +1,2 @@
ALTER TABLE "media_items"
ADD COLUMN "source" TEXT;
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+334
View File
@@ -0,0 +1,334 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum MembershipRole {
owner
admin
member
}
enum ContributionPolicy {
direct
pending
}
enum VisitorPolicy {
read_only
}
enum ContributionSessionStatus {
pending
approved
rejected
}
enum ContributionActorMode {
user
tree_access
}
enum ContributionEntityType {
person
union
parent_child_link
annotation
}
enum AnnotationType {
drawing
sticker
text
photo
}
enum ContributionAction {
create
update
delete
reorder
}
enum ConflictState {
none
needs_review
}
enum ContributionDecision {
approved
rejected
}
enum SharedAccessRole {
visitor
contributor
}
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[]
submittedContributionSessions ContributionSession[] @relation("ContributionSubmittedBy")
reviewedContributionSessions ContributionSession[] @relation("ContributionReviewedBy")
@@map("users")
}
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?
memberships TreeMembership[]
sharedUserAccesses UserTreeAccess[]
settings TreeSettings?
persons Person[]
unions Union[]
parentChildLinks ParentChildLink[]
mediaItems MediaItem[]
contributionSessions ContributionSession[]
auditLogs AuditLog[]
annotations Annotation[]
@@index([ownerUserId])
@@map("trees")
}
model TreeAccessPasswords {
treeId String @id
visitorHash String
contributorHash String
updatedAt DateTime @updatedAt
tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade)
@@map("tree_access_passwords")
}
model TreeMembership {
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)
@@unique([treeId, userId])
@@index([userId])
@@map("tree_memberships")
}
model UserTreeAccess {
id String @id @default(cuid())
treeId String
userId String
role SharedAccessRole
createdAt DateTime @default(now())
updatedAt DateTime @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])
@@map("user_tree_accesses")
}
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)
@@map("tree_settings")
}
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)
@@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)
@@index([treeId])
@@map("unions")
}
model ParentChildLink {
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)
@@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?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade)
@@index([treeId, personId, displayOrder])
@@map("media_items")
}
model ContributionSession {
id String @id @default(cuid())
treeId String
submittedByUserId String?
submittedByLabel String
mode ContributionActorMode
status ContributionSessionStatus @default(pending)
title String
createdAt DateTime @default(now())
reviewedAt DateTime?
reviewedBy String?
tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade)
submittedByUser User? @relation("ContributionSubmittedBy", fields: [submittedByUserId], references: [id], onDelete: SetNull)
reviewer User? @relation("ContributionReviewedBy", fields: [reviewedBy], references: [id], onDelete: SetNull)
changes ContributionChange[]
@@index([treeId, status, createdAt])
@@index([submittedByUserId])
@@map("contribution_sessions")
}
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)
@@index([sessionId])
@@map("contribution_changes")
}
model Annotation {
id String @id @default(cuid())
treeId String
type AnnotationType
x Float
y Float
content String
style Json @default("{}")
zIndex Int @default(0)
createdBy String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade)
@@index([treeId])
@@map("annotations")
}
model AuditLog {
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)
@@index([treeId, createdAt])
@@map("audit_log")
}
model WaitlistEntry {
id String @id @default(cuid())
email String @unique
prenom String?
source String @default("direct")
createdAt DateTime @default(now())
@@map("waitlist_entries")
}
+287
View File
@@ -0,0 +1,287 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { spawnSync } from 'node:child_process'
const rootDir = process.cwd()
const localDir = path.join(rootDir, '.local')
const pgRootDir = path.join(localDir, 'postgresql_full', 'pgsql')
const binDir = path.join(pgRootDir, 'bin')
const dataDir = path.join(localDir, 'pgdata')
const logFile = path.join(localDir, 'postgres.log')
const passwordFile = path.join(localDir, 'pgpass.txt')
const pgCtl = path.join(binDir, 'pg_ctl.exe')
const pgIsReady = path.join(binDir, 'pg_isready.exe')
const initdb = path.join(binDir, 'initdb.exe')
const psql = path.join(binDir, 'psql.exe')
const createdb = path.join(binDir, 'createdb.exe')
const postgresConnArgs = ['-w', '-h', '127.0.0.1', '-p', '5432', '-U', 'postgres', '-d', 'postgres']
const defaultStartTimeoutSeconds = 90
const parsedStartTimeoutSeconds = Number(process.env.PG_START_TIMEOUT_SEC ?? defaultStartTimeoutSeconds)
const startTimeoutSeconds = Number.isFinite(parsedStartTimeoutSeconds)
? Math.max(20, Math.floor(parsedStartTimeoutSeconds))
: defaultStartTimeoutSeconds
const startTimeoutMs = (startTimeoutSeconds + 10) * 1000
const readyPollMs = 500
const parsedReadyWaitMs = Number(process.env.PG_READY_WAIT_MS ?? startTimeoutSeconds * 1000)
const readyWaitMs = Number.isFinite(parsedReadyWaitMs)
? Math.max(readyPollMs, Math.floor(parsedReadyWaitMs))
: startTimeoutSeconds * 1000
function run(command, args, options = {}) {
const { silent = false, timeoutMs, ...spawnOptions } = options
const result = spawnSync(command, args, {
encoding: 'utf8',
stdio: 'pipe',
timeout: timeoutMs,
...spawnOptions,
})
if (result.error) {
if (!silent) {
console.error(result.error.message)
}
return { ...result, status: result.status ?? 1 }
}
if (!silent && result.stdout) {
process.stdout.write(result.stdout)
}
if (!silent && result.stderr) {
process.stderr.write(result.stderr)
}
return result
}
function sleep(ms) {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
}
function assertBinaryLayout() {
if (!existsSync(pgCtl) || !existsSync(pgIsReady) || !existsSync(initdb) || !existsSync(psql) || !existsSync(createdb)) {
console.error('PostgreSQL portable introuvable dans backend/.local/postgresql_full/pgsql/bin')
console.error('Re-extraire l archive dans backend/.local/postgresql_full')
process.exit(1)
}
}
function ensureDataDirInitialized() {
if (existsSync(path.join(dataDir, 'PG_VERSION'))) {
return
}
mkdirSync(localDir, { recursive: true })
writeFileSync(passwordFile, 'postgres\n', 'utf8')
const result = run(initdb, [
'-D',
dataDir,
'-U',
'postgres',
`--pwfile=${passwordFile}`,
'--auth-host=scram-sha-256',
'--auth-local=trust',
])
if (result.status !== 0) {
process.exit(result.status ?? 1)
}
}
function isPostgresAccepting() {
const result = run(pgIsReady, ['-h', '127.0.0.1', '-p', '5432', '-t', '1'], { silent: true, timeoutMs: 3000 })
return result.status === 0
}
function printPostgresLogTail(maxLines = 40) {
if (!existsSync(logFile)) {
return
}
try {
const content = readFileSync(logFile, 'utf8')
const lines = content.split(/\r?\n/).filter(Boolean)
const tail = lines.slice(-maxLines)
if (tail.length === 0) {
return
}
console.error('Dernieres lignes de backend/.local/postgres.log :')
for (const line of tail) {
console.error(line)
}
} catch {
// Keep original error path if reading log fails.
}
}
function isLogFilePermissionError(result) {
const stderr = (result.stderr || '').toLowerCase()
return stderr.includes('could not open log file') && stderr.includes('permission denied')
}
function isConcurrentStartupHint(result) {
const stderr = (result.stderr || '').toLowerCase()
return stderr.includes('another server might be running')
}
function runPgStart(targetLogFile) {
return run(
pgCtl,
['-D', dataDir, '-l', targetLogFile, '-o', '-p 5432', '-w', '-t', String(startTimeoutSeconds), 'start'],
{ timeoutMs: startTimeoutMs },
)
}
function startPostgres() {
// Fast path: already reachable.
if (isPostgresAccepting()) {
return
}
// If process exists but still warming up, readiness polling handles it.
const status = run(pgCtl, ['-D', dataDir, 'status'], { silent: true, timeoutMs: 5000 })
if (status.status === 0) {
return
}
console.log(`Demarrage de PostgreSQL (peut prendre jusqu a ~${startTimeoutSeconds}s)...`)
let start = runPgStart(logFile)
// If default log file is locked by another postgres process, retry with a unique startup log.
if (start.status !== 0 && isLogFilePermissionError(start)) {
const fallbackLogFile = path.join(localDir, `postgres-start-${Date.now()}.log`)
console.warn(`postgres.log est verrouille, tentative avec ${fallbackLogFile}`)
start = runPgStart(fallbackLogFile)
}
// On some Windows setups, pg_ctl can timeout while postgres still becomes ready.
if (start.error?.code === 'ETIMEDOUT' && isPostgresAccepting()) {
console.warn('pg_ctl a depasse le delai, mais PostgreSQL repond deja. Continuation.')
return
}
// A concurrent startup may report an error while another instance finishes booting.
if (start.status !== 0 && isConcurrentStartupHint(start) && waitForPostgresReady()) {
console.warn('Instance PostgreSQL detectee pendant le start, continuons apres verification readiness.')
return
}
// Another benign case: non-zero exit while readiness already says OK.
if (start.status !== 0 && isPostgresAccepting()) {
console.warn('pg_ctl a retourne une erreur, mais PostgreSQL repond deja. Continuation.')
return
}
if (start.status !== 0) {
if (start.error?.code === 'ETIMEDOUT') {
console.error(`Timeout pendant le demarrage PostgreSQL (> ${startTimeoutSeconds}s).`)
}
printPostgresLogTail()
process.exit(start.status ?? 1)
}
}
function stopPostgres() {
const stop = run(pgCtl, ['-D', dataDir, 'stop', '-m', 'fast'])
if (stop.status !== 0) {
process.exit(stop.status ?? 1)
}
}
function statusPostgres() {
const status = run(pgCtl, ['-D', dataDir, 'status'])
process.exit(status.status ?? 1)
}
function waitForPostgresReady() {
const attempts = Math.ceil(readyWaitMs / readyPollMs)
for (let attempt = 0; attempt < attempts; attempt += 1) {
if (isPostgresAccepting()) {
return true
}
sleep(readyPollMs)
}
return false
}
function ensureDatabase() {
const env = {
...process.env,
PGPASSWORD: 'postgres',
PGCONNECT_TIMEOUT: '5',
}
if (!waitForPostgresReady()) {
console.error('PostgreSQL demarre mais ne repond pas sur 127.0.0.1:5432.')
printPostgresLogTail()
process.exit(1)
}
const check = run(
psql,
[...postgresConnArgs, '-tAc', "SELECT 1 FROM pg_database WHERE datname='genealogy'"],
{ env, silent: true, timeoutMs: 15000 },
)
if (check.status !== 0) {
console.error('Impossible de verifier la base genealogy.')
process.exit(check.status ?? 1)
}
if (check.stdout.trim() === '1') {
return
}
const create = run(createdb, ['-w', '-h', '127.0.0.1', '-p', '5432', '-U', 'postgres', 'genealogy'], {
env,
timeoutMs: 15000,
})
if (create.status !== 0) {
process.exit(create.status ?? 1)
}
}
function bootstrap() {
console.log('Verification PostgreSQL local...')
ensureDataDirInitialized()
startPostgres()
console.log('Verification base genealogy...')
ensureDatabase()
console.log('PostgreSQL local pret (localhost:5432, db=genealogy, user=postgres).')
}
function main() {
assertBinaryLayout()
const action = process.argv[2] || 'status'
if (action === 'bootstrap') {
bootstrap()
return
}
if (action === 'start') {
ensureDataDirInitialized()
startPostgres()
return
}
if (action === 'stop') {
stopPostgres()
return
}
if (action === 'status') {
statusPostgres()
return
}
console.error('Action inconnue. Utiliser: bootstrap | start | stop | status')
process.exit(1)
}
main()
+128
View File
@@ -0,0 +1,128 @@
import { Resend } from 'resend'
const apiKey = process.env.RESEND_API_KEY
if (!apiKey) {
console.error('RESEND_API_KEY is missing in environment.')
process.exit(1)
}
const targetSegmentName = process.env.RESEND_WAITLIST_SEGMENT_NAME || 'parenthese-waitlist'
const preferredDomain = process.env.RESEND_WAITLIST_DOMAIN || 'parenthese.io'
const replyTo = process.env.RESEND_WAITLIST_REPLY_TO || 'hello@parenthese.io'
const resend = new Resend(apiKey)
function formatResendError(prefix, error) {
if (!error) return prefix
const code = error.name || 'unknown_error'
const status = error.statusCode ?? 'n/a'
const message = error.message || 'unknown message'
return `${prefix} (${code}, status=${status}): ${message}`
}
function findSegmentByName(items, name) {
return items.find((item) => String(item.name || '').toLowerCase() === name.toLowerCase())
}
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async function withRetry(task, label) {
const delays = [0, 700, 1400, 2200]
let lastError = null
for (let index = 0; index < delays.length; index += 1) {
if (delays[index] > 0) {
await wait(delays[index])
}
try {
return await task()
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
lastError = error
if (!message.includes('rate_limit_exceeded') || index === delays.length - 1) {
break
}
}
}
throw new Error(`${label} failed after retries: ${lastError instanceof Error ? lastError.message : String(lastError)}`)
}
function firstVerifiedDomain(domains) {
return domains.find((domain) => domain?.status === 'verified') || null
}
function chooseSenderDomain(domains, preferred) {
const preferredVerified = domains.find(
(domain) => domain?.status === 'verified' && String(domain.name || '').toLowerCase() === preferred.toLowerCase(),
)
if (preferredVerified) return preferredVerified.name
const fallbackVerified = firstVerifiedDomain(domains)
if (fallbackVerified) return fallbackVerified.name
return null
}
async function ensureSegment(name) {
const listResult = await withRetry(() => resend.segments.list({ limit: 100 }), 'segments.list')
if (listResult.error) {
throw new Error(formatResendError('Unable to list Resend segments', listResult.error))
}
const existing = findSegmentByName(listResult.data?.data || [], name)
if (existing) {
return { id: existing.id, created: false }
}
const createResult = await withRetry(() => resend.segments.create({ name }), 'segments.create')
if (createResult.error) {
throw new Error(formatResendError('Unable to create Resend segment', createResult.error))
}
return { id: createResult.data?.id, created: true }
}
async function resolveSenderAddress() {
const domainResult = await withRetry(() => resend.domains.list({ limit: 100 }), 'domains.list')
if (domainResult.error) {
throw new Error(formatResendError('Unable to list Resend domains', domainResult.error))
}
const domains = domainResult.data?.data || []
const domain = chooseSenderDomain(domains, preferredDomain)
if (!domain) {
return {
from: 'Parenthese <onboarding@resend.dev>',
warning: 'No verified custom domain found. Using onboarding@resend.dev temporarily.',
}
}
return {
from: `Parenthese <waitlist@${domain}>`,
warning: null,
}
}
async function main() {
const segment = await ensureSegment(targetSegmentName)
const sender = await resolveSenderAddress()
const summary = {
segmentName: targetSegmentName,
segmentId: segment.id,
segmentCreatedNow: segment.created,
fromAddress: sender.from,
replyToAddress: replyTo,
warning: sender.warning,
}
console.log(JSON.stringify(summary, null, 2))
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error))
process.exit(1)
})
+170
View File
@@ -0,0 +1,170 @@
import Fastify, { type FastifyError } from 'fastify'
import cors from '@fastify/cors'
import jwt from '@fastify/jwt'
import rateLimit from '@fastify/rate-limit'
import { Prisma } from '@prisma/client'
import { env } from './config/env.js'
import { prisma } from './lib/prisma.js'
import { isUserTokenRevoked } from './lib/token-revocation.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 { publicRoutes } from './routes/public.js'
import { annotationRoutes } from './routes/annotations.js'
import { gedcomRoutes } from './routes/gedcom.js'
import { treeRoutes } from './routes/trees.js'
import { waitlistRoutes } from './routes/waitlist.js'
import type { AnyJwtPayload } from './types/auth.js'
export function createApp() {
const app = Fastify({
logger: env.NODE_ENV !== 'test',
bodyLimit: 12 * 1024 * 1024,
// Accept both `/path` et `/path/` to avoid 301 → 400 issues via nginx
ignoreTrailingSlash: true,
// Trust X-Forwarded-For from Nginx proxy so rate limiting uses real client IPs
trustProxy: true,
})
app.decorate('prisma', prisma)
app.decorateRequest('actor', null)
app.register(cors, {
origin: env.CORS_ORIGIN.length === 1 ? env.CORS_ORIGIN[0] : env.CORS_ORIGIN,
})
app.register(rateLimit, {
global: false,
})
app.register(jwt, {
secret: env.JWT_SECRET,
})
app.addHook('preHandler', async (request) => {
request.actor = null
const authHeader = request.headers.authorization
if (!authHeader?.startsWith('Bearer ')) {
return
}
try {
const payload = await request.jwtVerify<AnyJwtPayload>()
if (payload.kind === 'user') {
if (isUserTokenRevoked(payload.jti)) {
return
}
request.actor = {
kind: 'user',
userId: payload.userId,
email: payload.email,
}
}
if (payload.kind === 'tree_access') {
const accessState = await app.prisma.treeAccessPasswords.findUnique({
where: {
treeId: payload.treeId,
},
select: {
updatedAt: true,
tree: {
select: {
deletedAt: true,
},
},
},
})
if (!accessState || accessState.tree.deletedAt) {
return
}
if (accessState.updatedAt.getTime() !== payload.accessVersion) {
return
}
request.actor = {
kind: 'tree_access',
treeId: payload.treeId,
role: payload.role,
}
}
} catch {
request.actor = null
}
})
app.get('/health', async () => ({ ok: true }))
app.register(authRoutes, { prefix: '/auth' })
app.register(publicRoutes)
app.register(treeRoutes)
app.register(mediaRoutes)
app.register(graphRoutes)
app.register(contributionRoutes)
app.register(annotationRoutes)
app.register(gedcomRoutes)
app.register(waitlistRoutes)
app.setErrorHandler((error: FastifyError | Error, request, reply) => {
if (error instanceof Prisma.PrismaClientInitializationError) {
return reply.code(503).send({
error: 'database_unavailable',
message: 'Database server unreachable. Start PostgreSQL and retry.',
})
}
if (error instanceof Prisma.PrismaClientKnownRequestError) {
const prismaError = error as Prisma.PrismaClientKnownRequestError
if (prismaError.code === 'P2002') {
return reply.code(409).send({
error: 'unique_constraint_violation',
message: 'A record with this value already exists.',
})
}
if (prismaError.code === 'P2025') {
return reply.code(404).send({
error: 'record_not_found',
message: 'The requested record does not exist.',
})
}
request.log.error({ err: error, prismaCode: prismaError.code }, 'Prisma known request error')
return reply.code(500).send({
error: 'database_error',
message: 'A database error occurred.',
})
}
const statusCode = 'statusCode' in error ? (error as FastifyError).statusCode : undefined
const errorCode = 'code' in error ? (error as FastifyError).code : undefined
if (statusCode && statusCode < 500) {
return reply.code(statusCode).send({
error: errorCode ?? 'request_error',
message: error.message,
})
}
request.log.error({ err: error }, 'Unhandled error')
return reply.code(statusCode ?? 500).send({
error: 'internal_error',
message: env.NODE_ENV === 'production'
? 'An unexpected error occurred.'
: error.message,
})
})
app.addHook('onClose', async () => {
await app.prisma.$disconnect()
})
return app
}
+44
View File
@@ -0,0 +1,44 @@
import { config as loadDotenv } from 'dotenv'
import { z } from 'zod'
loadDotenv()
const DEV_DATABASE_URL = 'postgresql://postgres:postgres@localhost:5432/genealogy'
const DEV_JWT_SECRET = 'dev-secret-change-me-please'
const optionalEnvString = z.preprocess(
(value) => (typeof value === 'string' && value.trim() === '' ? undefined : value),
z.string().trim().min(1).optional(),
)
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().positive().default(4000),
DATABASE_URL: z.string().min(1).default(DEV_DATABASE_URL),
JWT_SECRET: z.string().min(16).default(DEV_JWT_SECRET),
JWT_EXPIRES_IN: z.string().default('7d'),
JWT_TREE_ACCESS_EXPIRES_IN: z.string().default('24h'),
CORS_ORIGIN: z
.string()
.default('http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:5176')
.transform((value) => value.split(',').map((origin) => origin.trim()).filter(Boolean)),
MEDIA_STORAGE_PATH: z.string().default('./storage/media'),
RESEND_API_KEY: optionalEnvString,
RESEND_WAITLIST_SEGMENT_ID: optionalEnvString,
RESEND_WAITLIST_AUDIENCE_ID: optionalEnvString,
RESEND_WAITLIST_FROM: optionalEnvString,
RESEND_WAITLIST_REPLY_TO: optionalEnvString,
RESEND_WAITLIST_UNSUBSCRIBE_URL: optionalEnvString,
})
export const env = envSchema.parse(process.env)
if (env.NODE_ENV === 'production') {
if (env.JWT_SECRET === DEV_JWT_SECRET) {
throw new Error('JWT_SECRET must be overridden in production')
}
if (env.DATABASE_URL === DEV_DATABASE_URL) {
throw new Error('DATABASE_URL must be overridden in production')
}
}
+11
View File
@@ -0,0 +1,11 @@
import bcrypt from 'bcryptjs'
const HASH_ROUNDS = 12
export async function hashPassword(value: string): Promise<string> {
return bcrypt.hash(value, HASH_ROUNDS)
}
export async function verifyPassword(value: string, hash: string): Promise<boolean> {
return bcrypt.compare(value, hash)
}
+12
View File
@@ -0,0 +1,12 @@
import pkg from '@prisma/client'
import { env } from '../config/env.js'
const { PrismaClient } = pkg
export const prisma = new PrismaClient({
datasources: {
db: {
url: env.DATABASE_URL,
},
},
})
+24
View File
@@ -0,0 +1,24 @@
const revokedUserTokens = new Map<string, number>()
function nowSeconds(): number {
return Math.floor(Date.now() / 1000)
}
export function revokeUserToken(jti: string, exp: number): void {
revokedUserTokens.set(jti, exp)
}
export function isUserTokenRevoked(jti: string): boolean {
const exp = revokedUserTokens.get(jti)
if (!exp) {
return false
}
// Opportunistic cleanup of expired revocations.
if (exp <= nowSeconds()) {
revokedUserTokens.delete(jti)
return false
}
return true
}
+261
View File
@@ -0,0 +1,261 @@
import { Resend, type ErrorResponse } from 'resend'
import { env } from '../config/env.js'
type WaitlistSignupPayload = {
email: string
prenom: string | null
source: string
}
type LoggerLike = {
info: (obj: unknown, msg?: string) => void
warn: (obj: unknown, msg?: string) => void
error: (obj: unknown, msg?: string) => void
}
const resendClient = env.RESEND_API_KEY ? new Resend(env.RESEND_API_KEY) : null
const hasWaitlistContactTarget = Boolean(env.RESEND_WAITLIST_SEGMENT_ID || env.RESEND_WAITLIST_AUDIENCE_ID)
function isDuplicateLikeError(error: ErrorResponse | null): boolean {
if (!error?.message) {
return false
}
const normalizedMessage = error.message.toLowerCase()
return normalizedMessage.includes('already') || normalizedMessage.includes('exists')
}
function sanitizeTagValue(rawValue: string): string {
const normalized = rawValue.toLowerCase().replace(/[^a-z0-9_-]/g, '_')
return normalized.slice(0, 256) || 'direct'
}
function escapeHtml(rawValue: string): string {
return rawValue
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
function resolveUnsubscribeLink(): string {
if (env.RESEND_WAITLIST_UNSUBSCRIBE_URL) {
return env.RESEND_WAITLIST_UNSUBSCRIBE_URL
}
const unsubscribeEmail = (env.RESEND_WAITLIST_REPLY_TO || 'hello@parenthese.io').trim()
const subject = encodeURIComponent('Désinscription waitlist Parenthèse')
return `mailto:${unsubscribeEmail}?subject=${subject}`
}
function buildWaitlistEmailHtml(firstName: string, unsubscribeLink: string): string {
const escapedFirstName = escapeHtml(firstName)
const escapedUnsubscribeLink = escapeHtml(unsubscribeLink)
const shareLink = 'https://parenthese.io/?source=waitlist-share'
return `
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Parenthèse - inscription confirmée</title>
</head>
<body style="margin:0;padding:0;background:#fef9ed;color:#5d524b;font-family:'Segoe UI',Tahoma,Geneva,Verdana,sans-serif;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background:#fef9ed;padding:24px 12px;">
<tr>
<td align="center">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="max-width:600px;background:#f7ecd9;border:1px solid #eadac0;border-radius:16px;overflow:hidden;">
<tr>
<td style="padding:28px 28px 20px;background:linear-gradient(180deg,#fff5df 0%,#fef9ed 100%);text-align:center;">
<div style="display:inline-flex;align-items:center;gap:10px;">
<span style="display:inline-block;width:42px;height:42px;border-radius:10px;background:#a67c52;color:#fef9ed;line-height:42px;font-weight:700;font-size:20px;text-align:center;">P</span>
<span style="font-size:22px;font-weight:700;color:#5d524b;">Parenthèse</span>
</div>
<h1 style="margin:14px 0 6px;font-size:30px;line-height:1.15;font-weight:700;color:#5d524b;">Merci pour votre inscription</h1>
<p style="margin:0;font-size:15px;line-height:1.6;color:#6b5e56;">
Bonjour ${escapedFirstName}, votre place sur la waitlist Parenthèse est confirmée.
</p>
</td>
</tr>
<tr>
<td style="padding:24px 28px 16px;">
<p style="margin:0 0 14px;font-size:15px;line-height:1.7;color:#5d524b;">
Nous vous préviendrons dès que les prochaines invitations seront ouvertes, avec uniquement des nouvelles utiles.
</p>
<p style="margin:0 0 16px;font-size:15px;line-height:1.7;color:#5d524b;">
Merci de faire partie des premières familles qui nous font confiance.
</p>
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="border-radius:999px;background:#a67c52;">
<a href="${shareLink}" style="display:inline-block;padding:12px 20px;color:#fef9ed;text-decoration:none;font-size:14px;font-weight:600;">
Partager Parenthèse
</a>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:18px 28px 26px;border-top:1px solid #eadac0;">
<p style="margin:0 0 6px;font-size:13px;line-height:1.6;color:#6b5e56;">
À bientôt,<br /><strong>Parenthèse</strong>
</p>
<p style="margin:0 0 8px;font-size:11px;line-height:1.6;color:#8f7f72;">
Vous recevez cet email suite à votre inscription volontaire à la waitlist.
</p>
<p style="margin:0;font-size:11px;line-height:1.6;">
<a href="${escapedUnsubscribeLink}" style="color:#8f6f52;text-decoration:underline;">Se désinscrire de la waitlist</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`.trim()
}
function buildWaitlistEmailText(firstName: string, unsubscribeLink: string): string {
const shareLink = 'https://parenthese.io/?source=waitlist-share'
return [
`Bonjour ${firstName},`,
'',
'Merci, votre inscription à la waitlist Parenthèse est confirmée.',
'Nous vous écrirons dès que les prochaines invitations seront ouvertes.',
'Merci de faire partie des premières familles qui nous font confiance.',
'',
`Partager Parenthèse : ${shareLink}`,
'',
`Se désinscrire: ${unsubscribeLink}`,
'',
'À bientôt,',
'Parenthèse',
].join('\n')
}
async function upsertContactInResend(payload: WaitlistSignupPayload): Promise<void> {
if (!resendClient || !hasWaitlistContactTarget) {
return
}
const createPayload: {
email: string
unsubscribed: boolean
firstName?: string
segments?: Array<{ id: string }>
audienceId?: string
} = {
email: payload.email,
unsubscribed: false,
}
if (payload.prenom) {
createPayload.firstName = payload.prenom
}
if (env.RESEND_WAITLIST_SEGMENT_ID) {
createPayload.segments = [{ id: env.RESEND_WAITLIST_SEGMENT_ID }]
}
if (env.RESEND_WAITLIST_AUDIENCE_ID) {
createPayload.audienceId = env.RESEND_WAITLIST_AUDIENCE_ID
}
const createResult = await resendClient.contacts.create(createPayload)
if (!createResult.error) {
return
}
if (!isDuplicateLikeError(createResult.error)) {
throw new Error(`Resend contact create failed: ${createResult.error.message}`)
}
const updatePayload: {
email: string
unsubscribed: boolean
firstName: string | null
audienceId?: string
} = {
email: payload.email,
unsubscribed: false,
firstName: payload.prenom ?? null,
}
if (env.RESEND_WAITLIST_AUDIENCE_ID) {
updatePayload.audienceId = env.RESEND_WAITLIST_AUDIENCE_ID
}
const updateResult = await resendClient.contacts.update(updatePayload)
if (updateResult.error) {
throw new Error(`Resend contact update failed: ${updateResult.error.message}`)
}
if (env.RESEND_WAITLIST_SEGMENT_ID) {
const segmentResult = await resendClient.contacts.segments.add({
email: payload.email,
segmentId: env.RESEND_WAITLIST_SEGMENT_ID,
})
if (segmentResult.error && !isDuplicateLikeError(segmentResult.error)) {
throw new Error(`Resend segment add failed: ${segmentResult.error.message}`)
}
}
}
async function sendWaitlistConfirmation(payload: WaitlistSignupPayload): Promise<void> {
if (!resendClient || !env.RESEND_WAITLIST_FROM) {
return
}
const firstName = payload.prenom ?? 'la famille'
const sourceLabel = payload.source || 'direct'
const unsubscribeLink = resolveUnsubscribeLink()
const html = buildWaitlistEmailHtml(firstName, unsubscribeLink)
const text = buildWaitlistEmailText(firstName, unsubscribeLink)
const sendResult = await resendClient.emails.send({
from: env.RESEND_WAITLIST_FROM,
to: payload.email,
...(env.RESEND_WAITLIST_REPLY_TO ? { replyTo: env.RESEND_WAITLIST_REPLY_TO } : {}),
subject: 'Parenthèse - merci pour votre inscription',
text,
html,
tags: [
{ name: 'flow', value: 'waitlist_signup' },
{ name: 'source', value: sanitizeTagValue(sourceLabel) },
],
})
if (sendResult.error) {
throw new Error(`Resend email send failed: ${sendResult.error.message}`)
}
}
export async function syncWaitlistSignup(payload: WaitlistSignupPayload, log: LoggerLike): Promise<void> {
if (!resendClient) {
log.warn({ email: payload.email }, 'Resend waitlist skipped: RESEND_API_KEY is not configured')
return
}
if (!hasWaitlistContactTarget && !env.RESEND_WAITLIST_FROM) {
log.warn({ email: payload.email }, 'Resend configured but no waitlist segment/audience or sender configured')
return
}
try {
await upsertContactInResend(payload)
await sendWaitlistConfirmation(payload)
} catch (error) {
log.error({ err: error, email: payload.email, source: payload.source }, 'Waitlist Resend sync failed')
throw error
}
log.info({ email: payload.email, source: payload.source }, 'Waitlist Resend sync completed')
}
+314
View File
@@ -0,0 +1,314 @@
import type { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'
import { Prisma } from '@prisma/client'
import { z } from 'zod'
import type { MembershipRole } from '../types/auth.js'
const treeIdParamSchema = z.object({
id: z.string().min(1),
})
const annotationParamsSchema = z.object({
id: z.string().min(1),
annotationId: z.string().min(1),
})
const createAnnotationSchema = z.object({
type: z.enum(['drawing', 'sticker', 'text', 'photo']),
x: z.number(),
y: z.number(),
content: z.string().min(1).max(50000),
style: z.record(z.string(), z.unknown()).optional(),
zIndex: z.number().int().optional(),
})
const patchAnnotationSchema = z
.object({
x: z.number().optional(),
y: z.number().optional(),
content: z.string().min(1).max(50000).optional(),
style: z.record(z.string(), z.unknown()).optional(),
zIndex: z.number().int().optional(),
})
.refine((value) => Object.keys(value).length > 0, {
message: 'at_least_one_field_required',
})
const batchAnnotationSchema = z.object({
create: z.array(createAnnotationSchema).optional().default([]),
update: z.array(z.object({
id: z.string().min(1),
x: z.number().optional(),
y: z.number().optional(),
content: z.string().min(1).max(50000).optional(),
style: z.record(z.string(), z.unknown()).optional(),
zIndex: z.number().int().optional(),
})).optional().default([]),
delete: z.array(z.string().min(1)).optional().default([]),
})
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' })
return null
}
return {
userId: request.actor.userId,
email: request.actor.email,
}
}
function hasAdminRight(role: MembershipRole): boolean {
return role === 'owner' || role === 'admin'
}
async function getAdminMembership(app: FastifyInstance, userId: string, treeId: string) {
const membership = await app.prisma.treeMembership.findUnique({
where: {
treeId_userId: { treeId, userId },
},
include: {
tree: {
select: { deletedAt: true },
},
},
})
return membership
}
function hasActiveAdminMembership(membership: Awaited<ReturnType<typeof getAdminMembership>>): boolean {
return Boolean(membership && !membership.tree.deletedAt && hasAdminRight(membership.role))
}
export const annotationRoutes: FastifyPluginAsync = async (app) => {
// CREATE annotation
app.post('/trees/:id/annotations', 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 = createAnnotationSchema.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 annotation = await app.prisma.annotation.create({
data: {
treeId: params.data.id,
type: payload.data.type,
x: payload.data.x,
y: payload.data.y,
content: payload.data.content,
style: (payload.data.style ?? {}) as Prisma.InputJsonValue,
zIndex: payload.data.zIndex ?? 0,
createdBy: user.userId,
},
})
await app.prisma.auditLog.create({
data: {
treeId: params.data.id,
actorType: 'user',
actorId: user.userId,
action: 'annotation_created',
entityType: 'annotation',
entityId: annotation.id,
payloadJson: { type: annotation.type },
},
})
return reply.code(201).send({ annotation })
})
// LIST annotations
app.get('/trees/:id/annotations', async (request, reply) => {
const params = treeIdParamSchema.safeParse(request.params)
if (!params.success) return reply.code(400).send({ error: 'invalid_tree_id' })
if (!request.actor) return reply.code(401).send({ error: 'authentication_required' })
const annotations = await app.prisma.annotation.findMany({
where: {
treeId: params.data.id,
deletedAt: null,
},
orderBy: [{ zIndex: 'asc' }, { createdAt: 'asc' }, { id: 'asc' }],
})
return reply.send({ annotations })
})
// UPDATE annotation
app.patch('/trees/:id/annotations/:annotationId', async (request, reply) => {
const user = requireUser(request, reply)
if (!user) return
const params = annotationParamsSchema.safeParse(request.params)
if (!params.success) return reply.code(400).send({ error: 'invalid_params' })
const payload = patchAnnotationSchema.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 data: Record<string, unknown> = {}
if (payload.data.x !== undefined) data.x = payload.data.x
if (payload.data.y !== undefined) data.y = payload.data.y
if (payload.data.content !== undefined) data.content = payload.data.content
if (payload.data.style !== undefined) data.style = payload.data.style
if (payload.data.zIndex !== undefined) data.zIndex = payload.data.zIndex
const updated = await app.prisma.annotation.updateMany({
where: {
id: params.data.annotationId,
treeId: params.data.id,
deletedAt: null,
},
data,
})
if (updated.count === 0) return reply.code(404).send({ error: 'annotation_not_found' })
const annotation = await app.prisma.annotation.findUnique({
where: { id: params.data.annotationId },
})
await app.prisma.auditLog.create({
data: {
treeId: params.data.id,
actorType: 'user',
actorId: user.userId,
action: 'annotation_updated',
entityType: 'annotation',
entityId: params.data.annotationId,
payloadJson: { changedFields: Object.keys(payload.data) },
},
})
return reply.send({ annotation })
})
// DELETE annotation (soft delete)
app.delete('/trees/:id/annotations/:annotationId', async (request, reply) => {
const user = requireUser(request, reply)
if (!user) return
const params = annotationParamsSchema.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 deleted = await app.prisma.annotation.updateMany({
where: {
id: params.data.annotationId,
treeId: params.data.id,
deletedAt: null,
},
data: {
deletedAt: new Date(),
},
})
if (deleted.count === 0) return reply.code(404).send({ error: 'annotation_not_found' })
await app.prisma.auditLog.create({
data: {
treeId: params.data.id,
actorType: 'user',
actorId: user.userId,
action: 'annotation_deleted',
entityType: 'annotation',
entityId: params.data.annotationId,
},
})
return reply.send({ ok: true })
})
// BATCH annotations (create + update + delete in one request)
app.post('/trees/:id/annotations/batch', 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 = batchAnnotationSchema.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 treeId = params.data.id
const results = { created: 0, updated: 0, deleted: 0 }
// Creates
for (const item of payload.data.create) {
await app.prisma.annotation.create({
data: {
treeId,
type: item.type,
x: item.x,
y: item.y,
content: item.content,
style: (item.style ?? {}) as Prisma.InputJsonValue,
zIndex: item.zIndex ?? 0,
createdBy: user.userId,
},
})
results.created++
}
// Updates
for (const item of payload.data.update) {
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 = item.content
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 })
})
}
+298
View File
@@ -0,0 +1,298 @@
import type { FastifyInstance, FastifyPluginAsync } from 'fastify'
import { randomUUID } from 'node:crypto'
import { z } from 'zod'
import { env } from '../config/env.js'
import { hashPassword, verifyPassword } from '../lib/auth.js'
import { revokeUserToken } from '../lib/token-revocation.js'
import type { AnyJwtPayload } from '../types/auth.js'
const credentialsSchema = z.object({
email: z.string().email(),
password: z.string().min(8).max(128),
})
const updateLastTreeSchema = z.object({
treeId: z.string().min(1),
accessMode: z.enum(['member', 'share']),
})
async function resolveAccessibleLastTree(
app: FastifyInstance,
userId: string,
treeId: string,
accessMode: 'member' | 'share',
): Promise<{ role: string } | null> {
if (accessMode === 'member') {
const membership = await app.prisma.treeMembership.findUnique({
where: {
treeId_userId: {
treeId,
userId,
},
},
include: {
tree: {
select: {
deletedAt: true,
},
},
},
})
if (!membership || membership.tree.deletedAt) {
return null
}
return { role: membership.role }
}
const shared = await app.prisma.userTreeAccess.findUnique({
where: {
treeId_userId: {
treeId,
userId,
},
},
include: {
tree: {
select: {
deletedAt: true,
},
},
},
})
if (!shared || shared.tree.deletedAt) {
return null
}
return { role: shared.role }
}
export const authRoutes: FastifyPluginAsync = async (app) => {
app.post(
'/register',
{
config: {
rateLimit: {
max: 20,
timeWindow: '1 minute',
},
},
},
async (request, reply) => {
const parsed = credentialsSchema.safeParse(request.body)
if (!parsed.success) {
return reply.code(400).send({ error: 'invalid_payload', details: parsed.error.flatten() })
}
const email = parsed.data.email.trim().toLowerCase()
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({
data: {
email,
passwordHash,
},
select: {
id: true,
email: true,
},
})
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.code(201).send({ token, user })
},
)
app.post(
'/login',
{
config: {
rateLimit: {
max: 10,
timeWindow: '1 minute',
},
},
},
async (request, reply) => {
const parsed = credentialsSchema.safeParse(request.body)
if (!parsed.success) {
return reply.code(400).send({ error: 'invalid_payload', details: parsed.error.flatten() })
}
const email = parsed.data.email.trim().toLowerCase()
const user = await app.prisma.user.findUnique({
where: { email },
select: {
id: true,
email: true,
passwordHash: true,
},
})
if (!user) {
return reply.code(401).send({ error: 'invalid_credentials' })
}
const isValid = await verifyPassword(parsed.data.password, user.passwordHash)
if (!isValid) {
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,
user: {
id: user.id,
email: user.email,
},
})
},
)
app.get('/me/last-tree', 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: {
lastOpenedTreeId: true,
lastOpenedAccessMode: true,
lastOpenedRole: true,
lastOpenedAt: true,
},
})
if (
!user ||
!user.lastOpenedTreeId ||
!user.lastOpenedAccessMode ||
(user.lastOpenedAccessMode !== 'member' && user.lastOpenedAccessMode !== 'share')
) {
return reply.send({ lastTree: null })
}
const access = await resolveAccessibleLastTree(
app,
request.actor.userId,
user.lastOpenedTreeId,
user.lastOpenedAccessMode,
)
if (!access) {
return reply.send({ lastTree: null })
}
return reply.send({
lastTree: {
treeId: user.lastOpenedTreeId,
accessMode: user.lastOpenedAccessMode,
role: user.lastOpenedRole || access.role,
lastOpenedAt: user.lastOpenedAt,
},
})
})
app.put('/me/last-tree', async (request, reply) => {
if (!request.actor || request.actor.kind !== 'user') {
return reply.code(401).send({ error: 'authentication_required' })
}
const parsed = updateLastTreeSchema.safeParse(request.body)
if (!parsed.success) {
return reply.code(400).send({ error: 'invalid_payload', details: parsed.error.flatten() })
}
const access = await resolveAccessibleLastTree(
app,
request.actor.userId,
parsed.data.treeId,
parsed.data.accessMode,
)
if (!access) {
return reply.code(403).send({ error: 'forbidden' })
}
const now = new Date()
await app.prisma.user.update({
where: {
id: request.actor.userId,
},
data: {
lastOpenedTreeId: parsed.data.treeId,
lastOpenedAccessMode: parsed.data.accessMode,
lastOpenedRole: access.role,
lastOpenedAt: now,
},
})
if (parsed.data.accessMode === 'share') {
await app.prisma.userTreeAccess.updateMany({
where: {
treeId: parsed.data.treeId,
userId: request.actor.userId,
},
data: {
updatedAt: now,
},
})
}
return reply.send({
lastTree: {
treeId: parsed.data.treeId,
accessMode: parsed.data.accessMode,
role: access.role,
lastOpenedAt: now,
},
})
})
app.post('/logout', async (request, reply) => {
if (!request.actor || request.actor.kind !== 'user') {
return reply.code(401).send({ error: 'authentication_required' })
}
const payload = await request.jwtVerify<AnyJwtPayload & { exp?: number }>()
if (payload.kind !== 'user') {
return reply.code(401).send({ error: 'invalid_token_kind' })
}
if (typeof payload.exp === 'number') {
revokeUserToken(payload.jti, payload.exp)
}
return reply.send({ ok: true })
})
}
File diff suppressed because it is too large. Load diff
+533
View File
@@ -0,0 +1,533 @@
import type { FastifyPluginAsync } from 'fastify'
import { z } from 'zod'
import type { MembershipRole } from '../types/auth.js'
const treeIdParamSchema = z.object({
id: z.string().min(1),
})
const importBodySchema = z.object({
content: z.string().min(1).max(10 * 1024 * 1024),
})
function hasAdminRight(role: MembershipRole): boolean {
return role === 'owner' || role === 'admin'
}
// ─── GEDCOM Parser ─────────────────────────────────────────────────────────
type GedcomNode = {
level: number
xref?: string
tag: string
value: string
children: GedcomNode[]
}
function parseGedcom(text: string): GedcomNode[] {
const lines = text.split(/\r?\n/)
const roots: GedcomNode[] = []
const stack: GedcomNode[] = []
for (const raw of lines) {
const line = raw.trim()
if (!line) continue
const match = line.match(/^(\d+)\s+(?:(@[^@]+@)\s+)?(\w+)(?:\s+(.*))?$/)
if (!match) continue
const level = parseInt(match[1], 10)
const xref = match[2] ? match[2].slice(1, -1) : undefined
const tag = match[3]
const value = match[4]?.trim() ?? ''
const node: GedcomNode = { level, xref, tag, value, children: [] }
while (stack.length > 0 && stack[stack.length - 1].level >= level) {
stack.pop()
}
if (stack.length === 0) {
roots.push(node)
} else {
stack[stack.length - 1].children.push(node)
}
stack.push(node)
}
return roots
}
function findChild(node: GedcomNode | undefined, tag: string): GedcomNode | undefined {
return node?.children.find((child) => child.tag === tag)
}
function findAllChildren(node: GedcomNode | undefined, tag: string): GedcomNode[] {
return node?.children.filter((child) => child.tag === tag) ?? []
}
// ─── 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']
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+/, '')
// "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
}
function isoDateToDbDate(iso: string | null): 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 isoToGedcomDate(value: string | Date | null): string | null {
if (!value) return null
const str = value instanceof Date ? value.toISOString().split('T')[0] : String(value)
const full = str.match(/^(\d{4})-(\d{2})-(\d{2})$/)
if (full) {
const month = MONTHS_ARR[parseInt(full[2], 10) - 1]
if (month) return `${parseInt(full[3], 10)} ${month} ${full[1]}`
}
const partial = str.match(/^(\d{4})-(\d{2})$/)
if (partial) {
const month = MONTHS_ARR[parseInt(partial[2], 10) - 1]
if (month) return `${month} ${partial[1]}`
}
if (/^\d{4}$/.test(str)) return str
return null
}
// ─── Name Parsing ────────────────────────────────────────────────────────────
function parseGedcomName(value: string): { firstName: string; lastName: string } {
const slashMatch = value.match(/^(.*?)\s*\/([^/]*)\/(.*)?$/)
if (slashMatch) {
return {
firstName: (slashMatch[1].trim() || slashMatch[3]?.trim() || '').trim(),
lastName: slashMatch[2].trim(),
}
}
return { firstName: value.trim(), lastName: '' }
}
function sanitizeXref(id: string): string {
return id.replace(/[^A-Za-z0-9_]/g, '_')
}
// ─── Routes ─────────────────────────────────────────────────────────────────
export const gedcomRoutes: FastifyPluginAsync = async (app) => {
// Export
app.get('/trees/:id/export/gedcom', 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
if (!actor) {
return reply.code(401).send({ error: 'authentication_required' })
}
let treeName = 'arbre'
if (actor.kind === 'tree_access') {
if (actor.treeId !== treeId) {
return reply.code(403).send({ error: 'forbidden' })
}
const tree = await app.prisma.tree.findFirst({
where: { id: treeId, deletedAt: null },
select: { name: true },
})
if (!tree) return reply.code(404).send({ error: 'tree_not_found' })
treeName = tree.name
} else {
const membership = await app.prisma.treeMembership.findUnique({
where: { treeId_userId: { treeId, userId: actor.userId } },
include: { tree: { select: { deletedAt: true, name: true } } },
})
if (membership && !membership.tree.deletedAt) {
treeName = membership.tree.name
} else {
const shared = await app.prisma.userTreeAccess.findUnique({
where: { treeId_userId: { treeId, userId: actor.userId } },
include: { tree: { select: { deletedAt: true, name: true } } },
})
if (!shared || shared.tree.deletedAt) {
return reply.code(403).send({ error: 'forbidden' })
}
treeName = shared.tree.name
}
}
const [persons, unions, links] = 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 } }),
])
const personById = new Map(persons.map((p) => [p.id, p]))
// ── Build all cross-reference maps first (needed for FAMS/FAMC in INDI) ──
// Map: "parentA|parentB" (sorted) → unionId
const unionByPair = new Map<string, string>()
for (const union of unions) {
if (union.partner1PersonId && union.partner2PersonId) {
const [a, b] = [union.partner1PersonId, union.partner2PersonId].sort()
unionByPair.set(`${a}|${b}`, union.id)
}
}
// Map: personId → first unionId where that person is a partner (fallback only)
const firstUnionOfPerson = new Map<string, string>()
for (const union of unions) {
if (union.partner1PersonId && !firstUnionOfPerson.has(union.partner1PersonId)) {
firstUnionOfPerson.set(union.partner1PersonId, union.id)
}
if (union.partner2PersonId && !firstUnionOfPerson.has(union.partner2PersonId)) {
firstUnionOfPerson.set(union.partner2PersonId, union.id)
}
}
// Gather all parent IDs per child
const parentsByChild = new Map<string, string[]>()
for (const link of links) {
const arr = parentsByChild.get(link.childPersonId) ?? []
if (!arr.includes(link.parentPersonId)) arr.push(link.parentPersonId)
parentsByChild.set(link.childPersonId, arr)
}
// Assign each child to a union FAM
const childrenByUnion = new Map<string, Set<string>>()
for (const link of links) {
const childId = link.childPersonId
let unionId: string | undefined
if (link.viaUnionId) {
unionId = link.viaUnionId
} else {
const parents = parentsByChild.get(childId) ?? []
for (let i = 0; i < parents.length; i++) {
for (let j = i + 1; j < parents.length; j++) {
const [a, b] = [parents[i], parents[j]].sort()
const found = unionByPair.get(`${a}|${b}`)
if (found) { unionId = found; break }
}
if (unionId) break
}
if (!unionId) unionId = firstUnionOfPerson.get(link.parentPersonId)
}
if (!unionId) continue
const set = childrenByUnion.get(unionId) ?? new Set()
set.add(childId)
childrenByUnion.set(unionId, set)
}
// FAMS: personId → [unionIds where person is a spouse]
const famsByPerson = new Map<string, string[]>()
for (const union of unions) {
for (const pid of [union.partner1PersonId, union.partner2PersonId]) {
if (!pid) continue
const arr = famsByPerson.get(pid) ?? []
arr.push(union.id)
famsByPerson.set(pid, arr)
}
}
// FAMC: personId → [unionIds where person appears as CHIL]
const famcByPerson = new Map<string, string[]>()
for (const [unionId, childSet] of childrenByUnion) {
for (const childId of childSet) {
const arr = famcByPerson.get(childId) ?? []
arr.push(unionId)
famcByPerson.set(childId, arr)
}
}
// ── Write GEDCOM ──
const lines: string[] = []
const now = new Date()
const nowDate = `${now.getDate()} ${MONTHS_ARR[now.getMonth()]} ${now.getFullYear()}`
lines.push('0 HEAD')
lines.push('1 SOUR ArbreGeneralogique')
lines.push('1 VERS 1.0')
lines.push(`1 DATE ${nowDate}`)
lines.push('1 CHAR UTF-8')
lines.push('1 GEDC')
lines.push('2 VERS 5.5.1')
for (const person of persons) {
const xref = sanitizeXref(person.id)
lines.push(`0 @I${xref}@ INDI`)
lines.push(`1 NAME ${person.firstName} /${person.lastName}/`)
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 birthDate = isoToGedcomDate(person.birthDate)
if (birthDate || person.birthPlace) {
lines.push('1 BIRT')
if (birthDate) lines.push(`2 DATE ${birthDate}`)
if (person.birthPlace) lines.push(`2 PLAC ${person.birthPlace}`)
}
const deathDate = isoToGedcomDate(person.deathDate)
if (deathDate) {
lines.push('1 DEAT')
lines.push(`2 DATE ${deathDate}`)
}
if (person.profession) lines.push(`1 OCCU ${person.profession}`)
if (person.notes) {
const noteLines = person.notes.split('\n')
lines.push(`1 NOTE ${noteLines[0]}`)
for (let i = 1; i < noteLines.length; i++) {
lines.push(`2 CONT ${noteLines[i]}`)
}
}
// FAMS: links this person to their own family (as spouse)
for (const uid of famsByPerson.get(person.id) ?? []) {
lines.push(`1 FAMS @F${sanitizeXref(uid)}@`)
}
// 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)}@`)
}
}
// ── Write FAM records ──
for (const union of unions) {
const xref = sanitizeXref(union.id)
lines.push(`0 @F${xref}@ FAM`)
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'
// 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)
const husbId = shouldSwap ? union.partner2PersonId : union.partner1PersonId
const wifeId = shouldSwap ? union.partner1PersonId : union.partner2PersonId
if (husbId) lines.push(`1 HUSB @I${sanitizeXref(husbId)}@`)
if (wifeId && wifeId !== husbId) lines.push(`1 WIFE @I${sanitizeXref(wifeId)}@`)
for (const childId of childrenByUnion.get(union.id) ?? []) {
lines.push(`1 CHIL @I${sanitizeXref(childId)}@`)
}
const marriageDate = isoToGedcomDate(union.startDate)
if (marriageDate) {
lines.push('1 MARR')
lines.push(`2 DATE ${marriageDate}`)
}
}
lines.push('0 TRLR')
const content = lines.join('\r\n')
const filename = treeName.replace(/[^a-zA-Z0-9_\-]/g, '_') + '.ged'
reply.header('Content-Type', 'text/x-gedcom; charset=utf-8')
reply.header('Content-Disposition', `attachment; filename="${filename}"`)
return reply.send(content)
})
// Import
app.post('/trees/:id/import/gedcom', async (request, reply) => {
const params = treeIdParamSchema.safeParse(request.params)
if (!params.success) {
return reply.code(400).send({ error: 'invalid_tree_id' })
}
const body = importBodySchema.safeParse(request.body)
if (!body.success) {
return reply.code(400).send({ error: 'invalid_payload', details: body.error.flatten() })
}
const actor = request.actor
if (!actor || actor.kind !== 'user') {
return reply.code(401).send({ error: 'authentication_required' })
}
const treeId = params.data.id
const membership = await app.prisma.treeMembership.findUnique({
where: { treeId_userId: { treeId, userId: actor.userId } },
include: { tree: { select: { deletedAt: true } } },
})
if (!membership || membership.tree.deletedAt) {
return reply.code(404).send({ error: 'tree_not_found' })
}
if (!hasAdminRight(membership.role)) {
return reply.code(403).send({ error: 'forbidden' })
}
const roots = parseGedcom(body.data.content)
const indiNodes = roots.filter((node) => node.tag === 'INDI' && node.xref)
const famNodes = roots.filter((node) => node.tag === 'FAM' && node.xref)
const xrefToPersonId = new Map<string, string>()
let personsCreated = 0
let unionsCreated = 0
let linksCreated = 0
await app.prisma.$transaction(async (tx) => {
for (const indi of indiNodes) {
const nameNode = findChild(indi, 'NAME')
const { firstName, lastName } = nameNode
? parseGedcomName(nameNode.value)
: { firstName: '?', lastName: '' }
const sexNode = findChild(indi, 'SEX')
const sex = sexNode?.value === 'M' ? 'male' : sexNode?.value === 'F' ? 'female' : null
const birtNode = findChild(indi, 'BIRT')
const birthDateIso = gedcomDateToIso(findChild(birtNode, 'DATE')?.value ?? '')
const birthPlace = findChild(birtNode, 'PLAC')?.value?.trim() || null
const deatNode = findChild(indi, 'DEAT')
const deathDateIso = gedcomDateToIso(findChild(deatNode, 'DATE')?.value ?? '')
const profession = findChild(indi, 'OCCU')?.value?.trim() || null
const noteNode = findChild(indi, 'NOTE')
let notes: string | null = null
if (noteNode?.value) {
const contLines = findAllChildren(noteNode, 'CONT').map((n) => n.value)
notes = [noteNode.value, ...contLines].join('\n').trim() || null
}
const person = await tx.person.create({
data: {
treeId,
firstName: firstName || '?',
lastName: lastName || '',
sex,
birthDate: isoDateToDbDate(birthDateIso),
deathDate: isoDateToDbDate(deathDateIso),
birthPlace,
profession,
notes,
createdBy: actor.userId,
updatedBy: actor.userId,
},
})
xrefToPersonId.set(indi.xref!, person.id)
personsCreated++
}
for (const fam of famNodes) {
const husbXref = findChild(fam, 'HUSB')?.value?.replace(/@/g, '').trim()
const wifeXref = findChild(fam, 'WIFE')?.value?.replace(/@/g, '').trim()
const partner1Id = husbXref ? xrefToPersonId.get(husbXref) : undefined
const partner2Id = wifeXref ? xrefToPersonId.get(wifeXref) : undefined
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 chilNodes = findAllChildren(fam, 'CHIL')
const seenChildren = new Set<string>()
for (const chilNode of chilNodes) {
const chilXref = chilNode.value?.replace(/@/g, '').trim()
if (!chilXref || seenChildren.has(chilXref)) continue
seenChildren.add(chilXref)
const childId = xrefToPersonId.get(chilXref)
if (!childId) continue
const parentIds = [partner1Id, partner2Id].filter((id): id is string => Boolean(id))
for (const parentId of parentIds) {
await tx.parentChildLink.create({
data: {
treeId,
parentPersonId: parentId,
childPersonId: childId,
viaUnionId: union.id,
},
})
linksCreated++
}
}
}
await tx.auditLog.create({
data: {
treeId,
actorType: 'user',
actorId: actor.userId,
action: 'gedcom_imported',
entityType: 'tree',
entityId: treeId,
payloadJson: { personsCreated, unionsCreated, linksCreated },
},
})
}, { timeout: 60000 })
return reply.send({ personsCreated, unionsCreated, linksCreated })
})
}
+140
View File
@@ -0,0 +1,140 @@
import type { FastifyPluginAsync } from 'fastify'
import { z } from 'zod'
import { buildGraphPayload } from '../utils/graph-payload.js'
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' })
}
const treeId = params.data.id
const actor = request.actor
let rootPersonId: string | null = null
if (!actor) {
return reply.code(401).send({ error: 'authentication_required' })
}
if (actor.kind === 'tree_access') {
if (actor.treeId !== treeId) {
return reply.code(403).send({ error: 'forbidden' })
}
const tree = await app.prisma.tree.findFirst({
where: {
id: treeId,
deletedAt: null,
},
select: {
id: 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({
where: {
treeId_userId: {
treeId,
userId: actor.userId,
},
},
include: {
tree: {
select: {
deletedAt: true,
rootPersonId: true,
},
},
},
})
if (membership && !membership.tree.deletedAt) {
rootPersonId = membership.tree.rootPersonId
} 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
}
}
const [persons, unions, links, medias, annotations] = await Promise.all([
app.prisma.person.findMany({
where: {
treeId,
deletedAt: null,
},
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
}),
app.prisma.union.findMany({
where: {
treeId,
deletedAt: null,
},
orderBy: [{ displayOrder: 'asc' }, { createdAt: 'asc' }, { id: 'asc' }],
}),
app.prisma.parentChildLink.findMany({
where: {
treeId,
deletedAt: null,
},
orderBy: [{ parentPersonId: 'asc' }, { viaUnionId: 'asc' }, { displayOrder: 'asc' }, { createdAt: 'asc' }, { id: 'asc' }],
}),
app.prisma.mediaItem.findMany({
where: {
treeId,
deletedAt: null,
},
orderBy: [{ personId: 'asc' }, { displayOrder: 'asc' }, { createdAt: 'asc' }, { id: 'asc' }],
}),
app.prisma.annotation.findMany({
where: {
treeId,
deletedAt: null,
},
orderBy: [{ zIndex: 'asc' }, { createdAt: 'asc' }, { id: 'asc' }],
}),
])
const graph = buildGraphPayload(treeId, persons, unions, links, medias, annotations)
return reply.send({
treeId,
rootPersonId,
graph,
})
})
}
File diff suppressed because it is too large. Load diff
+42
View File
@@ -0,0 +1,42 @@
import type { FastifyPluginAsync } from 'fastify'
import { z } from 'zod'
const slugParamSchema = z.object({
slug: z.string().min(3).max(64),
})
export const publicRoutes: FastifyPluginAsync = async (app) => {
app.get('/arbre/:slug', async (request, reply) => {
const params = slugParamSchema.safeParse(request.params)
if (!params.success) {
return reply.code(400).send({ error: 'invalid_slug' })
}
const tree = await app.prisma.tree.findFirst({
where: {
slug: params.data.slug,
deletedAt: null,
},
select: {
id: true,
slug: true,
name: true,
description: true,
},
})
if (!tree) {
return reply.code(404).send({ error: 'tree_not_found' })
}
// Route publique, appelée avant tout mot de passe : rien sur le propriétaire (ni son email, ni sa partie locale).
return reply.send({
tree: {
id: tree.id,
slug: tree.slug,
name: tree.name,
description: tree.description,
},
})
})
}
File diff suppressed because it is too large. Load diff
+56
View File
@@ -0,0 +1,56 @@
import type { FastifyPluginAsync } from 'fastify'
import { z } from 'zod'
import { syncWaitlistSignup } from '../lib/waitlist-mailer.js'
const waitlistSchema = z.object({
email: z.string().trim().email(),
prenom: z.string().trim().max(100).optional(),
source: z.string().trim().max(200).default('direct'),
})
export const waitlistRoutes: FastifyPluginAsync = async (app) => {
app.post(
'/waitlist',
{
config: {
rateLimit: {
max: 5,
timeWindow: '1 minute',
},
},
},
async (request, reply) => {
const body = waitlistSchema.safeParse(request.body)
if (!body.success) {
return reply.code(400).send({ error: 'validation_error', message: 'Email invalide.' })
}
const { email, prenom, source } = body.data
const normalizedEmail = email.toLowerCase()
await app.prisma.waitlistEntry.create({
data: {
email: normalizedEmail,
prenom: prenom || null,
source,
},
})
try {
await syncWaitlistSignup(
{
email: normalizedEmail,
prenom: prenom || null,
source,
},
request.log,
)
} catch (error) {
request.log.warn({ err: error, email: normalizedEmail, source }, 'Waitlist signup saved but Resend sync failed')
}
return reply.code(200).send({ ok: true })
},
)
}
+18
View File
@@ -0,0 +1,18 @@
import { createApp } from './app.js'
import { env } from './config/env.js'
const app = createApp()
async function bootstrap() {
try {
await app.listen({
port: env.PORT,
host: '0.0.0.0',
})
} catch (error) {
app.log.error(error)
process.exit(1)
}
}
bootstrap()
+34
View File
@@ -0,0 +1,34 @@
export type MembershipRole = 'owner' | 'admin' | 'member'
export type TreeAccessRole = 'visitor' | 'contributor'
export type UserJwtPayload = {
kind: 'user'
sub: string
jti: string
userId: string
email: string
}
export type TreeAccessJwtPayload = {
kind: 'tree_access'
sub: string
treeId: string
role: TreeAccessRole
accessVersion: number
}
export type AnyJwtPayload = UserJwtPayload | TreeAccessJwtPayload
export type UserActor = {
kind: 'user'
userId: string
email: string
}
export type TreeAccessActor = {
kind: 'tree_access'
treeId: string
role: TreeAccessRole
}
export type Actor = UserActor | TreeAccessActor | null
+19
View File
@@ -0,0 +1,19 @@
import type { PrismaClient } from '@prisma/client'
import type { Actor, AnyJwtPayload } from './auth.js'
declare module '@fastify/jwt' {
interface FastifyJWT {
payload: AnyJwtPayload
user: AnyJwtPayload
}
}
declare module 'fastify' {
interface FastifyInstance {
prisma: PrismaClient
}
interface FastifyRequest {
actor: Actor
}
}
+131
View File
@@ -0,0 +1,131 @@
import type { Annotation, MediaItem, ParentChildLink, Person, Union } from '@prisma/client'
export type GraphPayload = {
persons: Array<{
id: string
firstName: string
lastName: string
birthName: string | null
birthDate: string | null
deathDate: string | null
birthPlace: string | null
profession: string | null
region: string | null
nationality: string | null
avatarUrlPath: string | null
sex: string | null
notes: string | null
}>
unions: Array<{
id: string
partner1PersonId: string
partner2PersonId: string | null
unionType: string | null
startDate: string | null
endDate: string | null
displayOrder: number
}>
filiations: Array<{
id: string
parentPersonId: string
childPersonId: string
viaUnionId: string | null
parentageType: string | null
displayOrder: number
}>
medias: Array<{
id: string
personId: string
type: string
mimeType: string
urlPath: string
caption: string | null
source: string | null
displayOrder: number
isFeatured: boolean
}>
annotations: Array<{
id: string
type: string
x: number
y: number
content: string
style: unknown
zIndex: number
createdBy: string | null
}>
}
function formatDate(value: Date | null): string | null {
if (!value) {
return null
}
return value.toISOString().split('T')[0]
}
export function buildGraphPayload(
treeId: string,
persons: Person[],
unions: Union[],
links: ParentChildLink[],
medias: MediaItem[] = [],
annotations: Annotation[] = [],
): GraphPayload {
// Keep DB order from route queries to preserve sibling and union ordering semantics.
return {
persons: persons.map((person) => ({
id: person.id,
firstName: person.firstName,
lastName: person.lastName,
birthName: person.birthName,
birthDate: formatDate(person.birthDate),
deathDate: formatDate(person.deathDate),
birthPlace: person.birthPlace,
profession: person.profession,
region: person.region,
nationality: person.nationality,
avatarUrlPath: person.avatarPath ? `/trees/${treeId}/persons/${person.id}/avatar` : null,
sex: person.sex,
notes: person.notes,
})),
unions: unions.map((union) => ({
id: union.id,
partner1PersonId: union.partner1PersonId,
partner2PersonId: union.partner2PersonId,
unionType: union.unionType,
startDate: formatDate(union.startDate),
endDate: formatDate(union.endDate),
displayOrder: union.displayOrder,
})),
filiations: links.map((link) => ({
id: link.id,
parentPersonId: link.parentPersonId,
childPersonId: link.childPersonId,
viaUnionId: link.viaUnionId,
parentageType: link.parentageType,
displayOrder: link.displayOrder,
})),
medias: medias.map((media) => ({
id: media.id,
personId: media.personId,
type: media.type,
mimeType: media.mimeType,
urlPath: media.mimeType === 'video/youtube' ? media.filePath : `/trees/${treeId}/media/${media.id}`,
caption: media.caption,
source: media.source,
displayOrder: media.displayOrder,
isFeatured: media.isFeatured,
})),
annotations: annotations.map((ann) => ({
id: ann.id,
type: ann.type,
x: ann.x,
y: ann.y,
content: ann.content,
style: ann.style,
zIndex: ann.zIndex,
createdBy: ann.createdBy,
})),
}
}
+234
View File
@@ -0,0 +1,234 @@
import type { Prisma, PrismaClient } from '@prisma/client'
type RelationReader = Pick<PrismaClient, 'parentChildLink' | 'union'>
type UnionLite = {
id: string
partner1PersonId: string
partner2PersonId: string | null
}
type LinkLite = {
id: string
parentPersonId: string
childPersonId: string
viaUnionId: string | null
}
type RelationSnapshot = {
unionsById: Map<string, UnionLite>
links: LinkLite[]
}
type BuildSnapshotOptions = {
excludeLinkId?: string | null
extraUnions?: UnionLite[]
}
export type RelationshipValidationCode =
| 'invalid_union_partners'
| 'forbidden_union_with_ancestor_descendant'
| 'invalid_parent_child_link'
| 'parent_child_cycle_detected'
| 'parent_child_conflicts_with_union'
| 'parent_not_in_union'
| 'child_cannot_be_union_partner'
| 'invalid_union_reference'
type ParentChildValidationOptions = {
excludeLinkId?: string | null
viaUnionId?: string | null
resolvedViaUnion?: UnionLite | null
}
function addEdge(adjacency: Map<string, Set<string>>, parentId: string, childId: string): void {
if (!adjacency.has(parentId)) {
adjacency.set(parentId, new Set())
}
adjacency.get(parentId)?.add(childId)
}
function buildAdjacency(snapshot: RelationSnapshot): Map<string, Set<string>> {
const adjacency = new Map<string, Set<string>>()
snapshot.links.forEach((link) => {
addEdge(adjacency, link.parentPersonId, link.childPersonId)
if (link.viaUnionId) {
const viaUnion = snapshot.unionsById.get(link.viaUnionId)
if (viaUnion) {
addEdge(adjacency, viaUnion.partner1PersonId, link.childPersonId)
if (viaUnion.partner2PersonId) {
addEdge(adjacency, viaUnion.partner2PersonId, link.childPersonId)
}
}
}
})
return adjacency
}
function hasDirectedPath(adjacency: Map<string, Set<string>>, source: string, target: string): boolean {
if (source === target) {
return true
}
const queue: string[] = [source]
const visited = new Set<string>([source])
while (queue.length > 0) {
const current = queue.shift() as string
const children = adjacency.get(current)
if (!children) continue
for (const child of children) {
if (child === target) {
return true
}
if (!visited.has(child)) {
visited.add(child)
queue.push(child)
}
}
}
return false
}
async function loadRelationSnapshot(
tx: RelationReader,
treeId: string,
options: BuildSnapshotOptions = {},
): Promise<RelationSnapshot> {
const linkWhere: Prisma.ParentChildLinkWhereInput = {
treeId,
deletedAt: null,
}
if (options.excludeLinkId) {
linkWhere.id = { not: options.excludeLinkId }
}
const [links, unions] = await Promise.all([
tx.parentChildLink.findMany({
where: linkWhere,
select: {
id: true,
parentPersonId: true,
childPersonId: true,
viaUnionId: true,
},
}),
tx.union.findMany({
where: {
treeId,
deletedAt: null,
},
select: {
id: true,
partner1PersonId: true,
partner2PersonId: true,
},
}),
])
const unionsById = new Map<string, UnionLite>()
unions.forEach((union: UnionLite) => {
unionsById.set(union.id, union)
})
options.extraUnions?.forEach((union) => {
unionsById.set(union.id, union)
})
return { unionsById, links }
}
function isSameUnionPair(union: UnionLite, leftId: string, rightId: string): boolean {
if (!union.partner2PersonId) {
return false
}
return (
(union.partner1PersonId === leftId && union.partner2PersonId === rightId) ||
(union.partner1PersonId === rightId && union.partner2PersonId === leftId)
)
}
export async function findUnionValidationError(
tx: RelationReader,
treeId: string,
partner1PersonId: string,
partner2PersonId: string | null | undefined,
): Promise<RelationshipValidationCode | null> {
if (!partner2PersonId) {
return null
}
if (partner1PersonId === partner2PersonId) {
return 'invalid_union_partners'
}
const snapshot = await loadRelationSnapshot(tx, treeId)
const adjacency = buildAdjacency(snapshot)
if (
hasDirectedPath(adjacency, partner1PersonId, partner2PersonId) ||
hasDirectedPath(adjacency, partner2PersonId, partner1PersonId)
) {
return 'forbidden_union_with_ancestor_descendant'
}
return null
}
export async function findParentChildValidationError(
tx: RelationReader,
treeId: string,
parentPersonId: string,
childPersonId: string,
options: ParentChildValidationOptions = {},
): Promise<RelationshipValidationCode | null> {
if (parentPersonId === childPersonId) {
return 'invalid_parent_child_link'
}
const extraUnions = options.resolvedViaUnion ? [options.resolvedViaUnion] : []
const snapshot = await loadRelationSnapshot(tx, treeId, {
excludeLinkId: options.excludeLinkId ?? null,
extraUnions,
})
const adjacency = buildAdjacency(snapshot)
if (hasDirectedPath(adjacency, childPersonId, parentPersonId)) {
return 'parent_child_cycle_detected'
}
for (const union of snapshot.unionsById.values()) {
if (isSameUnionPair(union, parentPersonId, childPersonId)) {
return 'parent_child_conflicts_with_union'
}
}
const viaUnionId = options.viaUnionId ?? options.resolvedViaUnion?.id ?? null
if (viaUnionId) {
const viaUnion = snapshot.unionsById.get(viaUnionId) ?? (options.resolvedViaUnion && options.resolvedViaUnion.id === viaUnionId ? options.resolvedViaUnion : undefined)
if (!viaUnion) {
return 'invalid_union_reference'
}
const parentInUnion = viaUnion.partner1PersonId === parentPersonId || viaUnion.partner2PersonId === parentPersonId
if (!parentInUnion) {
return 'parent_not_in_union'
}
const childIsUnionPartner = viaUnion.partner1PersonId === childPersonId || viaUnion.partner2PersonId === childPersonId
if (childIsUnionPartner) {
return 'child_cannot_be_union_partner'
}
}
return null
}
+604
View File
@@ -0,0 +1,604 @@
import { randomUUID } from 'node:crypto'
import type { FastifyInstance } from 'fastify'
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
const { prismaMock } = vi.hoisted(() => ({
prismaMock: {
user: {
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
tree: {
findFirst: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
updateMany: vi.fn(),
},
treeMembership: {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
},
treeSettings: {
create: vi.fn(),
findUnique: vi.fn(),
upsert: vi.fn(),
},
treeAccessPasswords: {
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
userTreeAccess: {
findMany: vi.fn(),
findUnique: vi.fn(),
upsert: vi.fn(),
updateMany: vi.fn(),
},
person: {
findMany: vi.fn(),
findFirst: vi.fn(),
create: vi.fn(),
count: vi.fn(),
updateMany: vi.fn(),
},
union: {
findMany: vi.fn(),
findFirst: vi.fn(),
create: vi.fn(),
count: vi.fn(),
updateMany: vi.fn(),
},
parentChildLink: {
findMany: vi.fn(),
findFirst: vi.fn(),
create: vi.fn(),
count: vi.fn(),
updateMany: vi.fn(),
},
mediaItem: {
count: vi.fn(),
create: vi.fn(),
findMany: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
},
annotation: {
findMany: vi.fn(),
},
contributionSession: {
create: vi.fn(),
findMany: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(),
count: vi.fn(),
},
contributionChange: {
create: vi.fn(),
update: vi.fn(),
},
auditLog: {
create: vi.fn(),
findMany: vi.fn(),
},
$transaction: vi.fn(),
$disconnect: vi.fn(),
},
}))
vi.mock('../src/lib/prisma.js', () => ({
prisma: prismaMock,
}))
let createApp: () => FastifyInstance
function applyTestEnv(): void {
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'
}
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' },
)
}
beforeAll(async () => {
applyTestEnv()
const module = await import('../src/app.js')
createApp = module.createApp
})
beforeEach(() => {
vi.clearAllMocks()
prismaMock.user.findUnique.mockResolvedValue(null)
prismaMock.user.create.mockResolvedValue({ id: 'user-1', email: 'user@example.com' })
prismaMock.user.update.mockResolvedValue({ id: 'user-1' })
prismaMock.tree.findFirst.mockResolvedValue({ id: 'tree-1', rootPersonId: null })
prismaMock.tree.findUnique.mockResolvedValue({ id: 'tree-1', slug: 'tree-1', name: 'Tree' })
prismaMock.tree.create.mockResolvedValue({ id: 'tree-1', slug: 'tree-1', name: 'Tree' })
prismaMock.tree.updateMany.mockResolvedValue({ count: 1 })
prismaMock.treeMembership.findMany.mockResolvedValue([])
prismaMock.treeMembership.findUnique.mockResolvedValue({
treeId: 'tree-1',
userId: 'user-1',
role: 'owner',
tree: { deletedAt: null, rootPersonId: null },
})
prismaMock.treeAccessPasswords.findUnique.mockResolvedValue({
treeId: 'tree-1',
visitorHash: 'hash',
contributorHash: 'hash',
updatedAt: new Date('2026-02-11T10:00:00.000Z'),
tree: { deletedAt: null },
})
prismaMock.userTreeAccess.findMany.mockResolvedValue([])
prismaMock.userTreeAccess.findUnique.mockResolvedValue(null)
prismaMock.userTreeAccess.upsert.mockResolvedValue({
id: 'share-1',
treeId: 'tree-1',
userId: 'user-1',
role: 'visitor',
})
prismaMock.userTreeAccess.updateMany.mockResolvedValue({ count: 0 })
prismaMock.treeSettings.findUnique.mockResolvedValue({
treeId: 'tree-1',
memberContributionPolicy: 'pending',
visitorPolicy: 'read_only',
contributorPolicy: 'pending',
})
prismaMock.treeSettings.create.mockResolvedValue({ treeId: 'tree-1' })
prismaMock.person.findMany.mockResolvedValue([])
prismaMock.person.findFirst.mockResolvedValue({ id: 'person-1' })
prismaMock.person.create.mockResolvedValue({ id: 'person-1' })
prismaMock.person.count.mockResolvedValue(0)
prismaMock.person.updateMany.mockResolvedValue({ count: 1 })
prismaMock.union.findMany.mockResolvedValue([])
prismaMock.union.create.mockResolvedValue({ id: 'union-1' })
prismaMock.union.count.mockResolvedValue(0)
prismaMock.parentChildLink.findMany.mockResolvedValue([])
prismaMock.parentChildLink.create.mockResolvedValue({ id: 'link-1' })
prismaMock.parentChildLink.count.mockResolvedValue(0)
prismaMock.mediaItem.findMany.mockResolvedValue([])
prismaMock.mediaItem.count.mockResolvedValue(0)
prismaMock.annotation.findMany.mockResolvedValue([])
prismaMock.contributionSession.count.mockResolvedValue(0)
prismaMock.auditLog.create.mockResolvedValue({ id: 'audit-1' })
prismaMock.$transaction.mockImplementation(async (callback: (tx: typeof prismaMock) => Promise<unknown>) => callback(prismaMock))
prismaMock.$disconnect.mockResolvedValue(undefined)
})
describe('Auth routes', () => {
it('registers a new user and returns a token', async () => {
const app = createApp()
const response = await app.inject({
method: 'POST',
url: '/auth/register',
payload: { email: 'new@example.com', password: 'password123' },
})
expect(response.statusCode).toBe(201)
const body = response.json()
expect(body.token).toBeDefined()
expect(body.user.email).toBe('user@example.com')
await app.close()
})
it('rejects register with existing email', async () => {
const app = createApp()
prismaMock.user.findUnique.mockResolvedValue({ id: 'existing', email: 'taken@example.com' })
const response = await app.inject({
method: 'POST',
url: '/auth/register',
payload: { email: 'taken@example.com', password: 'password123' },
})
expect(response.statusCode).toBe(409)
expect(response.json().error).toBe('email_already_exists')
await app.close()
})
it('rejects register with invalid email', async () => {
const app = createApp()
const response = await app.inject({
method: 'POST',
url: '/auth/register',
payload: { email: 'not-an-email', password: 'password123' },
})
expect(response.statusCode).toBe(400)
expect(response.json().error).toBe('invalid_payload')
await app.close()
})
it('rejects register with short password', async () => {
const app = createApp()
const response = await app.inject({
method: 'POST',
url: '/auth/register',
payload: { email: 'user@example.com', password: '123' },
})
expect(response.statusCode).toBe(400)
await app.close()
})
it('rejects login with wrong credentials', async () => {
const app = createApp()
prismaMock.user.findUnique.mockResolvedValue({
id: 'user-1',
email: 'user@example.com',
passwordHash: '$2a$12$invalid_hash_that_wont_match_anything_00000000000000000',
})
const response = await app.inject({
method: 'POST',
url: '/auth/login',
payload: { email: 'user@example.com', password: 'wrong-password' },
})
expect(response.statusCode).toBe(401)
expect(response.json().error).toBe('invalid_credentials')
await app.close()
})
it('rejects login for non-existent user', async () => {
const app = createApp()
const response = await app.inject({
method: 'POST',
url: '/auth/login',
payload: { email: 'nobody@example.com', password: 'password123' },
})
expect(response.statusCode).toBe(401)
expect(response.json().error).toBe('invalid_credentials')
await app.close()
})
it('rejects logout without token', async () => {
const app = createApp()
const response = await app.inject({
method: 'POST',
url: '/auth/logout',
})
expect(response.statusCode).toBe(401)
await app.close()
})
})
describe('Public routes', () => {
it('resolves tree by slug', async () => {
const app = createApp()
prismaMock.tree.findFirst.mockResolvedValue({
id: 'tree-1',
slug: 'famille-martin',
name: 'Famille Martin',
description: null,
})
const response = await app.inject({
method: 'GET',
url: '/arbre/famille-martin',
})
expect(response.statusCode).toBe(200)
const body = response.json()
expect(body.tree.slug).toBe('famille-martin')
expect(body.tree.name).toBe('Famille Martin')
expect(body.tree).not.toHaveProperty('ownerEmail')
expect(body.tree).not.toHaveProperty('ownerName')
await app.close()
})
it('returns 404 for unknown slug', async () => {
const app = createApp()
prismaMock.tree.findFirst.mockResolvedValue(null)
const response = await app.inject({
method: 'GET',
url: '/arbre/inexistant',
})
expect(response.statusCode).toBe(404)
expect(response.json().error).toBe('tree_not_found')
await app.close()
})
it('returns 400 for invalid slug format', async () => {
const app = createApp()
const response = await app.inject({
method: 'GET',
url: '/arbre/ab',
})
expect(response.statusCode).toBe(400)
await app.close()
})
})
describe('Graph routes', () => {
it('returns graph data for authenticated user', async () => {
const app = createApp()
const token = await createUserToken(app)
prismaMock.person.findMany.mockResolvedValue([
{
id: 'p-1',
treeId: 'tree-1',
firstName: 'Alice',
lastName: 'Martin',
birthDate: new Date('1990-01-15'),
deathDate: null,
sex: 'female',
notes: null,
createdBy: null,
updatedBy: null,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
},
])
prismaMock.union.findMany.mockResolvedValue([])
prismaMock.parentChildLink.findMany.mockResolvedValue([])
prismaMock.mediaItem.findMany.mockResolvedValue([])
const response = await app.inject({
method: 'GET',
url: '/trees/tree-1/graph',
headers: { authorization: `Bearer ${token}` },
})
expect(response.statusCode).toBe(200)
const body = response.json()
expect(body.treeId).toBe('tree-1')
expect(body.graph.persons).toHaveLength(1)
expect(body.graph.persons[0].firstName).toBe('Alice')
expect(body.graph.unions).toHaveLength(0)
expect(body.graph.filiations).toHaveLength(0)
await app.close()
})
it('rejects graph access without auth', async () => {
const app = createApp()
const response = await app.inject({
method: 'GET',
url: '/trees/tree-1/graph',
})
expect(response.statusCode).toBe(401)
await app.close()
})
it('rejects graph access for non-member user', async () => {
const app = createApp()
const token = await createUserToken(app)
prismaMock.treeMembership.findUnique.mockResolvedValue(null)
const response = await app.inject({
method: 'GET',
url: '/trees/tree-1/graph',
headers: { authorization: `Bearer ${token}` },
})
expect(response.statusCode).toBe(403)
await app.close()
})
it('allows tree access token to fetch graph', async () => {
const app = createApp()
await app.ready()
const treeAccessToken = (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(),
})
prismaMock.tree.findFirst.mockResolvedValue({ id: 'tree-1', rootPersonId: 'p-1' })
prismaMock.person.findMany.mockResolvedValue([])
prismaMock.union.findMany.mockResolvedValue([])
prismaMock.parentChildLink.findMany.mockResolvedValue([])
prismaMock.mediaItem.findMany.mockResolvedValue([])
const response = await app.inject({
method: 'GET',
url: '/trees/tree-1/graph',
headers: { authorization: `Bearer ${treeAccessToken}` },
})
expect(response.statusCode).toBe(200)
expect(response.json().rootPersonId).toBe('p-1')
await app.close()
})
})
describe('Health check', () => {
it('returns ok', async () => {
const app = createApp()
const response = await app.inject({
method: 'GET',
url: '/health',
})
expect(response.statusCode).toBe(200)
expect(response.json()).toEqual({ ok: true })
await app.close()
})
})
describe('Tree CRUD', () => {
it('creates a tree and returns it', async () => {
const app = createApp()
const token = await createUserToken(app)
const response = await app.inject({
method: 'POST',
url: '/trees',
headers: { authorization: `Bearer ${token}` },
payload: {
name: 'Mon Arbre',
slug: 'mon-arbre',
visitorPassword: 'visitor-pass-123',
contributorPassword: 'contributor-pass-123',
},
})
expect(response.statusCode).toBe(201)
expect(prismaMock.tree.create).toHaveBeenCalled()
const createCall = prismaMock.tree.create.mock.calls[0][0]
expect(createCall.data.memberships.create.role).toBe('owner')
expect(createCall.data.settings.create).toBeDefined()
expect(createCall.data.accessPasswords.create.visitorHash).toBeDefined()
await app.close()
})
it('requires auth to create a tree', async () => {
const app = createApp()
const response = await app.inject({
method: 'POST',
url: '/trees',
payload: { name: 'Mon Arbre', slug: 'mon-arbre' },
})
expect(response.statusCode).toBe(401)
await app.close()
})
it('lists user trees', async () => {
const app = createApp()
const token = await createUserToken(app)
prismaMock.treeMembership.findMany.mockResolvedValue([
{
role: 'owner',
tree: { id: 'tree-1', slug: 'mon-arbre', name: 'Mon Arbre', description: null, rootPersonId: null, createdAt: new Date() },
},
])
const response = await app.inject({
method: 'GET',
url: '/trees',
headers: { authorization: `Bearer ${token}` },
})
expect(response.statusCode).toBe(200)
const body = response.json()
expect(body.trees).toHaveLength(1)
expect(body.trees[0].name).toBe('Mon Arbre')
await app.close()
})
it('requires auth to list trees', async () => {
const app = createApp()
const response = await app.inject({
method: 'GET',
url: '/trees',
})
expect(response.statusCode).toBe(401)
await app.close()
})
})
describe('Relationship guardrails', () => {
it('rejects creating a union between ancestor and descendant', async () => {
const app = createApp()
const token = await createUserToken(app)
prismaMock.person.findFirst.mockImplementation(async (args: any) => ({
id: args.where.id,
}))
prismaMock.parentChildLink.findMany.mockResolvedValue([
{
id: 'link-1',
parentPersonId: 'person-parent',
childPersonId: 'person-child',
viaUnionId: null,
},
])
prismaMock.union.findMany.mockResolvedValue([])
const response = await app.inject({
method: 'POST',
url: '/trees/tree-1/unions',
headers: { authorization: `Bearer ${token}` },
payload: {
partner1PersonId: 'person-parent',
partner2PersonId: 'person-child',
unionType: 'mariage',
},
})
expect(response.statusCode).toBe(400)
expect(response.json().error).toBe('forbidden_union_with_ancestor_descendant')
expect(prismaMock.union.create).not.toHaveBeenCalled()
await app.close()
})
it('rejects creating a parent-child link that introduces a cycle', async () => {
const app = createApp()
const token = await createUserToken(app)
prismaMock.person.findFirst.mockImplementation(async (args: any) => ({
id: args.where.id,
}))
prismaMock.union.findMany.mockResolvedValue([])
prismaMock.parentChildLink.findMany.mockResolvedValue([
{
id: 'link-a',
parentPersonId: 'a',
childPersonId: 'b',
viaUnionId: null,
},
{
id: 'link-b',
parentPersonId: 'b',
childPersonId: 'c',
viaUnionId: null,
},
])
const response = await app.inject({
method: 'POST',
url: '/trees/tree-1/links',
headers: { authorization: `Bearer ${token}` },
payload: {
parentPersonId: 'c',
childPersonId: 'a',
parentageType: 'biologique',
},
})
expect(response.statusCode).toBe(400)
expect(response.json().error).toBe('parent_child_cycle_detected')
expect(prismaMock.parentChildLink.create).not.toHaveBeenCalled()
await app.close()
})
})
@@ -0,0 +1,935 @@
import { randomUUID } from 'node:crypto'
import type { FastifyInstance } from 'fastify'
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
const { prismaMock } = vi.hoisted(() => ({
prismaMock: {
user: {
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
tree: {
findFirst: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
updateMany: vi.fn(),
},
treeMembership: {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
},
treeSettings: {
create: vi.fn(),
findUnique: vi.fn(),
upsert: vi.fn(),
},
treeAccessPasswords: {
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
userTreeAccess: {
findMany: vi.fn(),
findUnique: vi.fn(),
upsert: vi.fn(),
updateMany: vi.fn(),
},
person: {
findMany: vi.fn(),
findFirst: vi.fn(),
create: vi.fn(),
count: vi.fn(),
updateMany: vi.fn(),
},
union: {
findMany: vi.fn(),
findFirst: vi.fn(),
create: vi.fn(),
count: vi.fn(),
updateMany: vi.fn(),
},
parentChildLink: {
findMany: vi.fn(),
findFirst: vi.fn(),
create: vi.fn(),
count: vi.fn(),
updateMany: vi.fn(),
},
mediaItem: {
count: vi.fn(),
create: vi.fn(),
findMany: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
},
annotation: {
findMany: vi.fn(),
},
contributionSession: {
create: vi.fn(),
findMany: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(),
count: vi.fn(),
},
contributionChange: {
create: vi.fn(),
update: vi.fn(),
},
auditLog: {
create: vi.fn(),
findMany: vi.fn(),
},
$transaction: vi.fn(),
$disconnect: vi.fn(),
},
}))
vi.mock('../src/lib/prisma.js', () => ({
prisma: prismaMock,
}))
let createApp: () => FastifyInstance
function applyTestEnv(): void {
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'
}
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',
},
)
}
beforeAll(async () => {
applyTestEnv()
const module = await import('../src/app.js')
createApp = module.createApp
})
beforeEach(() => {
vi.clearAllMocks()
prismaMock.user.findUnique.mockResolvedValue(null)
prismaMock.user.create.mockResolvedValue({ id: 'user-1', email: 'user@example.com' })
prismaMock.user.update.mockResolvedValue({ id: 'user-1' })
prismaMock.tree.findFirst.mockResolvedValue({ id: 'tree-1', rootPersonId: null })
prismaMock.tree.findUnique.mockResolvedValue({ id: 'tree-1', slug: 'tree-1', name: 'Tree' })
prismaMock.tree.create.mockResolvedValue({ id: 'tree-1', slug: 'tree-1', name: 'Tree' })
prismaMock.tree.updateMany.mockResolvedValue({ count: 1 })
prismaMock.treeMembership.findMany.mockResolvedValue([])
prismaMock.treeMembership.findUnique.mockResolvedValue({
treeId: 'tree-1',
userId: 'user-1',
role: 'owner',
tree: { deletedAt: null, rootPersonId: null },
})
prismaMock.treeAccessPasswords.findUnique.mockResolvedValue({
treeId: 'tree-1',
visitorHash: 'hash',
contributorHash: 'hash',
updatedAt: new Date('2026-02-11T10:00:00.000Z'),
tree: { deletedAt: null },
})
prismaMock.userTreeAccess.findMany.mockResolvedValue([])
prismaMock.userTreeAccess.findUnique.mockResolvedValue(null)
prismaMock.userTreeAccess.upsert.mockResolvedValue({
id: 'share-1',
treeId: 'tree-1',
userId: 'user-1',
role: 'visitor',
})
prismaMock.userTreeAccess.updateMany.mockResolvedValue({ count: 0 })
prismaMock.treeAccessPasswords.update.mockResolvedValue({ treeId: 'tree-1' })
prismaMock.treeSettings.findUnique.mockResolvedValue({
treeId: 'tree-1',
memberContributionPolicy: 'pending',
visitorPolicy: 'read_only',
contributorPolicy: 'pending',
})
prismaMock.treeSettings.upsert.mockResolvedValue({
treeId: 'tree-1',
memberContributionPolicy: 'direct',
visitorPolicy: 'read_only',
contributorPolicy: 'pending',
})
prismaMock.person.findMany.mockResolvedValue([])
prismaMock.person.findFirst.mockImplementation(async (args: any) => ({
id: args?.where?.id || 'person-1',
treeId: 'tree-1',
deletedAt: null,
}))
prismaMock.person.create.mockResolvedValue({ id: 'person-1' })
prismaMock.person.count.mockResolvedValue(2)
prismaMock.person.updateMany.mockResolvedValue({ count: 1 })
prismaMock.union.findMany.mockResolvedValue([
{
id: 'union-1',
partner1PersonId: 'person-1',
partner2PersonId: null,
treeId: 'tree-1',
deletedAt: null,
},
])
prismaMock.union.findFirst.mockResolvedValue({
id: 'union-1',
partner1PersonId: 'person-1',
partner2PersonId: null,
treeId: 'tree-1',
deletedAt: null,
})
prismaMock.union.create.mockResolvedValue({ id: 'union-1' })
prismaMock.union.count.mockResolvedValue(1)
prismaMock.union.updateMany.mockResolvedValue({ count: 1 })
prismaMock.parentChildLink.findMany.mockResolvedValue([])
prismaMock.parentChildLink.findFirst.mockResolvedValue({
id: 'link-1',
treeId: 'tree-1',
parentPersonId: 'person-1',
childPersonId: 'person-2',
viaUnionId: 'union-1',
deletedAt: null,
})
prismaMock.parentChildLink.create.mockResolvedValue({ id: 'link-1' })
prismaMock.parentChildLink.count.mockResolvedValue(1)
prismaMock.parentChildLink.updateMany.mockResolvedValue({ count: 1 })
prismaMock.mediaItem.count.mockResolvedValue(0)
prismaMock.mediaItem.create.mockResolvedValue({ id: 'media-1' })
prismaMock.mediaItem.findMany.mockResolvedValue([])
prismaMock.mediaItem.findFirst.mockResolvedValue({ id: 'media-1', filePath: 'tree-1/person-1/media-1.jpg', mimeType: 'image/jpeg' })
prismaMock.mediaItem.update.mockResolvedValue({ id: 'media-1' })
prismaMock.mediaItem.updateMany.mockResolvedValue({ count: 1 })
prismaMock.annotation.findMany.mockResolvedValue([])
prismaMock.contributionSession.create.mockResolvedValue({
id: 'session-1',
treeId: 'tree-1',
status: 'pending',
})
prismaMock.contributionSession.findMany.mockResolvedValue([])
prismaMock.contributionSession.findFirst.mockResolvedValue({
id: 'session-1',
treeId: 'tree-1',
status: 'pending',
changes: [],
})
prismaMock.contributionSession.update.mockResolvedValue({
id: 'session-1',
treeId: 'tree-1',
status: 'approved',
})
prismaMock.contributionSession.count.mockResolvedValue(0)
prismaMock.contributionChange.create.mockResolvedValue({ id: 'change-1' })
prismaMock.contributionChange.update.mockResolvedValue({ id: 'change-1' })
prismaMock.auditLog.create.mockResolvedValue({ id: 'audit-1' })
prismaMock.auditLog.findMany.mockResolvedValue([])
prismaMock.$transaction.mockImplementation(async (callback: (tx: typeof prismaMock) => Promise<unknown>) => callback(prismaMock))
prismaMock.$disconnect.mockResolvedValue(undefined)
})
describe('API security and authz', () => {
it('rate-limits login endpoint', async () => {
const app = createApp()
for (let i = 0; i < 10; i += 1) {
await app.inject({
method: 'POST',
url: '/auth/login',
payload: { email: 'user@example.com', password: 'password123' },
})
}
const response = await app.inject({
method: 'POST',
url: '/auth/login',
payload: { email: 'user@example.com', password: 'password123' },
})
expect(response.statusCode).toBe(429)
await app.close()
})
it('rate-limits register endpoint', async () => {
const app = createApp()
prismaMock.user.findUnique.mockResolvedValue({ id: 'existing-user' })
for (let i = 0; i < 20; i += 1) {
await app.inject({
method: 'POST',
url: '/auth/register',
payload: { email: `user${i}@example.com`, password: 'password123' },
})
}
const response = await app.inject({
method: 'POST',
url: '/auth/register',
payload: { email: 'overflow@example.com', password: 'password123' },
})
expect(response.statusCode).toBe(429)
await app.close()
}, 15000)
it('revokes user token on logout', async () => {
const app = createApp()
const token = await createUserToken(app)
const logoutResponse = await app.inject({
method: 'POST',
url: '/auth/logout',
headers: { authorization: `Bearer ${token}` },
})
expect(logoutResponse.statusCode).toBe(200)
const treesResponse = await app.inject({
method: 'GET',
url: '/trees',
headers: { authorization: `Bearer ${token}` },
})
expect(treesResponse.statusCode).toBe(401)
await app.close()
})
it('rejects stale tree access tokens after access secret changes', async () => {
const app = createApp()
await app.ready()
prismaMock.treeAccessPasswords.findUnique.mockResolvedValue({
treeId: 'tree-1',
visitorHash: 'hash',
contributorHash: 'hash',
updatedAt: new Date('2026-02-12T10:00:00.000Z'),
tree: { deletedAt: null },
})
const staleTreeAccessToken = (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 response = await app.inject({
method: 'GET',
url: '/trees/tree-1/graph',
headers: { authorization: `Bearer ${staleTreeAccessToken}` },
})
expect(response.statusCode).toBe(401)
await app.close()
})
it('does not update soft-deleted tree metadata', async () => {
const app = createApp()
const token = await createUserToken(app)
prismaMock.tree.updateMany.mockResolvedValue({ count: 0 })
const response = await app.inject({
method: 'PATCH',
url: '/trees/tree-1',
headers: { authorization: `Bearer ${token}` },
payload: { name: 'Updated Name' },
})
expect(response.statusCode).toBe(404)
await app.close()
})
it('validates rootPersonId belongs to tree before patching tree metadata', async () => {
const app = createApp()
const token = await createUserToken(app)
prismaMock.person.findFirst.mockResolvedValue(null)
const response = await app.inject({
method: 'PATCH',
url: '/trees/tree-1',
headers: { authorization: `Bearer ${token}` },
payload: { rootPersonId: 'missing-person' },
})
expect(response.statusCode).toBe(400)
await app.close()
})
it('allows owner to create persons, unions and links', async () => {
const app = createApp()
const token = await createUserToken(app)
const personResponse = await app.inject({
method: 'POST',
url: '/trees/tree-1/persons',
headers: { authorization: `Bearer ${token}` },
payload: { firstName: 'Alice', lastName: 'Martin' },
})
const unionResponse = await app.inject({
method: 'POST',
url: '/trees/tree-1/unions',
headers: { authorization: `Bearer ${token}` },
payload: { partner1PersonId: 'person-1', partner2PersonId: 'person-2' },
})
const linkResponse = await app.inject({
method: 'POST',
url: '/trees/tree-1/links',
headers: { authorization: `Bearer ${token}` },
payload: { parentPersonId: 'person-1', childPersonId: 'person-2', viaUnionId: 'union-1' },
})
expect(personResponse.statusCode).toBe(201)
expect(unionResponse.statusCode).toBe(201)
expect(linkResponse.statusCode).toBe(201)
await app.close()
})
it('forbids member from creating persons', 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: 'POST',
url: '/trees/tree-1/persons',
headers: { authorization: `Bearer ${token}` },
payload: { firstName: 'Alice', lastName: 'Martin' },
})
expect(response.statusCode).toBe(403)
await app.close()
})
it('allows owner to patch and soft-delete a person', async () => {
const app = createApp()
const token = await createUserToken(app)
const patchResponse = await app.inject({
method: 'PATCH',
url: '/trees/tree-1/persons/person-1',
headers: { authorization: `Bearer ${token}` },
payload: {
firstName: 'Alice',
notes: 'Mise a jour',
},
})
expect(patchResponse.statusCode).toBe(200)
expect(prismaMock.person.updateMany).toHaveBeenCalled()
prismaMock.union.updateMany.mockResolvedValueOnce({ count: 2 })
prismaMock.parentChildLink.updateMany.mockResolvedValueOnce({ count: 3 })
prismaMock.mediaItem.updateMany.mockResolvedValueOnce({ count: 1 })
const deleteResponse = await app.inject({
method: 'DELETE',
url: '/trees/tree-1/persons/person-1',
headers: { authorization: `Bearer ${token}` },
})
expect(deleteResponse.statusCode).toBe(200)
expect(deleteResponse.json()).toEqual({
ok: true,
cascaded: {
unions: 2,
links: 3,
medias: 1,
},
})
await app.close()
})
it('allows owner to patch union and link entities', async () => {
const app = createApp()
const token = await createUserToken(app)
const patchUnionResponse = await app.inject({
method: 'PATCH',
url: '/trees/tree-1/unions/union-1',
headers: { authorization: `Bearer ${token}` },
payload: {
unionType: 'mariage',
},
})
expect(patchUnionResponse.statusCode).toBe(200)
expect(prismaMock.union.updateMany).toHaveBeenCalled()
const patchLinkResponse = await app.inject({
method: 'PATCH',
url: '/trees/tree-1/links/link-1',
headers: { authorization: `Bearer ${token}` },
payload: {
parentageType: 'adoption',
},
})
expect(patchLinkResponse.statusCode).toBe(200)
expect(prismaMock.parentChildLink.updateMany).toHaveBeenCalled()
await app.close()
})
it('forbids member from patching person records', 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: 'PATCH',
url: '/trees/tree-1/persons/person-1',
headers: { authorization: `Bearer ${token}` },
payload: {
firstName: 'Alice',
},
})
expect(response.statusCode).toBe(403)
await app.close()
})
it('allows contributor access token to submit a pending contribution session', 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',
rootPersonId: null,
settings: {
contributorPolicy: 'pending',
},
})
const response = await app.inject({
method: 'POST',
url: '/trees/tree-1/contributions/sessions',
headers: { authorization: `Bearer ${contributorToken}` },
payload: {
title: 'Ajout photo branche maternelle',
changes: [
{
entityType: 'person',
action: 'create',
after: { firstName: 'Alice', lastName: 'Martin' },
},
],
},
})
expect(response.statusCode).toBe(201)
expect(prismaMock.contributionSession.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: 'pending',
mode: 'tree_access',
}),
}),
)
await app.close()
})
it('allows admin to list pending contribution sessions', async () => {
const app = createApp()
const token = await createUserToken(app)
prismaMock.contributionSession.findMany.mockResolvedValue([
{
id: 'session-1',
treeId: 'tree-1',
status: 'pending',
title: 'Session pending',
changes: [],
},
])
const response = await app.inject({
method: 'GET',
url: '/trees/tree-1/contributions/sessions?status=pending',
headers: { authorization: `Bearer ${token}` },
})
expect(response.statusCode).toBe(200)
expect(response.json()).toEqual({
sessions: [
{
id: 'session-1',
treeId: 'tree-1',
status: 'pending',
title: 'Session pending',
changes: [],
},
],
})
await app.close()
})
it('forbids non-admin user from listing contribution sessions', 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: 'GET',
url: '/trees/tree-1/contributions/sessions',
headers: { authorization: `Bearer ${token}` },
})
expect(response.statusCode).toBe(403)
await app.close()
})
it('allows admin to review a contribution session', 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' },
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.contributionSession.update).toHaveBeenCalled()
expect(prismaMock.contributionChange.update).toHaveBeenCalled()
await app.close()
})
it('allows owner to upload media below image limit', async () => {
const app = createApp()
const token = await createUserToken(app)
const response = await app.inject({
method: 'POST',
url: '/trees/tree-1/persons/person-1/media',
headers: { authorization: `Bearer ${token}` },
payload: {
type: 'photo',
fileName: 'portrait.jpg',
mimeType: 'image/jpeg',
sizeBytes: 5,
dataBase64: Buffer.from('hello').toString('base64'),
caption: 'Portrait famille',
},
})
expect(response.statusCode).toBe(201)
await app.close()
})
it('rejects image upload above 5MB', async () => {
const app = createApp()
const token = await createUserToken(app)
const tooBig = Buffer.alloc((5 * 1024 * 1024) + 1, 1)
const response = await app.inject({
method: 'POST',
url: '/trees/tree-1/persons/person-1/media',
headers: { authorization: `Bearer ${token}` },
payload: {
type: 'photo',
fileName: 'big.jpg',
mimeType: 'image/jpeg',
sizeBytes: tooBig.length,
dataBase64: tooBig.toString('base64'),
},
})
expect(response.statusCode).toBe(400)
await app.close()
})
it('forbids non-admin from uploading media', 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: 'POST',
url: '/trees/tree-1/persons/person-1/media',
headers: { authorization: `Bearer ${token}` },
payload: {
type: 'photo',
fileName: 'portrait.jpg',
mimeType: 'image/jpeg',
sizeBytes: 5,
dataBase64: Buffer.from('hello').toString('base64'),
},
})
expect(response.statusCode).toBe(403)
await app.close()
})
it('allows owner to upload citation without file payload', async () => {
const app = createApp()
const token = await createUserToken(app)
const response = await app.inject({
method: 'POST',
url: '/trees/tree-1/persons/person-1/media',
headers: { authorization: `Bearer ${token}` },
payload: {
type: 'citation',
caption: 'Le passe guide l avenir',
source: 'Journal familial',
},
})
expect(response.statusCode).toBe(201)
expect(prismaMock.mediaItem.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
type: 'citation',
mimeType: 'text/plain',
caption: 'Le passe guide l avenir',
source: 'Journal familial',
}),
}),
)
await app.close()
})
it('allows owner to upload geojson media', async () => {
const app = createApp()
const token = await createUserToken(app)
const geojsonText = JSON.stringify({
type: 'FeatureCollection',
features: [
{
type: 'Feature',
geometry: { type: 'Point', coordinates: [2.35, 48.85] },
properties: { label: 'Paris' },
},
],
})
const response = await app.inject({
method: 'POST',
url: '/trees/tree-1/persons/person-1/media',
headers: { authorization: `Bearer ${token}` },
payload: {
type: 'geojson',
fileName: 'migration.geojson',
mimeType: 'application/geo+json',
sizeBytes: Buffer.byteLength(geojsonText),
dataBase64: Buffer.from(geojsonText).toString('base64'),
caption: 'Migration',
},
})
expect(response.statusCode).toBe(201)
await app.close()
})
it('rejects mismatched mime type for gpx media', async () => {
const app = createApp()
const token = await createUserToken(app)
const fakePayload = '<gpx version=\"1.1\"></gpx>'
const response = await app.inject({
method: 'POST',
url: '/trees/tree-1/persons/person-1/media',
headers: { authorization: `Bearer ${token}` },
payload: {
type: 'gpx',
fileName: 'trace.gpx',
mimeType: 'image/png',
sizeBytes: Buffer.byteLength(fakePayload),
dataBase64: Buffer.from(fakePayload).toString('base64'),
},
})
expect(response.statusCode).toBe(400)
expect(response.json().error).toBe('invalid_media_mime_type')
await app.close()
})
it('allows owner to read tree stats', async () => {
const app = createApp()
const token = await createUserToken(app)
prismaMock.mediaItem.count.mockResolvedValue(3)
prismaMock.contributionSession.count
.mockResolvedValueOnce(4)
.mockResolvedValueOnce(9)
const response = await app.inject({
method: 'GET',
url: '/trees/tree-1/stats',
headers: { authorization: `Bearer ${token}` },
})
expect(response.statusCode).toBe(200)
expect(response.json()).toEqual({
stats: {
persons: 2,
unions: 1,
filiations: 1,
medias: 3,
pendingContributions: 4,
totalContributions: 9,
},
})
await app.close()
})
it('forbids non-admin from reading tree stats', 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: 'GET',
url: '/trees/tree-1/stats',
headers: { authorization: `Bearer ${token}` },
})
expect(response.statusCode).toBe(403)
await app.close()
})
it('allows owner to update tree settings policy', async () => {
const app = createApp()
const token = await createUserToken(app)
const response = await app.inject({
method: 'PATCH',
url: '/trees/tree-1/settings',
headers: { authorization: `Bearer ${token}` },
payload: {
memberContributionPolicy: 'direct',
},
})
expect(response.statusCode).toBe(200)
expect(prismaMock.treeSettings.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { treeId: 'tree-1' },
update: expect.objectContaining({
memberContributionPolicy: 'direct',
}),
}),
)
expect(prismaMock.auditLog.create).toHaveBeenCalled()
await app.close()
})
it('lists audit logs for admin with pagination cursor', async () => {
const app = createApp()
const token = await createUserToken(app)
prismaMock.auditLog.findMany.mockResolvedValue([
{
id: 'audit-2',
treeId: 'tree-1',
actorType: 'user',
actorId: 'user-1',
action: 'tree_settings_updated',
entityType: 'tree_settings',
entityId: 'tree-1',
payloadJson: { memberContributionPolicy: 'direct' },
createdAt: new Date('2026-02-11T12:00:00.000Z'),
},
])
const response = await app.inject({
method: 'GET',
url: '/trees/tree-1/audit?limit=20&offset=0',
headers: { authorization: `Bearer ${token}` },
})
expect(response.statusCode).toBe(200)
expect(response.json()).toEqual({
logs: [
{
id: 'audit-2',
treeId: 'tree-1',
actorType: 'user',
actorId: 'user-1',
action: 'tree_settings_updated',
entityType: 'tree_settings',
entityId: 'tree-1',
payloadJson: { memberContributionPolicy: 'direct' },
createdAt: '2026-02-11T12:00:00.000Z',
},
],
nextOffset: null,
})
await app.close()
})
})
+14
View File
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest'
import { hashPassword, verifyPassword } from '../src/lib/auth.js'
describe('auth password helpers', () => {
it('hashes and verifies passwords', async () => {
const password = 'super-safe-password'
const hash = await hashPassword(password)
expect(hash).not.toBe(password)
expect(await verifyPassword(password, hash)).toBe(true)
expect(await verifyPassword('wrong-password', hash)).toBe(false)
}, 15000)
})
+726
View File
@@ -0,0 +1,726 @@
import { randomUUID } from 'node:crypto'
import type { FastifyInstance } from 'fastify'
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
/**
* E2E Journey Tests — Tests the 4 core user journeys
* using Fastify inject with a mocked Prisma layer.
*
* Journey 1: Owner creates account + tree + adds persons
* Journey 2: Visitor accesses tree via slug + password
* Journey 3: Contributor submits a session
* Journey 4: Admin reviews the session
*/
const { prismaMock } = vi.hoisted(() => ({
prismaMock: {
user: {
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
tree: {
findFirst: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
updateMany: vi.fn(),
},
treeMembership: {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
},
treeSettings: {
create: vi.fn(),
findUnique: vi.fn(),
upsert: vi.fn(),
},
treeAccessPasswords: {
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
userTreeAccess: {
findMany: vi.fn(),
findUnique: vi.fn(),
upsert: vi.fn(),
updateMany: vi.fn(),
},
person: {
findMany: vi.fn(),
findFirst: vi.fn(),
create: vi.fn(),
count: vi.fn(),
updateMany: vi.fn(),
},
union: {
findMany: vi.fn(),
findFirst: vi.fn(),
create: vi.fn(),
count: vi.fn(),
updateMany: vi.fn(),
},
parentChildLink: {
findMany: vi.fn(),
findFirst: vi.fn(),
create: vi.fn(),
count: vi.fn(),
updateMany: vi.fn(),
},
mediaItem: {
count: vi.fn(),
create: vi.fn(),
findMany: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
},
annotation: {
findMany: vi.fn(),
},
contributionSession: {
create: vi.fn(),
findMany: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(),
count: vi.fn(),
},
contributionChange: {
create: vi.fn(),
update: vi.fn(),
},
auditLog: {
create: vi.fn(),
findMany: vi.fn(),
},
$transaction: vi.fn(),
$disconnect: vi.fn(),
},
}))
vi.mock('../src/lib/prisma.js', () => ({
prisma: prismaMock,
}))
let createApp: () => FastifyInstance
function applyTestEnv(): void {
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'
}
beforeAll(async () => {
applyTestEnv()
const module = await import('../src/app.js')
createApp = module.createApp
})
beforeEach(() => {
vi.clearAllMocks()
prismaMock.user.findUnique.mockResolvedValue(null)
prismaMock.user.create.mockResolvedValue({ id: 'user-1', email: 'owner@example.com' })
prismaMock.user.update.mockResolvedValue({ id: 'user-1' })
prismaMock.tree.findFirst.mockResolvedValue({ id: 'tree-1', rootPersonId: null, deletedAt: null })
prismaMock.tree.findUnique.mockResolvedValue({ id: 'tree-1', slug: 'famille-martin', name: 'Famille Martin' })
prismaMock.tree.create.mockResolvedValue({ id: 'tree-1', slug: 'famille-martin', name: 'Famille Martin' })
prismaMock.tree.updateMany.mockResolvedValue({ count: 1 })
prismaMock.treeMembership.findMany.mockResolvedValue([])
prismaMock.treeMembership.findUnique.mockResolvedValue({
treeId: 'tree-1',
userId: 'user-1',
role: 'owner',
contributionModeOverride: null,
tree: { deletedAt: null, rootPersonId: null, settings: { memberContributionPolicy: 'pending' } },
})
prismaMock.treeMembership.create.mockResolvedValue({ treeId: 'tree-1', userId: 'user-1', role: 'owner' })
prismaMock.treeAccessPasswords.findUnique.mockResolvedValue({
treeId: 'tree-1',
visitorHash: '$2a$12$examplehashvisitor000000000000000000000000000000',
contributorHash: '$2a$12$examplehashcontrib0000000000000000000000000000000',
updatedAt: new Date('2026-02-11T10:00:00.000Z'),
tree: { deletedAt: null },
})
prismaMock.userTreeAccess.findMany.mockResolvedValue([])
prismaMock.userTreeAccess.findUnique.mockResolvedValue(null)
prismaMock.userTreeAccess.upsert.mockResolvedValue({
id: 'share-1',
treeId: 'tree-1',
userId: 'user-1',
role: 'visitor',
})
prismaMock.userTreeAccess.updateMany.mockResolvedValue({ count: 0 })
prismaMock.treeSettings.findUnique.mockResolvedValue({
treeId: 'tree-1',
memberContributionPolicy: 'pending',
visitorPolicy: 'read_only',
contributorPolicy: 'pending',
})
prismaMock.treeSettings.create.mockResolvedValue({ treeId: 'tree-1' })
prismaMock.person.findMany.mockResolvedValue([])
prismaMock.person.findFirst.mockResolvedValue({ id: 'person-1', treeId: 'tree-1', deletedAt: null })
prismaMock.person.create.mockResolvedValue({
id: 'person-1',
treeId: 'tree-1',
firstName: 'Pierre',
lastName: 'Martin',
})
prismaMock.person.count.mockResolvedValue(0)
prismaMock.person.updateMany.mockResolvedValue({ count: 1 })
prismaMock.union.findMany.mockResolvedValue([])
prismaMock.union.findFirst.mockResolvedValue({
id: 'union-1',
treeId: 'tree-1',
deletedAt: null,
partner1PersonId: 'person-2',
partner2PersonId: null,
})
prismaMock.union.create.mockResolvedValue({ id: 'union-1' })
prismaMock.union.count.mockResolvedValue(0)
prismaMock.parentChildLink.findMany.mockResolvedValue([])
prismaMock.parentChildLink.findFirst.mockResolvedValue({ id: 'link-1', treeId: 'tree-1', deletedAt: null })
prismaMock.parentChildLink.create.mockResolvedValue({ id: 'link-1' })
prismaMock.parentChildLink.count.mockResolvedValue(0)
prismaMock.mediaItem.findMany.mockResolvedValue([])
prismaMock.mediaItem.count.mockResolvedValue(0)
prismaMock.annotation.findMany.mockResolvedValue([])
prismaMock.contributionSession.create.mockImplementation(async (args: any) => ({
id: 'session-1',
treeId: args.data.treeId,
status: args.data.status,
title: args.data.title,
...args.data,
}))
prismaMock.contributionSession.findFirst.mockResolvedValue(null)
prismaMock.contributionSession.findMany.mockResolvedValue([])
prismaMock.contributionSession.count.mockResolvedValue(0)
prismaMock.contributionSession.update.mockImplementation(async (args: any) => ({
id: args.where.id,
...args.data,
}))
prismaMock.contributionChange.create.mockImplementation(async (args: any) => ({
id: `change-${randomUUID().slice(0, 8)}`,
...args.data,
}))
prismaMock.contributionChange.update.mockResolvedValue({ id: 'change-1' })
prismaMock.auditLog.create.mockResolvedValue({ id: 'audit-1' })
prismaMock.auditLog.findMany.mockResolvedValue([])
prismaMock.$transaction.mockImplementation(async (callback: (tx: typeof prismaMock) => Promise<unknown>) => callback(prismaMock))
prismaMock.$disconnect.mockResolvedValue(undefined)
})
describe('Journey 1 — Owner creates account, tree, and adds family', () => {
it('full owner onboarding flow', async () => {
const app = createApp()
// Step 1: Register
const registerRes = await app.inject({
method: 'POST',
url: '/auth/register',
payload: { email: 'owner@example.com', password: 'securepass123' },
})
expect(registerRes.statusCode).toBe(201)
const { token } = registerRes.json()
expect(token).toBeDefined()
// Step 2: Create tree
const createTreeRes = await app.inject({
method: 'POST',
url: '/trees',
headers: { authorization: `Bearer ${token}` },
payload: {
name: 'Famille Martin',
slug: 'famille-martin',
visitorPassword: 'visitor-pass-123',
contributorPassword: 'contributor-pass-123',
},
})
expect(createTreeRes.statusCode).toBe(201)
expect(prismaMock.tree.create).toHaveBeenCalled()
// Step 3: Add self (person)
const addSelfRes = await app.inject({
method: 'POST',
url: '/trees/tree-1/persons',
headers: { authorization: `Bearer ${token}` },
payload: {
firstName: 'Pierre',
lastName: 'Martin',
birthDate: '1990-03-15',
sex: 'male',
},
})
expect(addSelfRes.statusCode).toBe(201)
// Step 4: Add parent
prismaMock.person.create.mockResolvedValue({ id: 'person-2', firstName: 'Jean', lastName: 'Martin' })
const addParentRes = await app.inject({
method: 'POST',
url: '/trees/tree-1/persons',
headers: { authorization: `Bearer ${token}` },
payload: {
firstName: 'Jean',
lastName: 'Martin',
birthDate: '1960-07-20',
sex: 'male',
},
})
expect(addParentRes.statusCode).toBe(201)
// Step 5: Create union (parents)
const addUnionRes = await app.inject({
method: 'POST',
url: '/trees/tree-1/unions',
headers: { authorization: `Bearer ${token}` },
payload: {
partner1PersonId: 'person-2',
partner2PersonId: null,
unionType: 'marriage',
},
})
expect(addUnionRes.statusCode).toBe(201)
// Step 6: Create filiation link
const addLinkRes = await app.inject({
method: 'POST',
url: '/trees/tree-1/links',
headers: { authorization: `Bearer ${token}` },
payload: {
parentPersonId: 'person-2',
childPersonId: 'person-1',
viaUnionId: 'union-1',
},
})
expect(addLinkRes.statusCode).toBe(201)
// Step 7: Verify graph returns data
prismaMock.person.findMany.mockResolvedValue([
{ id: 'person-1', treeId: 'tree-1', firstName: 'Pierre', lastName: 'Martin', birthDate: new Date('1990-03-15'), deathDate: null, sex: 'male', notes: null, createdBy: null, updatedBy: null, createdAt: new Date(), updatedAt: new Date(), deletedAt: null },
{ id: 'person-2', treeId: 'tree-1', firstName: 'Jean', lastName: 'Martin', birthDate: new Date('1960-07-20'), deathDate: null, sex: 'male', notes: null, createdBy: null, updatedBy: null, createdAt: new Date(), updatedAt: new Date(), deletedAt: null },
])
prismaMock.parentChildLink.findMany.mockResolvedValue([
{ id: 'link-1', treeId: 'tree-1', parentPersonId: 'person-2', childPersonId: 'person-1', viaUnionId: 'union-1' },
])
const graphRes = await app.inject({
method: 'GET',
url: '/trees/tree-1/graph',
headers: { authorization: `Bearer ${token}` },
})
expect(graphRes.statusCode).toBe(200)
const graph = graphRes.json()
expect(graph.graph.persons).toHaveLength(2)
expect(graph.graph.filiations).toHaveLength(1)
await app.close()
})
})
describe('Journey 2 — Visitor accesses tree via slug + password', () => {
it('full visitor access flow', async () => {
const app = createApp()
// Step 1: Resolve slug
prismaMock.tree.findFirst.mockResolvedValue({
id: 'tree-1',
slug: 'famille-martin',
name: 'Famille Martin',
description: null,
})
const slugRes = await app.inject({
method: 'GET',
url: '/arbre/famille-martin',
})
expect(slugRes.statusCode).toBe(200)
expect(slugRes.json().tree.slug).toBe('famille-martin')
// Step 2: Unlock with visitor password — we need bcrypt to work
// Since we mock the hash, we simulate the unlock by directly creating a tree-access token
await app.ready()
const treeAccessToken = (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(),
})
// Step 3: Fetch graph with tree-access token
prismaMock.tree.findFirst.mockResolvedValue({ id: 'tree-1', rootPersonId: 'person-1' })
prismaMock.person.findMany.mockResolvedValue([
{ id: 'person-1', treeId: 'tree-1', firstName: 'Pierre', lastName: 'Martin', birthDate: new Date('1990-03-15'), deathDate: null, sex: 'male', notes: null, createdBy: null, updatedBy: null, createdAt: new Date(), updatedAt: new Date(), deletedAt: null },
])
prismaMock.union.findMany.mockResolvedValue([])
prismaMock.parentChildLink.findMany.mockResolvedValue([])
prismaMock.mediaItem.findMany.mockResolvedValue([])
const graphRes = await app.inject({
method: 'GET',
url: '/trees/tree-1/graph',
headers: { authorization: `Bearer ${treeAccessToken}` },
})
expect(graphRes.statusCode).toBe(200)
expect(graphRes.json().graph.persons).toHaveLength(1)
expect(graphRes.json().rootPersonId).toBe('person-1')
// Step 4: Visitor cannot create persons directly
const createRes = await app.inject({
method: 'POST',
url: '/trees/tree-1/persons',
headers: { authorization: `Bearer ${treeAccessToken}` },
payload: { firstName: 'Hack', lastName: 'Attempt' },
})
expect(createRes.statusCode).toBe(401)
await app.close()
})
})
describe('Journey 3 — Contributor submits a contribution session', () => {
it('full contribution submission flow', async () => {
const app = createApp()
await app.ready()
// Contributor has a tree-access token with contributor role
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(),
})
// Setup: tree exists with pending contributor policy
prismaMock.tree.findFirst.mockResolvedValue({
id: 'tree-1',
deletedAt: null,
settings: { contributorPolicy: 'pending' },
})
// Step 1: Submit a contribution session with a person creation
const submitRes = await app.inject({
method: 'POST',
url: '/trees/tree-1/contributions/sessions',
headers: { authorization: `Bearer ${contributorToken}` },
payload: {
title: 'Contribution de Tatie Jeanette',
submittedByLabel: 'Jeanette Martin',
changes: [
{
entityType: 'person',
action: 'create',
after: {
firstName: 'Marie',
lastName: 'Martin',
birthDate: '1955-12-01',
sex: 'female',
},
},
{
entityType: 'person',
action: 'update',
entityId: 'person-1',
before: { firstName: 'Pierre' },
after: { notes: 'Surnomme Pierrot' },
},
],
},
})
expect(submitRes.statusCode).toBe(201)
const body = submitRes.json()
expect(body.session.status).toBe('pending')
expect(body.changesCount).toBe(2)
// The changes should NOT be applied (pending status)
expect(prismaMock.person.create).not.toHaveBeenCalled()
expect(prismaMock.person.updateMany).not.toHaveBeenCalled()
// Audit log should be created
expect(prismaMock.auditLog.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
action: 'contribution_session_submitted',
entityType: 'contribution_session',
}),
}),
)
await app.close()
})
it('auto-applies when admin submits', async () => {
const app = createApp()
await app.ready()
const ownerToken = (app as any).jwt.sign(
{ kind: 'user', sub: 'user-1', jti: randomUUID(), userId: 'user-1', email: 'owner@example.com' },
{ expiresIn: '1h' },
)
prismaMock.tree.findFirst.mockResolvedValue({ id: 'tree-1', deletedAt: null })
const submitRes = await app.inject({
method: 'POST',
url: '/trees/tree-1/contributions/sessions',
headers: { authorization: `Bearer ${ownerToken}` },
payload: {
title: 'Ajout rapide par admin',
changes: [
{
entityType: 'person',
action: 'create',
after: {
firstName: 'Claude',
lastName: 'Martin',
sex: 'male',
},
},
],
},
})
expect(submitRes.statusCode).toBe(201)
expect(submitRes.json().session.status).toBe('approved')
// Changes SHOULD be applied directly
expect(prismaMock.person.create).toHaveBeenCalled()
await app.close()
})
})
describe('Journey 4 — Admin reviews a contribution session', () => {
it('full admin moderation flow — approve all', 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' },
)
// Setup: there is a pending session with 2 changes
prismaMock.contributionSession.findFirst.mockResolvedValue({
id: 'session-1',
treeId: 'tree-1',
status: 'pending',
title: 'Contribution de Tatie',
changes: [
{
id: 'change-1',
entityType: 'person',
action: 'create',
entityId: null,
beforeJson: null,
afterJson: { firstName: 'Marie', lastName: 'Martin', birthDate: '1955-12-01', sex: 'female' },
conflictState: 'none',
},
{
id: 'change-2',
entityType: 'person',
action: 'update',
entityId: 'person-1',
beforeJson: { firstName: 'Pierre' },
afterJson: { notes: 'Surnomme Pierrot' },
conflictState: 'none',
},
],
})
// Mock: person-1 still has same firstName as beforeJson (no conflict)
prismaMock.person.findFirst.mockResolvedValue({
id: 'person-1',
treeId: 'tree-1',
firstName: 'Pierre',
lastName: 'Martin',
birthDate: null,
deathDate: null,
sex: 'male',
notes: null,
deletedAt: null,
})
const reviewRes = await app.inject({
method: 'POST',
url: '/trees/tree-1/contributions/sessions/session-1/review',
headers: { authorization: `Bearer ${adminToken}` },
payload: {
decision: 'approved',
},
})
expect(reviewRes.statusCode).toBe(200)
const body = reviewRes.json()
expect(body.approvedCount).toBe(2)
expect(body.rejectedCount).toBe(0)
expect(body.conflictCount).toBe(0)
// Both changes should be applied
expect(prismaMock.person.create).toHaveBeenCalled()
expect(prismaMock.person.updateMany).toHaveBeenCalled()
// Audit log for review
expect(prismaMock.auditLog.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
action: 'contribution_session_reviewed',
}),
}),
)
await app.close()
})
it('partial review — approve one, reject one', 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-2',
treeId: 'tree-1',
status: 'pending',
title: 'Mixed review',
changes: [
{
id: 'change-a',
entityType: 'person',
action: 'create',
entityId: null,
beforeJson: null,
afterJson: { firstName: 'Bon', lastName: 'Ajout' },
conflictState: 'none',
},
{
id: 'change-b',
entityType: 'person',
action: 'update',
entityId: 'person-1',
beforeJson: { firstName: 'Pierre' },
afterJson: { firstName: 'WRONG NAME' },
conflictState: 'none',
},
],
})
prismaMock.person.findFirst.mockResolvedValue({
id: 'person-1',
treeId: 'tree-1',
firstName: 'Pierre',
lastName: 'Martin',
birthDate: null,
deathDate: null,
sex: null,
notes: null,
deletedAt: null,
})
const reviewRes = await app.inject({
method: 'POST',
url: '/trees/tree-1/contributions/sessions/session-2/review',
headers: { authorization: `Bearer ${adminToken}` },
payload: {
decision: 'rejected',
changeDecisions: [
{ changeId: 'change-a', decision: 'approved' },
],
},
})
expect(reviewRes.statusCode).toBe(200)
const body = reviewRes.json()
expect(body.approvedCount).toBe(1)
expect(body.rejectedCount).toBe(1)
// Only person create should have been called, not updateMany
expect(prismaMock.person.create).toHaveBeenCalledTimes(1)
await app.close()
})
it('detects conflict when entity changed since submission', 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-3',
treeId: 'tree-1',
status: 'pending',
title: 'Conflict test',
changes: [
{
id: 'change-c',
entityType: 'person',
action: 'update',
entityId: 'person-1',
beforeJson: { firstName: 'Pierre' },
afterJson: { firstName: 'Pierrot' },
conflictState: 'none',
},
],
})
// The person's firstName has changed since submission (was 'Pierre', now 'Pierre-Louis')
prismaMock.person.findFirst.mockResolvedValue({
id: 'person-1',
treeId: 'tree-1',
firstName: 'Pierre-Louis',
lastName: 'Martin',
birthDate: null,
deathDate: null,
sex: 'male',
notes: null,
deletedAt: null,
})
const reviewRes = await app.inject({
method: 'POST',
url: '/trees/tree-1/contributions/sessions/session-3/review',
headers: { authorization: `Bearer ${adminToken}` },
payload: {
decision: 'approved',
},
})
expect(reviewRes.statusCode).toBe(200)
expect(reviewRes.json().conflictCount).toBe(1)
// conflictState should have been updated
expect(prismaMock.contributionChange.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'change-c' },
data: { conflictState: 'needs_review' },
}),
)
await app.close()
})
})
+142
View File
@@ -0,0 +1,142 @@
import { describe, expect, it } from 'vitest'
import { buildGraphPayload } from '../src/utils/graph-payload.js'
describe('buildGraphPayload', () => {
it('maps graph entities while preserving input order', () => {
const graph = buildGraphPayload(
't-1',
[
{
id: 'p-2',
treeId: 't-1',
firstName: 'Zoe',
lastName: 'Martin',
birthName: 'Durand',
birthDate: new Date('1994-06-02T00:00:00.000Z'),
deathDate: null,
sex: null,
notes: null,
avatarPath: 't-1/p-2/avatar.jpg',
createdBy: null,
updatedBy: null,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
},
{
id: 'p-1',
treeId: 't-1',
firstName: 'Alice',
lastName: 'Bernard',
birthName: null,
birthDate: null,
deathDate: null,
sex: 'female',
notes: 'note',
avatarPath: null,
createdBy: null,
updatedBy: null,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
},
] as any,
[
{
id: 'u-1',
treeId: 't-1',
partner1PersonId: 'p-1',
partner2PersonId: 'p-2',
unionType: 'mariage',
startDate: new Date('2012-01-01T00:00:00.000Z'),
endDate: null,
displayOrder: 2,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
},
] as any,
[
{
id: 'l-1',
treeId: 't-1',
parentPersonId: 'p-1',
childPersonId: 'p-2',
viaUnionId: 'u-1',
parentageType: 'biologique',
displayOrder: 1,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
},
] as any,
[
{
id: 'm-1',
treeId: 't-1',
personId: 'p-1',
type: 'document',
mimeType: 'application/pdf',
filePath: 't-1/p-1/m-1.pdf',
thumbPath: null,
caption: 'Acte notarié',
source: 'Archives départementales',
sizeBytes: 1234,
displayOrder: 1,
isFeatured: true,
uploadedBy: null,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
},
] as any,
)
expect(graph.persons[0].id).toBe('p-2')
expect(graph.persons[0].birthDate).toBe('1994-06-02')
expect(graph.persons[0].birthName).toBe('Durand')
expect(graph.persons[0].avatarUrlPath).toBe('/trees/t-1/persons/p-2/avatar')
expect(graph.persons[1].avatarUrlPath).toBeNull()
expect(graph.unions[0].id).toBe('u-1')
expect(graph.filiations[0].viaUnionId).toBe('u-1')
expect(graph.medias[0].source).toBe('Archives départementales')
})
it('preserves sibling display order as provided by the query layer', () => {
const graph = buildGraphPayload(
't-1',
[] as any,
[] as any,
[
{
id: 'l-2',
treeId: 't-1',
parentPersonId: 'p-parent',
childPersonId: 'p-child-2',
viaUnionId: null,
parentageType: 'biologique',
displayOrder: 2,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
},
{
id: 'l-1',
treeId: 't-1',
parentPersonId: 'p-parent',
childPersonId: 'p-child-1',
viaUnionId: null,
parentageType: 'biologique',
displayOrder: 1,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
},
] as any,
)
expect(graph.filiations[0].id).toBe('l-2')
expect(graph.filiations[1].id).toBe('l-1')
})
})
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "dist",
"rootDir": "src",
"types": ["node"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts"],
"exclude": ["dist", "node_modules"]
}
+29
View File
@@ -0,0 +1,29 @@
# Déploiement de parenthese.io
Ce document concerne l'instance hébergée par Ilies Allali. Pour installer Parenthèse chez vous, suivez plutôt la section « Auto-héberger » du README (Docker).
## Principe
Chaque push sur `main` lance `.github/workflows/deploy-hostinger.yml`. Le workflow se connecte au serveur en SSH, aligne le dépôt sur `origin/main` sous verrou, puis lance `deploy/deploy.sh`, qui ne réinstalle et ne reconstruit que ce qui a changé depuis le dernier déploiement réussi.
## Secrets du dépôt
- `HOSTINGER_HOST` : adresse du serveur
- `HOSTINGER_PORT` : port SSH (facultatif, 22 par défaut)
- `HOSTINGER_USER` : utilisateur SSH
- `HOSTINGER_SSH_PRIVATE_KEY` : clé privée dont la clé publique est dans `authorized_keys` sur le serveur
## Ce qui vit sur le serveur, hors du dépôt
- `backend/.env` : configuration de production de l'API (voir `backend/.env.production.example`)
- `/etc/parenthese/build.env` : variables de build du front et de la landing (`VITE_POSTHOG_KEY`, `VITE_POSTHOG_HOST`)
- `/etc/cron.d/parenthese-backup` : sauvegarde nocturne par `deploy/backup.sh`
- `/etc/genealogy/backup.env` (facultatif) : rétention et envoi S3 des sauvegardes
## Prérequis du serveur
Node 20, `pm2` en global, PostgreSQL joignable par `DATABASE_URL`, nginx configuré avec `deploy/nginx.conf`, `pg_dump` et `flock`.
## Déployer à la main
`bash deploy/deploy.sh --sync` depuis le dépôt sur le serveur. `FORCE_FULL=1` pour tout reconstruire. Ne pas le lancer pendant un déploiement GitHub : le verrou le fera attendre.
+136
View File
@@ -0,0 +1,136 @@
#!/bin/bash
# Parenthèse : sauvegarde de la base et des médias.
#
# Lancé chaque nuit par cron sur le VPS (/etc/cron.d/parenthese-backup), sans dépendre d'une connexion SSH depuis GitHub.
# La base est sauvegardée à chaque passage. L'archive des médias n'est refaite que si les médias ont
# changé (sinon une archive identique par jour), et seules les
# BACKUP_MEDIA_KEEP dernières archives sont gardées.
set -euo pipefail
REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
BACKEND_ENV_FILE="$REPO_DIR/backend/.env"
BACKUP_ENV_FILE="${BACKUP_ENV_FILE:-/etc/genealogy/backup.env}"
if [ ! -f "$BACKEND_ENV_FILE" ]; then
echo "ERROR: Missing $BACKEND_ENV_FILE"
exit 1
fi
set -a
# shellcheck disable=SC1090
source "$BACKEND_ENV_FILE"
if [ -f "$BACKUP_ENV_FILE" ]; then
# shellcheck disable=SC1090
source "$BACKUP_ENV_FILE"
fi
set +a
if [ -z "${DATABASE_URL:-}" ]; then
echo "ERROR: DATABASE_URL is required for backups."
exit 1
fi
if ! command -v pg_dump >/dev/null 2>&1; then
echo "ERROR: pg_dump is not installed."
exit 1
fi
BACKUP_ROOT="${BACKUP_ROOT:-/var/backups/genealogy}"
BACKUP_RETENTION_DAYS="${BACKUP_RETENTION_DAYS:-14}"
BACKUP_MEDIA_KEEP="${BACKUP_MEDIA_KEEP:-2}"
TIMESTAMP="$(date -u +%Y%m%dT%H%M%SZ)"
DB_DIR="$BACKUP_ROOT/db"
MEDIA_DIR="$BACKUP_ROOT/media"
META_DIR="$BACKUP_ROOT/meta"
MANIFEST_FILE="$META_DIR/media-manifest.sha256"
mkdir -p "$DB_DIR" "$MEDIA_DIR" "$META_DIR"
DB_BACKUP_FILE="$DB_DIR/genealogy-db-$TIMESTAMP.dump"
CHECKSUM_FILE="$META_DIR/genealogy-$TIMESTAMP.sha256"
echo "[$(date -u +%FT%TZ)] Sauvegarde Parenthèse"
echo "[1/5] Backup PostgreSQL..."
pg_dump "$DATABASE_URL" --format=custom --file "$DB_BACKUP_FILE"
echo "[2/5] Backup media files..."
MEDIA_STORAGE_PATH_VALUE="${MEDIA_STORAGE_PATH:-./storage/media}"
if [[ "$MEDIA_STORAGE_PATH_VALUE" != /* ]]; then
MEDIA_STORAGE_PATH_VALUE="$REPO_DIR/backend/$MEDIA_STORAGE_PATH_VALUE"
fi
# Empreinte des médias : chemin, taille et date de chaque fichier. Même empreinte = rien de nouveau.
if [ -d "$MEDIA_STORAGE_PATH_VALUE" ]; then
CURRENT_MANIFEST="$(cd "$MEDIA_STORAGE_PATH_VALUE" && find . -type f -printf '%P\t%s\t%T@\n' | LC_ALL=C sort | sha256sum | cut -d' ' -f1)"
else
CURRENT_MANIFEST="empty"
fi
LAST_MANIFEST="$(cat "$MANIFEST_FILE" 2>/dev/null || true)"
LATEST_MEDIA="$(ls -1t "$MEDIA_DIR"/*.tar.gz 2>/dev/null | head -1 || true)"
MEDIA_IS_NEW=0
if [ -n "$LATEST_MEDIA" ] && [ "$CURRENT_MANIFEST" = "$LAST_MANIFEST" ] && [ "${FORCE_MEDIA_BACKUP:-0}" != "1" ]; then
echo "Médias inchangés depuis $(basename "$LATEST_MEDIA"), pas de nouvelle archive."
MEDIA_BACKUP_FILE="$LATEST_MEDIA"
else
MEDIA_BACKUP_FILE="$MEDIA_DIR/genealogy-media-$TIMESTAMP.tar.gz"
if [ -d "$MEDIA_STORAGE_PATH_VALUE" ]; then
tar -czf "$MEDIA_BACKUP_FILE" -C "$MEDIA_STORAGE_PATH_VALUE" .
else
echo "WARNING: Media path '$MEDIA_STORAGE_PATH_VALUE' does not exist, creating empty archive."
tar -czf "$MEDIA_BACKUP_FILE" --files-from /dev/null
fi
echo "$CURRENT_MANIFEST" > "$MANIFEST_FILE"
MEDIA_IS_NEW=1
fi
echo "[3/5] Generate checksums..."
(
cd "$BACKUP_ROOT"
sha256sum "db/$(basename "$DB_BACKUP_FILE")"
if [ "$MEDIA_IS_NEW" = "1" ]; then
sha256sum "media/$(basename "$MEDIA_BACKUP_FILE")"
else
# empreinte déjà calculée le jour où l'archive a été faite
grep -h " media/$(basename "$MEDIA_BACKUP_FILE")" "$META_DIR"/genealogy-*.sha256 2>/dev/null | head -1 || true
fi
) > "$CHECKSUM_FILE"
echo "[4/5] Optional offsite upload..."
if [ -n "${BACKUP_S3_BUCKET:-}" ]; then
if ! command -v aws >/dev/null 2>&1; then
echo "ERROR: BACKUP_S3_BUCKET is set but aws CLI is not installed."
exit 1
fi
BACKUP_S3_PREFIX="${BACKUP_S3_PREFIX:-genealogy}"
S3_BASE_URI="s3://$BACKUP_S3_BUCKET/$BACKUP_S3_PREFIX"
S3_ARGS=()
if [ -n "${BACKUP_S3_ENDPOINT:-}" ]; then
S3_ARGS+=(--endpoint-url "$BACKUP_S3_ENDPOINT")
fi
if [ -n "${BACKUP_S3_REGION:-}" ]; then
S3_ARGS+=(--region "$BACKUP_S3_REGION")
fi
aws s3 cp "$DB_BACKUP_FILE" "$S3_BASE_URI/db/$(basename "$DB_BACKUP_FILE")" "${S3_ARGS[@]}"
if [ "$MEDIA_IS_NEW" = "1" ]; then
aws s3 cp "$MEDIA_BACKUP_FILE" "$S3_BASE_URI/media/$(basename "$MEDIA_BACKUP_FILE")" "${S3_ARGS[@]}"
fi
aws s3 cp "$CHECKSUM_FILE" "$S3_BASE_URI/meta/$(basename "$CHECKSUM_FILE")" "${S3_ARGS[@]}"
else
echo "No BACKUP_S3_BUCKET configured, skipping offsite upload."
fi
echo "[5/5] Local retention cleanup..."
find "$DB_DIR" -type f -name '*.dump' -mtime +"$BACKUP_RETENTION_DAYS" -delete
find "$META_DIR" -type f -name 'genealogy-*.sha256' -mtime +"$BACKUP_RETENTION_DAYS" -delete
# Médias : par nombre d'archives et non par âge, l'archive en cours peut dater de plusieurs semaines
ls -1t "$MEDIA_DIR"/*.tar.gz 2>/dev/null | tail -n +"$((BACKUP_MEDIA_KEEP + 1))" | xargs -r rm -f
echo "Backup complete:"
echo " DB: $DB_BACKUP_FILE"
echo " Media: $MEDIA_BACKUP_FILE$([ "$MEDIA_IS_NEW" = "1" ] || echo " (inchangé)")"
echo " Checksums: $CHECKSUM_FILE"
+167
View File
@@ -0,0 +1,167 @@
#!/bin/bash
# Parenthèse : déploiement sur le VPS.
#
# Lancé par GitHub Actions à chaque push sur main (.github/workflows/deploy-hostinger.yml),
# ou à la main : bash deploy/deploy.sh --sync (aligne le dépôt sur origin/main puis déploie)
#
# Incrémental : les dépendances ne sont réinstallées que si un package-lock a changé, et seules les
# parties modifiées depuis le dernier déploiement réussi sont reconstruites. FORCE_FULL=1 pour tout refaire.
# Verrouillé : un seul déploiement à la fois, pour ne jamais lancer deux npm ci dans le même dossier.
set -euo pipefail
SCRIPT_PATH="$(readlink -f "$0")"
REPO_DIR="$(cd "$(dirname "$SCRIPT_PATH")/.." && pwd)"
LOCK_FILE="${PARENTHESE_DEPLOY_LOCK:-/tmp/parenthese-deploy.lock}"
STATE_DIR="${PARENTHESE_DEPLOY_STATE_DIR:-/var/lib/parenthese}"
BUILD_ENV_FILE="${PARENTHESE_BUILD_ENV:-/etc/parenthese/build.env}"
log() { echo "[deploy] $*"; }
# 1. Verrou
if [ -z "${PARENTHESE_DEPLOY_LOCKED:-}" ]; then
exec flock -w 900 "$LOCK_FILE" env PARENTHESE_DEPLOY_LOCKED=1 bash "$SCRIPT_PATH" "$@"
fi
# 2. --sync : aligne le dépôt sur origin/main, puis relance la version à jour de ce script
if [ "${1:-}" = "--sync" ]; then
git -C "$REPO_DIR" fetch -q origin main
git -C "$REPO_DIR" reset -q --hard origin/main
exec bash "$SCRIPT_PATH"
fi
if [ ! -f "$REPO_DIR/backend/.env" ]; then
echo "ERROR: Missing $REPO_DIR/backend/.env"
echo "Aborting deploy to avoid starting production with fallback development values."
exit 1
fi
# Variables de build du front et de la landing (clé PostHog), gardées sur le serveur, hors du dépôt
if [ -f "$BUILD_ENV_FILE" ]; then
set -a
# shellcheck disable=SC1090
. "$BUILD_ENV_FILE"
set +a
else
log "WARNING: $BUILD_ENV_FILE absent, le front sera construit sans analytics"
fi
SUDO=""
if [ "$(id -u)" != "0" ]; then SUDO="sudo"; fi
# 3. Ce qui a changé depuis le dernier déploiement réussi
mkdir -p "$STATE_DIR"
NEW_SHA="$(git -C "$REPO_DIR" rev-parse HEAD)"
LAST_SHA="$(cat "$STATE_DIR/last-deployed-sha" 2>/dev/null || true)"
FULL=0
if [ "${FORCE_FULL:-0}" = "1" ] || [ -z "$LAST_SHA" ] || ! git -C "$REPO_DIR" cat-file -e "${LAST_SHA}^{commit}" 2>/dev/null; then
FULL=1
fi
changed() {
[ "$FULL" = "1" ] && return 0
! git -C "$REPO_DIR" diff --quiet "$LAST_SHA" "$NEW_SHA" -- "$@"
}
if [ "$FULL" = "0" ] && [ "$LAST_SHA" = "$NEW_SHA" ]; then
log "Rien à déployer : $(git -C "$REPO_DIR" log -1 --format='%h') est déjà en ligne."
exit 0
fi
if [ "$FULL" = "1" ]; then
log "Déploiement complet de $(git -C "$REPO_DIR" log -1 --format='%h %s')"
else
log "Déploiement de $(git -C "$REPO_DIR" log -1 --format='%h %s') (changements depuis ${LAST_SHA:0:7})"
fi
install_deps() {
local dir="$1"
if [ "$FULL" = "1" ] || [ ! -d "$REPO_DIR/$dir/node_modules" ] || changed "$dir/package-lock.json" "$dir/package.json"; then
log "npm ci ($dir)"
if ! (cd "$REPO_DIR/$dir" && npm ci --no-audit --no-fund); then
log "npm ci a échoué ($dir), cache npm purgé, nouvel essai"
npm cache clean --force
(cd "$REPO_DIR/$dir" && npm ci --no-audit --no-fund)
fi
else
log "Dépendances $dir inchangées"
fi
}
fix_perms() {
find "$REPO_DIR/$1" -type d -exec chmod 755 {} +
find "$REPO_DIR/$1" -type f -exec chmod 644 {} +
}
# 4. Backend
BACKEND_CHANGED=0
if changed backend ecosystem.config.cjs; then
BACKEND_CHANGED=1
install_deps backend
(cd "$REPO_DIR/backend" && node -e "require('fastify'); require('zod'); require('@prisma/client')") \
|| { echo "ERROR: dépendances backend manquantes après installation"; exit 1; }
log "Build backend"
(cd "$REPO_DIR/backend" && NODE_ENV='' npm run prisma:generate && NODE_ENV='' npm run build)
log "Migrations"
(cd "$REPO_DIR/backend" && npm run prisma:deploy)
else
log "Backend inchangé"
fi
# 5. Application
if changed frontend; then
install_deps frontend
log "Build frontend"
(cd "$REPO_DIR/frontend" && npm run build)
fix_perms frontend/dist
else
log "Frontend inchangé"
fi
# 6. Landing (elle utilise les polices du frontend)
if changed landing frontend/src/assets; then
install_deps landing
log "Build landing"
(cd "$REPO_DIR/landing" && npm run build)
fix_perms landing/dist
else
log "Landing inchangée"
fi
mkdir -p "$REPO_DIR/backend/storage/media" "$REPO_DIR/logs"
# 7. nginx, seulement si sa configuration a changé
if changed deploy/nginx.conf; then
log "Mise à jour nginx"
$SUDO install -m 644 "$REPO_DIR/deploy/nginx.conf" /etc/nginx/sites-available/parenthese
$SUDO ln -sf /etc/nginx/sites-available/parenthese /etc/nginx/sites-enabled/parenthese
$SUDO nginx -t
$SUDO systemctl reload nginx
fi
# 8. API
if [ "$BACKEND_CHANGED" = "1" ]; then
command -v pm2 >/dev/null 2>&1 || { echo "ERROR: pm2 is not installed on this server."; exit 1; }
cd "$REPO_DIR"
if pm2 describe parenthese-api > /dev/null 2>&1; then
pm2 reload ecosystem.config.cjs --env production
else
pm2 start ecosystem.config.cjs --env production
fi
pm2 save
fi
# 9. Vérification
code="000"
for _ in $(seq 1 15); do
code="$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:4000/health || true)"
[ "$code" = "200" ] && break
sleep 2
done
if [ "$code" != "200" ]; then
echo "ERROR: API health check returned $code"
pm2 logs parenthese-api --lines 30 --nostream || true
exit 1
fi
echo "$NEW_SHA" > "$STATE_DIR/last-deployed-sha"
log "Terminé, API OK"
+154
View File
@@ -0,0 +1,154 @@
# ─── app.parenthese.io ────────────────────────────────────────────────────────
server {
listen 80;
server_name app.parenthese.io;
return 301 https://app.parenthese.io$request_uri;
}
server {
listen 443 ssl http2;
server_name app.parenthese.io;
ssl_certificate /etc/letsencrypt/live/app.parenthese.io/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.parenthese.io/privkey.pem;
# Security headers
# frame-ancestors allows the landing page (parenthese.io) to embed the app in an iframe
add_header Content-Security-Policy "frame-ancestors 'self' https://parenthese.io https://www.parenthese.io" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
root /var/www/parenthese/frontend/dist;
index index.html;
location /api/ {
rewrite ^/api(/.*)$ $1 break;
proxy_pass http://127.0.0.1:4000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 25m;
proxy_connect_timeout 10s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
}
location /auth/ {
proxy_pass http://127.0.0.1:4000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /trees {
proxy_pass http://127.0.0.1:4000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 25m;
}
location /health {
proxy_pass http://127.0.0.1:4000;
proxy_http_version 1.1;
}
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header Pragma "no-cache" always;
add_header Content-Security-Policy "frame-ancestors 'self' https://parenthese.io https://www.parenthese.io" always;
# Keep embedding control in CSP; override any inherited SAMEORIGIN frame header.
add_header X-Frame-Options "ALLOWALL" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 30d;
add_header Cache-Control "public, immutable";
add_header Content-Security-Policy "frame-ancestors 'self' https://parenthese.io https://www.parenthese.io" always;
add_header X-Frame-Options "ALLOWALL" always;
add_header X-Content-Type-Options "nosniff" always;
}
}
# ─── parenthese.io (landing) ─────────────────────────────────────────────────
server {
listen 80;
server_name parenthese.io www.parenthese.io;
return 301 https://parenthese.io$request_uri;
}
server {
listen 443 ssl http2;
server_name www.parenthese.io;
ssl_certificate /etc/letsencrypt/live/parenthese.io/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/parenthese.io/privkey.pem;
return 301 https://parenthese.io$request_uri;
}
server {
listen 443 ssl http2;
server_name parenthese.io;
ssl_certificate /etc/letsencrypt/live/parenthese.io/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/parenthese.io/privkey.pem;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-Frame-Options "DENY" always;
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
root /var/www/parenthese/landing/dist;
index index.html;
# Waitlist API — proxy vers le backend Fastify existant
location = /api/waitlist {
proxy_pass http://127.0.0.1:4000/waitlist;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 10s;
proxy_send_timeout 10s;
proxy_read_timeout 10s;
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header Pragma "no-cache" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
}
# Fingerprinted static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
}
+74
View File
@@ -0,0 +1,74 @@
# nginx du service `web` (docker compose). Dérivé de deploy/nginx.conf (prod app.parenthese.io) :
# même origine, /api/* réécrit en /* vers l'API, /auth, /trees et /health relayés tels quels,
# fallback SPA. Pas de TLS ici : placez un reverse proxy HTTPS devant si vous exposez sur Internet.
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
location /api/ {
rewrite ^/api(/.*)$ $1 break;
proxy_pass http://api:4000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 25m;
proxy_connect_timeout 10s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
}
location /auth/ {
proxy_pass http://api:4000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /trees {
proxy_pass http://api:4000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 25m;
}
location /health {
proxy_pass http://api:4000;
proxy_http_version 1.1;
}
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header Pragma "no-cache" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 30d;
add_header Cache-Control "public, immutable";
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
}
}
+62
View File
@@ -0,0 +1,62 @@
# Parenthèse auto-hébergé : base PostgreSQL, API, front servi par nginx.
# Configuration dans le fichier .env à la racine (copiez .env.example).
# docker compose up -d démarrer
# docker compose logs -f api suivre l'API
# docker compose down arrêter (les volumes db-data et media sont conservés)
name: parenthese
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-parenthese}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Renseignez POSTGRES_PASSWORD dans .env}
POSTGRES_DB: ${POSTGRES_DB:-parenthese}
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-parenthese} -d ${POSTGRES_DB:-parenthese}"]
interval: 5s
timeout: 5s
retries: 12
api:
build: ./backend
restart: unless-stopped
depends_on:
db:
condition: service_healthy
# Tout le .env passe à l'API (JWT_SECRET, CORS_ORIGIN, RESEND_* ...).
env_file: .env
# Ces valeurs sont imposées par le déploiement Docker et priment sur le .env.
environment:
NODE_ENV: production
PORT: "4000"
DATABASE_URL: postgresql://${POSTGRES_USER:-parenthese}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-parenthese}
MEDIA_STORAGE_PATH: /app/storage/media
volumes:
- media:/app/storage/media
# Migrations Prisma en attente, puis serveur.
command: ["sh", "-c", "./node_modules/.bin/prisma migrate deploy && exec node dist/server.js"]
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:4000/health').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
interval: 15s
timeout: 5s
retries: 5
start_period: 30s
web:
build:
context: .
dockerfile: Dockerfile.web
restart: unless-stopped
depends_on:
- api
ports:
- "${WEB_PORT:-80}:80"
volumes:
db-data:
media:
+23
View File
@@ -0,0 +1,23 @@
module.exports = {
apps: [
{
name: 'parenthese-api',
cwd: './backend',
script: 'dist/server.js',
node_args: '--enable-source-maps',
instances: 1,
exec_mode: 'fork',
env_production: {
NODE_ENV: 'production',
PORT: 4000,
},
max_memory_restart: '512M',
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
error_file: './logs/api-error.log',
out_file: './logs/api-out.log',
merge_logs: true,
restart_delay: 3000,
max_restarts: 10,
},
],
}
+5
View File
@@ -0,0 +1,5 @@
VITE_API_BASE_URL=http://localhost:4000
# Optional fallback when not using /arbre/:slug URL
VITE_TREE_ID=
VITE_POSTHOG_KEY=
VITE_POSTHOG_HOST=https://us.i.posthog.com
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+16
View File
@@ -0,0 +1,16 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
+36
View File
@@ -0,0 +1,36 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
linterOptions: {
reportUnusedDisableDirectives: 'off',
},
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]', argsIgnorePattern: '^_' }],
'react-hooks/set-state-in-effect': 'off',
'react-hooks/immutability': 'off',
'react-hooks/preserve-manual-memoization': 'off',
'react-hooks/exhaustive-deps': 'off',
},
},
])
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#FDF8EC" />
<title>Parenthèse</title>
<meta name="description" content="Parenthèse, l'application. Les arbres partagés sont privés : la vitrine publique est sur parenthese.io." />
<!-- Arbres familiaux privés (noms, dates) : l'app ne doit pas être indexée, la vitrine est parenthese.io -->
<meta name="robots" content="noindex, nofollow" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Red+Hat+Mono:ital,wght@0,300..700;1,300..700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+4334
View File
File diff suppressed because it is too large. Load diff
+35
View File
@@ -0,0 +1,35 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"lucide-react": "^0.564.0",
"posthog-js": "^1.353.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.24",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.19",
"vite": "^7.3.1",
"vitest": "^4.0.18"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Disallow: /
+2903
View File
File diff suppressed because it is too large. Load diff
+2552
View File
File diff suppressed because it is too large. Load diff
+53
View File
@@ -0,0 +1,53 @@
import { apiRequest } from './client'
export async function loginUser(email, password) {
const data = await apiRequest('/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
})
return data
}
export async function registerUser(firstName, email, password) {
const data = await apiRequest('/auth/register', {
method: 'POST',
body: JSON.stringify({ firstName, email, password }),
})
return data
}
export async function logoutUser(token) {
const data = await apiRequest('/auth/logout', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
})
return data
}
export async function fetchLastOpenedTree(token) {
const data = await apiRequest('/auth/me/last-tree', {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
})
return data?.lastTree || null
}
export async function updateLastOpenedTree(token, payload) {
const data = await apiRequest('/auth/me/last-tree', {
method: 'PUT',
headers: {
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(payload),
})
return data?.lastTree || null
}
+50
View File
@@ -0,0 +1,50 @@
const rawApiBaseUrl = import.meta.env.VITE_API_BASE_URL
const defaultApiBaseUrl = import.meta.env.DEV ? 'http://localhost:4000' : ''
const API_BASE_URL = (rawApiBaseUrl ?? defaultApiBaseUrl).replace(/\/$/, '')
class ApiError extends Error {
constructor(message, status, payload = null) {
super(message)
this.name = 'ApiError'
this.status = status
this.payload = payload
}
}
async function safeParseJson(text) {
if (!text) {
return null
}
try {
return JSON.parse(text)
} catch {
return null
}
}
async function apiRequest(path, options = {}) {
const providedHeaders = options.headers || {}
const hasExplicitContentType = Object.keys(providedHeaders).some((key) => key.toLowerCase() === 'content-type')
const headers = {
...(options.body !== undefined && !hasExplicitContentType ? { 'Content-Type': 'application/json' } : {}),
...providedHeaders,
}
const response = await fetch(`${API_BASE_URL}${path}`, {
...options,
headers,
})
const text = await response.text()
const payload = await safeParseJson(text)
if (!response.ok) {
throw new ApiError(payload?.error || `request_failed_${response.status}`, response.status, payload)
}
return payload
}
export { ApiError, API_BASE_URL, apiRequest }
+421
View File
@@ -0,0 +1,421 @@
import { apiRequest } from './client'
export async function resolveTreeBySlug(slug) {
const data = await apiRequest(`/api/arbre/${encodeURIComponent(slug)}`)
return {
id: data?.tree?.id || '',
slug: data?.tree?.slug || '',
name: data?.tree?.name || '',
description: data?.tree?.description || null,
ownerName: data?.tree?.ownerName || null,
ownerEmail: data?.tree?.ownerEmail || null,
}
}
export async function unlockTreeAccess(treeId, password, userToken = '') {
const headers = {}
if (userToken) {
headers.Authorization = `Bearer ${userToken}`
}
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/access/unlock`, {
method: 'POST',
headers,
body: JSON.stringify({ password }),
})
return data
}
// Rattache à un compte un accès partagé déjà ouvert (jeton d'arbre en poche), sans redemander le mot de passe
export async function linkTreeAccess(treeId, accessToken, userToken) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/access/link`, {
method: 'POST',
headers: {
Authorization: `Bearer ${userToken}`,
},
body: JSON.stringify({ accessToken }),
})
return data
}
export async function fetchTreeGraph(treeId, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/graph?t=${Date.now()}`, {
method: 'GET',
cache: 'no-store',
headers: {
Authorization: `Bearer ${token}`,
},
})
return {
treeId: data?.treeId || treeId,
rootPersonId: data?.rootPersonId || null,
graph: data?.graph || { persons: [], unions: [], filiations: [], medias: [] },
}
}
export async function listTrees(token) {
const data = await apiRequest('/trees', {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
})
return {
trees: Array.isArray(data?.trees) ? data.trees : [],
lastOpenedTree: data?.lastOpenedTree || null,
}
}
export async function getTree(treeId, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}`, {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
})
return {
tree: data?.tree || null,
role: data?.role || '',
}
}
export async function createTree(payload, token) {
const data = await apiRequest('/trees', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(payload),
})
return data.tree
}
export async function updateTree(treeId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(payload),
})
return data.tree
}
export async function deleteTree(treeId, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
},
})
return data
}
export async function createPerson(treeId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/persons`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(payload),
})
return data.person
}
export async function updatePerson(treeId, personId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(payload),
})
return data.person
}
export async function deletePerson(treeId, personId, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
},
})
return data
}
export async function createUnion(treeId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/unions`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(payload),
})
return data.union
}
export async function createParentChildLink(treeId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/links`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(payload),
})
return data.link
}
export async function deleteUnion(treeId, unionId, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/unions/${encodeURIComponent(unionId)}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
},
})
return data
}
export async function deleteParentChildLink(treeId, linkId, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/links/${encodeURIComponent(linkId)}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
},
})
return data
}
export async function rotateTreePasswords(treeId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/access/passwords`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(payload),
})
return data
}
export async function fetchTreeStats(treeId, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/stats`, {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
})
return data?.stats || null
}
export async function fetchTreeSettings(treeId, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/settings`, {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
})
return data?.settings || null
}
export async function updateTreeSettings(treeId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/settings`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(payload),
})
return data?.settings || null
}
export async function listTreeAuditLogs(treeId, token, options = {}) {
const queryParams = new URLSearchParams()
if (options.limit) {
queryParams.set('limit', String(options.limit))
}
if (options.offset !== undefined && options.offset !== null) {
queryParams.set('offset', String(options.offset))
}
if (options.action) {
queryParams.set('action', options.action)
}
const query = queryParams.toString()
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/audit${query ? `?${query}` : ''}`, {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
})
return {
logs: Array.isArray(data?.logs) ? data.logs : [],
nextOffset: typeof data?.nextOffset === 'number' ? data.nextOffset : null,
}
}
export async function submitContributionSession(treeId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/contributions/sessions`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(payload),
})
return data
}
export async function listContributionSessions(treeId, token, status = 'pending') {
const query = status ? `?status=${encodeURIComponent(status)}` : ''
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/contributions/sessions${query}`, {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
})
return Array.isArray(data?.sessions) ? data.sessions : []
}
export async function reviewContributionSession(treeId, sessionId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/contributions/sessions/${encodeURIComponent(sessionId)}/review`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(payload),
})
return data
}
export async function uploadAnnotationPhoto(treeId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/annotation-photos`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(payload),
})
return data // { photoId, photoPath }
}
export async function uploadPersonMedia(treeId, personId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}/media`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(payload),
})
return data.media
}
export async function reorderPersonMedia(treeId, personId, orderedMediaIds, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}/media/reorder`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ orderedMediaIds }),
})
return data
}
export async function deletePersonMedia(treeId, personId, mediaId, token) {
const data = await apiRequest(
`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}/media/${encodeURIComponent(mediaId)}`,
{
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
},
},
)
return data
}
export async function uploadPersonAvatar(treeId, personId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}/avatar`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(payload),
})
return data
}
export async function deletePersonAvatar(treeId, personId, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}/avatar`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
},
})
return data
}
export async function batchAnnotations(treeId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/annotations/batch`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(payload),
})
return data
}
export async function exportGedcom(treeId, token) {
const { API_BASE_URL } = await import('./client')
const resp = await fetch(`${API_BASE_URL}/trees/${encodeURIComponent(treeId)}/export/gedcom`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!resp.ok) {
throw new Error('export_failed')
}
const blob = await resp.blob()
const disposition = resp.headers.get('Content-Disposition') ?? ''
const filenameMatch = disposition.match(/filename="([^"]+)"/)
const filename = filenameMatch ? filenameMatch[1] : 'arbre.ged'
return { blob, filename }
}
export async function importGedcom(treeId, fileContent, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/import/gedcom`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: JSON.stringify({ content: fileContent }),
})
return data
}
+4
View File
@@ -0,0 +1,4 @@
<svg width="400" height="400" viewBox="0 0 400 400" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="400" height="400" rx="59" fill="#F7F1E6"/>
<path d="M174.887 159.122C169.851 167.606 163.424 170.911 155.806 170.911C151.516 170.911 146.062 170.294 139.361 168.956L139.214 168.926L139.066 168.904L136.494 168.511C130.481 167.577 124.376 166.512 118.179 165.315C111.028 163.933 103.992 162.551 97.0728 161.169L96.9257 161.139L96.7773 161.117C89.662 160.051 83.4359 159.49 78.1617 159.49C69.1859 159.49 61.0961 161.278 54.0787 164.947C47.0981 168.596 41.4507 173.962 37.0058 180.803L36.9938 180.822L36.9817 180.84C28.1973 194.726 24 213.38 24 236.509V272H51.2117V237.548C51.2117 222.972 53.9416 212.085 58.96 204.553C61.3391 201.15 64.3726 198.736 68.0205 197.145C71.7017 195.54 76.2888 194.653 81.9743 194.653C86.8306 194.653 92.1966 195.123 98.0908 196.096C104.072 197.473 110.278 198.85 116.708 200.225L116.728 200.23L116.75 200.234C123.508 201.63 130.268 202.852 137.031 203.902C144.293 205.343 151.478 206.074 158.579 206.074C164.902 206.074 170.875 204.335 176.414 201.175C181.975 198.003 186.682 193.552 190.634 188.003C194.375 182.751 197.378 176.456 199.706 169.182C202.04 176.425 205.273 182.755 209.461 188.131C218.173 199.364 228.964 206.074 242.115 206.074C248.993 206.074 255.953 205.342 262.986 203.9C269.977 202.849 276.746 201.626 283.292 200.225C289.729 198.848 295.941 197.47 301.929 196.091C307.578 195.123 312.823 194.653 317.68 194.653C323.365 194.653 327.952 195.54 331.633 197.145C335.285 198.738 338.321 201.156 340.701 204.565C345.714 212.097 348.441 222.98 348.441 237.548V272H376V236.509C376 214.043 371.791 195.592 363.04 181.394L363.018 181.359L362.599 180.706C358.225 174.002 352.689 168.723 345.867 165.094C338.785 161.328 330.596 159.49 321.492 159.49C316.218 159.49 309.992 160.051 302.877 161.117L302.708 161.142L302.541 161.177C295.867 162.555 288.96 163.935 281.821 165.315C274.739 166.683 267.776 167.88 260.934 168.904L260.786 168.926L260.639 168.956C253.938 170.294 248.485 170.911 244.194 170.911C236.556 170.911 230.009 167.586 224.748 159.09C219.607 150.411 216.658 138.373 216.205 122.739L216.096 119H183.904L183.796 122.739C183.341 138.378 180.276 150.405 174.92 159.068L174.903 159.095L174.887 159.122Z" fill="#9A734C"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

+116
View File
@@ -0,0 +1,116 @@
import { useEffect, useState } from 'react'
const AccessGate = ({
initialTreeId,
treeName,
treeDescription = '',
treeOwnerName = '',
treeNotFound = false,
loading,
errorMessage,
onSubmit,
onUseAccount,
}) => {
const [treeId, setTreeId] = useState(initialTreeId || '')
const [password, setPassword] = useState('')
useEffect(() => {
setTreeId(initialTreeId || '')
}, [initialTreeId])
const handleSubmit = async (event) => {
event.preventDefault()
if (!treeId || !password || loading) {
return
}
await onSubmit(treeId.trim(), password)
}
// Jamais d'adresse email avant le mot de passe : seul un nom explicite peut s'afficher
const ownerLabel = treeOwnerName || ''
return (
<div className="access-gate">
<div className="access-gate-mock-blur" aria-hidden="true">
<div className="access-gate-mock-card access-gate-mock-card-main" />
<div className="access-gate-mock-card access-gate-mock-card-side" />
<div className="access-gate-mock-dot" />
</div>
<form className="access-gate-card" onSubmit={handleSubmit}>
<h1>Accès à votre arbre</h1>
{treeNotFound ? (
<p>Cet arbre n'existe pas ou n'est plus disponible.</p>
) : (
<p>
{treeName
? `Entrez le mot de passe partagé pour ouvrir "${treeName}".`
: "Entrez les informations reçues avec votre lien de partage pour ouvrir l'arbre familial."}
</p>
)}
{!treeNotFound && (treeName || treeDescription || ownerLabel) && (
<div className="access-gate-tree-info">
{treeName && <div className="access-gate-tree-name">{treeName}</div>}
{ownerLabel && <div className="access-gate-tree-meta">Partagé par : {ownerLabel}</div>}
{treeDescription && <div className="access-gate-tree-desc">{treeDescription}</div>}
</div>
)}
{!treeNotFound && (
<>
<label htmlFor="treeId">Code de l'arbre partagé</label>
<input
id="treeId"
type="text"
value={treeId}
onChange={(event) => setTreeId(event.target.value)}
disabled={Boolean(initialTreeId) || loading}
placeholder="Ex: cm..."
required
/>
<label htmlFor="sharePassword">Mot de passe de partage</label>
<input
id="sharePassword"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
disabled={loading}
placeholder="********"
required
/>
</>
)}
{errorMessage && <div className="access-gate-error">{errorMessage}</div>}
{!treeNotFound && (
<button type="submit" disabled={loading}>
{loading ? 'Connexion...' : "Ouvrir l'arbre"}
</button>
)}
{treeNotFound && onUseAccount && (
<button type="button" className="ghost" onClick={() => onUseAccount('register')} disabled={loading}>
Créer mon arbre
</button>
)}
{onUseAccount && !treeNotFound && (
<>
<button type="button" className="ghost" onClick={() => onUseAccount('login')} disabled={loading}>
Se connecter avec un compte
</button>
<button type="button" className="ghost" onClick={() => onUseAccount('register')} disabled={loading}>
Créer un compte
</button>
</>
)}
</form>
</div>
)
}
export default AccessGate
@@ -0,0 +1,192 @@
import { useEffect, useMemo, useState } from 'react'
const AccountDashboard = ({
authenticated,
loading,
errorMessage,
trees,
activeTreeId,
defaultMode = 'login',
pendingTreeName = '',
onLogin,
onRegister,
onOpenTree,
onStartTreeWizard,
onLogout,
onBackToAccess,
onUseDemo,
}) => {
const [mode, setMode] = useState(defaultMode === 'register' ? 'register' : 'login')
const [firstName, setFirstName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
useEffect(() => {
setMode(defaultMode === 'register' ? 'register' : 'login')
}, [defaultMode])
const isRegister = mode === 'register'
const canSubmitAuth = useMemo(() => {
if (loading) return false
if (!email.trim()) return false
if (password.length < 8) return false
if (isRegister && !firstName.trim()) return false
return true
}, [email, firstName, isRegister, loading, password.length])
const handleAuthSubmit = async (event) => {
event.preventDefault()
if (!canSubmitAuth) {
return
}
const safeEmail = email.trim().toLowerCase()
if (isRegister) {
await onRegister?.(firstName.trim(), safeEmail, password)
return
}
await onLogin(safeEmail, password)
}
if (!authenticated) {
return (
<div className="account-dashboard">
<div className="account-dashboard-card auth-only">
<h1>Parenthèse</h1>
<p className="account-subtitle">
{isRegister ? 'Créez votre compte.' : 'Votre histoire familiale, vivante et partagée.'}
</p>
{pendingTreeName && (
<p className="account-pending-tree">
Vous reviendrez ensuite sur l'arbre « {pendingTreeName} », rattaché à votre compte.
</p>
)}
<form className="account-form" onSubmit={handleAuthSubmit}>
{isRegister && (
<>
<label htmlFor="accountFirstName">Prénom</label>
<input
id="accountFirstName"
type="text"
autoComplete="given-name"
value={firstName}
maxLength={100}
onChange={(event) => setFirstName(event.target.value)}
required
/>
</>
)}
<label htmlFor="accountEmail">Email</label>
<input
id="accountEmail"
type="email"
autoComplete="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
/>
<label htmlFor="accountPassword">Mot de passe</label>
<input
id="accountPassword"
type="password"
autoComplete={isRegister ? 'new-password' : 'current-password'}
value={password}
onChange={(event) => setPassword(event.target.value)}
minLength={8}
required
/>
{isRegister && <p className="account-hint">8 caractères minimum.</p>}
{errorMessage && <div className="account-error">{errorMessage}</div>}
<button type="submit" disabled={!canSubmitAuth}>
{loading ? 'Traitement...' : isRegister ? 'Créer mon compte' : 'Se connecter'}
</button>
{isRegister && (
<p className="account-hint">
En créant un compte, vous acceptez{' '}
<a href="https://parenthese.io/donnees-et-vie-privee/" target="_blank" rel="noopener noreferrer">
la façon dont Parenthèse traite vos données
</a>
.
</p>
)}
</form>
<div className="account-divider">ou</div>
<div className="account-secondary-actions">
<button
type="button"
className="account-secondary-btn account-signup-toggle"
onClick={() => setMode(isRegister ? 'login' : 'register')}
disabled={loading}
>
{isRegister ? "J'ai déjà un compte" : 'Créer un compte'}
</button>
<button
type="button"
className="account-secondary-btn account-shared-link"
onClick={onBackToAccess}
disabled={loading}
>
Accéder avec un lien partagé
</button>
</div>
</div>
</div>
)
}
return (
<div className="account-dashboard">
<div className="account-dashboard-card">
<h1>Votre compte</h1>
<p>Choisissez un arbre ou créez-en un nouveau.</p>
{errorMessage && <div className="account-error">{errorMessage}</div>}
<div className="tree-list">
{trees.length === 0 ? (
<div className="tree-empty">Aucun arbre pour le moment.</div>
) : (
trees.map((tree) => (
<button
key={tree.id}
type="button"
className={`tree-item ${activeTreeId === tree.id ? 'active' : ''}`}
onClick={() => onOpenTree(tree.id)}
>
<span className="tree-name">{tree.name}</span>
</button>
))
)}
</div>
<button type="button" onClick={onStartTreeWizard} disabled={loading}>
Créer un nouvel arbre
</button>
<div className="account-actions">
<button type="button" className="ghost" onClick={onLogout} disabled={loading}>
Déconnexion
</button>
<button type="button" className="ghost" onClick={onBackToAccess} disabled={loading}>
Accès par mot de passe partagé
</button>
{onUseDemo && (
<button type="button" className="ghost" onClick={onUseDemo} disabled={loading}>
Essayer la démo
</button>
)}
</div>
</div>
</div>
)
}
export default AccountDashboard
+112
View File
@@ -0,0 +1,112 @@
import { useMemo, useState } from 'react'
const AccountPanel = ({
visible,
userDisplayName = '',
userEmail = '',
linkedPerson = null,
selectedPersonId = '',
people = [],
onSelectPerson,
onClose,
}) => {
const [query, setQuery] = useState('')
const filteredPeople = useMemo(() => {
const normalizedQuery = query.trim().toLowerCase()
const source = Array.isArray(people) ? people : []
if (!normalizedQuery) {
return source.slice(0, 40)
}
return source
.filter((person) => `${person.firstName || ''} ${person.lastName || ''}`.toLowerCase().includes(normalizedQuery))
.slice(0, 40)
}, [people, query])
if (!visible) {
return null
}
return (
<div className="account-overlay account-overlay--open">
<div className="account-card">
<div className="account-header">
<div className="account-header-text">
<h1>Compte</h1>
{userEmail && <p>{userEmail}</p>}
</div>
<button type="button" className="ghost" onClick={onClose}>Fermer</button>
</div>
<div className="account-linked">
<h2>Profil lié dans cet arbre</h2>
<div className="account-linked-person">
<div className="account-linked-avatar">
{linkedPerson?.photo ? (
<img src={linkedPerson.photo} alt="" />
) : (
<span>{(linkedPerson?.firstName || userDisplayName || 'C').charAt(0).toUpperCase()}</span>
)}
</div>
<div className="account-linked-meta">
<strong>
{linkedPerson ? `${linkedPerson.firstName || ''} ${linkedPerson.lastName || ''}`.trim() : userDisplayName || 'Compte'}
</strong>
<span>
{linkedPerson
? 'Cette personne est utilisée pour votre nom et photo de profil.'
: 'Aucune personne liée pour le moment.'}
</span>
</div>
</div>
</div>
<div className="account-pick">
<h2>Je suis cette personne</h2>
<input
type="text"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Rechercher une personne..."
/>
<div className="account-person-list">
{filteredPeople.length === 0 ? (
<div className="account-empty">Aucun résultat.</div>
) : (
filteredPeople.map((person) => {
const isSelected = String(selectedPersonId) === String(person.id)
return (
<button
key={person.id}
type="button"
className={`account-person-item ${isSelected ? 'active' : ''}`}
onClick={() => onSelectPerson?.(person.id)}
>
<div className="account-person-avatar">
{person.photo ? <img src={person.photo} alt="" /> : <span>{(person.firstName || '?').charAt(0)}</span>}
</div>
<div className="account-person-name">
{person.firstName} {person.lastName || ''}
</div>
{isSelected && <span className="account-person-tag">Lié</span>}
</button>
)
})
)}
</div>
{selectedPersonId && (
<button type="button" className="ghost" onClick={() => onSelectPerson?.(null)}>
Retirer la liaison
</button>
)}
</div>
</div>
<button type="button" className="account-backdrop" aria-label="Fermer" onClick={onClose} />
</div>
)
}
export default AccountPanel
+172
View File
@@ -0,0 +1,172 @@
/* ============================================================
Add Person Button - FAB draggable pour ajouter personne
============================================================ */
.add-person-button {
position: fixed;
width: 64px;
height: 64px;
border-radius: 50%;
background: rgba(247, 236, 217, 0.95);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 2px solid rgba(166, 124, 82, 0.3);
box-shadow:
0 8px 32px rgba(93, 82, 75, 0.25),
0 2px 8px rgba(93, 82, 75, 0.12);
cursor: grab;
user-select: none;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.25s cubic-bezier(0.22, 1, 0.36, 1);
z-index: 900;
/* Position initiale : bas-gauche */
bottom: 24px;
left: 24px;
}
.add-person-button:hover {
transform: scale(1.12);
box-shadow:
0 12px 40px rgba(166, 124, 82, 0.35),
0 4px 12px rgba(93, 82, 75, 0.18);
border-color: rgba(166, 124, 82, 0.5);
}
.add-person-button.dragging {
cursor: grabbing;
transform: scale(1.2) rotate(5deg);
box-shadow:
0 16px 48px rgba(166, 124, 82, 0.45),
0 6px 16px rgba(93, 82, 75, 0.25);
border-color: rgba(166, 124, 82, 0.7);
z-index: 1100;
}
/* Icône + */
.add-person-button-icon {
font-size: 32px;
font-weight: 300;
color: #A67C52;
line-height: 1;
filter: drop-shadow(0 1px 3px rgba(93, 82, 75, 0.15));
transition: transform 0.2s ease;
}
.add-person-button:hover .add-person-button-icon {
transform: rotate(90deg);
}
.add-person-button.dragging .add-person-button-icon {
transform: rotate(180deg);
}
/* Pulse animation sur apparition */
@keyframes addPersonPulse {
0% {
opacity: 0;
transform: scale(0.7);
}
50% {
transform: scale(1.15);
}
100% {
opacity: 1;
transform: scale(1);
}
}
.add-person-button.entering {
animation: addPersonPulse 0.5s cubic-bezier(0.22, 1, 0.36, 1);
}
/* Tooltip */
.add-person-button-tooltip {
position: absolute;
top: 50%;
left: calc(100% + 12px);
transform: translateY(-50%);
padding: 8px 14px;
background: rgba(93, 82, 75, 0.95);
color: #FEF9ED;
font-size: 14px;
font-weight: 500;
border-radius: 8px;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.add-person-button:hover .add-person-button-tooltip {
opacity: 1;
}
.add-person-button.dragging .add-person-button-tooltip {
opacity: 0;
}
/* Instructions drag (affichées pendant drag) */
.add-person-drag-hint {
position: fixed;
top: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 1050;
padding: 10px 18px;
background: rgba(247, 236, 217, 0.98);
backdrop-filter: blur(24px);
border-radius: 50px;
border: 1px solid rgba(166, 124, 82, 0.2);
font-size: 14px;
font-weight: 450;
color: #5D524B;
box-shadow:
0 6px 24px rgba(93, 82, 75, 0.18),
0 2px 8px rgba(93, 82, 75, 0.08);
animation: hintSlideDown 0.3s cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes hintSlideDown {
from {
opacity: 0;
transform: translateX(-50%) translateY(-10px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
/* Responsive mobile */
@media (max-width: 768px) {
.add-person-button {
width: 56px;
height: 56px;
bottom: 20px;
left: 20px;
}
.add-person-button-icon {
font-size: 28px;
}
.add-person-button-tooltip {
display: none;
}
}
@@ -0,0 +1,83 @@
import { useRef, useEffect, useState } from 'react'
import { Plus, Baby, Heart, Users, MapPin } from 'lucide-react'
import './AddPersonButton.css'
/**
* Bouton FAB draggable pour ajouter une personne
* Utilisé en mode édition pour drag-and-drop dans la galaxie
*
* @param {Object} props
* @param {boolean} props.isDragging - État dragging (du hook)
* @param {Object} props.dragPosition - Position drag {x, y}
* @param {Function} props.onDragStart - Handler début drag
* @param {string} props.linkType - Type de lien détecté ('child', 'spouse', 'sibling')
*/
function AddPersonButton({ isDragging, dragPosition, onDragStart, linkType }) {
const buttonRef = useRef(null)
const [entering, setEntering] = useState(true)
// Animation d'entrée
useEffect(() => {
const timer = setTimeout(() => setEntering(false), 500)
return () => clearTimeout(timer)
}, [])
const handleMouseDown = (e) => {
e.preventDefault()
if (!buttonRef.current) return
const rect = buttonRef.current.getBoundingClientRect()
const buttonPosition = {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
}
onDragStart?.(e, buttonPosition)
}
// Position inline si dragging
const style = isDragging
? {
left: `${dragPosition.x - 32}px`,
top: `${dragPosition.y - 32}px`,
transition: 'none',
}
: {}
// Message tooltip selon linkType
let tooltipText = 'Glisser pour ajouter une personne'
if (isDragging) {
if (linkType === 'child') tooltipText = 'Ajouter un enfant'
else if (linkType === 'spouse') tooltipText = 'Ajouter un conjoint'
else if (linkType === 'sibling') tooltipText = 'Ajouter un frère/sœur'
else tooltipText = 'Relâcher pour créer'
}
return (
<>
<button
ref={buttonRef}
type="button"
className={`add-person-button ${isDragging ? 'dragging' : ''} ${entering ? 'entering' : ''}`}
style={style}
onMouseDown={handleMouseDown}
aria-label="Ajouter une personne"
>
<span className="add-person-button-icon"><Plus size={28} strokeWidth={1.5} /></span>
<span className="add-person-button-tooltip">{tooltipText}</span>
</button>
{/* Hint pendant drag */}
{isDragging && (
<div className="add-person-drag-hint">
{linkType === 'child' && <><Baby size={16} strokeWidth={2} /> Enfant</>}
{linkType === 'spouse' && <><Heart size={16} strokeWidth={2} /> Conjoint</>}
{linkType === 'sibling' && <><Users size={16} strokeWidth={2} /> Frère·Sœur</>}
{!linkType && <><MapPin size={16} strokeWidth={2} /> Positionnez la personne</>}
</div>
)}
</>
)
}
export default AddPersonButton
+413
View File
@@ -0,0 +1,413 @@
/* ============================================================
Add Person Panel - Slide-in gauche ajout de personne
400px desktop, 90% mobile
============================================================ */
.add-person-panel {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: 400px;
z-index: 1050;
background: rgba(254, 249, 237, 0.98);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border-right: 1px solid rgba(166, 124, 82, 0.15);
box-shadow: 6px 0 32px rgba(93, 82, 75, 0.15);
transform: translateX(-100%);
transition: transform 0.35s cubic-bezier(0.22, 1, 0.36, 1);
display: flex;
flex-direction: column;
overflow: hidden;
}
.add-person-panel--open {
transform: translateX(0);
}
/* --- Header --- */
.add-person-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px 16px;
border-bottom: 1px solid rgba(166, 124, 82, 0.1);
flex-shrink: 0;
}
.add-person-header-title {
display: flex;
align-items: center;
gap: 10px;
color: #5D524B;
}
.add-person-header-title h2 {
font-family: 'Red Hat Mono', ui-monospace, monospace;
font-size: 18px;
font-weight: 700;
margin: 0;
color: #5D524B;
}
.add-person-close {
width: 32px;
height: 32px;
border-radius: 50%;
border: none;
background: transparent;
color: #8B7355;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.add-person-close:hover {
background: rgba(93, 82, 75, 0.1);
color: #5D524B;
}
/* --- Form --- */
.add-person-form {
flex: 1;
overflow-y: auto;
padding: 20px 24px 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.add-person-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.add-person-label {
font-family: 'Red Hat Mono', ui-monospace, monospace;
font-size: 14px;
font-weight: 450;
color: #8B7355;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.add-person-required {
color: #C0392B;
}
.add-person-input {
padding: 10px 14px;
border-radius: 10px;
border: 1.5px solid rgba(166, 124, 82, 0.2);
background: rgba(255, 255, 255, 0.6);
font-family: 'Red Hat Mono', ui-monospace, monospace;
font-size: 14px;
color: #5D524B;
transition: all 0.2s ease;
outline: none;
}
.add-person-input::placeholder {
color: #B8A898;
}
.add-person-input:focus {
border-color: #A67C52;
box-shadow: 0 0 0 3px rgba(166, 124, 82, 0.12);
}
.add-person-input--error {
border-color: rgba(192, 57, 43, 0.5);
}
.add-person-input--error:focus {
border-color: #C0392B;
box-shadow: 0 0 0 3px rgba(192, 57, 43, 0.1);
}
.add-person-error-text {
font-size: 14px;
color: #C0392B;
}
.add-person-row {
display: flex;
gap: 12px;
}
.add-person-row .add-person-field {
flex: 1;
}
/* --- Section Relations --- */
.add-person-section {
margin-top: 8px;
padding-top: 16px;
border-top: 1px solid rgba(166, 124, 82, 0.1);
display: flex;
flex-direction: column;
gap: 12px;
}
.add-person-section-title {
font-family: 'Red Hat Mono', ui-monospace, monospace;
font-size: 14px;
font-weight: 700;
color: #5D524B;
margin: 0;
}
/* Relation type pills */
.relation-type-pills {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.relation-type-pill {
padding: 5px 12px;
border-radius: 20px;
border: 1.5px solid rgba(166, 124, 82, 0.2);
background: transparent;
font-family: 'Red Hat Mono', ui-monospace, monospace;
font-size: 14px;
font-weight: 450;
color: #8B7355;
cursor: pointer;
transition: all 0.2s ease;
}
.relation-type-pill:hover {
border-color: rgba(166, 124, 82, 0.4);
background: rgba(166, 124, 82, 0.05);
}
.relation-type-pill--active {
border-color: #A67C52;
background: rgba(166, 124, 82, 0.1);
color: #A67C52;
}
/* Relation search */
.relation-search-wrapper {
position: relative;
}
.relation-search-icon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #B8A898;
pointer-events: none;
}
.relation-search-input {
padding-left: 32px !important;
padding-right: 28px !important;
}
.relation-search-chevron {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
color: #B8A898;
pointer-events: none;
}
/* Relation dropdown */
.relation-dropdown-wrapper {
position: relative;
}
.relation-dropdown-list {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 10;
margin: 4px 0 0;
padding: 4px;
list-style: none;
background: rgba(254, 249, 237, 0.99);
backdrop-filter: blur(16px);
border: 1px solid rgba(166, 124, 82, 0.2);
border-radius: 12px;
box-shadow: 0 8px 24px rgba(93, 82, 75, 0.15);
max-height: 200px;
overflow-y: auto;
}
.relation-dropdown-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 8px 12px;
border: none;
background: transparent;
border-radius: 8px;
cursor: pointer;
transition: background 0.15s ease;
text-align: left;
}
.relation-dropdown-item:hover {
background: rgba(166, 124, 82, 0.08);
}
.relation-dropdown-name {
font-family: 'Red Hat Mono', ui-monospace, monospace;
font-size: 14px;
font-weight: 450;
color: #5D524B;
}
.relation-dropdown-date {
font-family: 'Red Hat Mono', ui-monospace, monospace;
font-size: 14px;
color: #B8A898;
}
/* Relation tags */
.relation-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.relation-tag {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 6px 4px 10px;
border-radius: 20px;
background: rgba(166, 124, 82, 0.08);
border: 1px solid rgba(166, 124, 82, 0.15);
}
.relation-tag-type {
font-family: 'Red Hat Mono', ui-monospace, monospace;
font-size: 12px;
font-weight: 700;
color: #A67C52;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.relation-tag-name {
font-family: 'Red Hat Mono', ui-monospace, monospace;
font-size: 14px;
font-weight: 450;
color: #5D524B;
}
.relation-tag-remove {
width: 18px;
height: 18px;
border-radius: 50%;
border: none;
background: transparent;
color: #8B7355;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease;
}
.relation-tag-remove:hover {
background: rgba(192, 57, 43, 0.1);
color: #C0392B;
}
/* --- Error --- */
.add-person-error {
padding: 10px 14px;
border-radius: 10px;
background: rgba(192, 57, 43, 0.08);
border: 1px solid rgba(192, 57, 43, 0.2);
color: #C0392B;
font-family: 'Red Hat Mono', ui-monospace, monospace;
font-size: 14px;
}
/* --- Actions --- */
.add-person-actions {
margin-top: auto;
padding-top: 16px;
border-top: 1px solid rgba(166, 124, 82, 0.1);
display: flex;
flex-direction: column;
gap: 8px;
}
.add-person-submit {
padding: 12px 20px;
border-radius: 12px;
border: none;
background: #A67C52;
color: #FEF9ED;
font-family: 'Red Hat Mono', ui-monospace, monospace;
font-size: 14px;
font-weight: 700;
cursor: pointer;
transition: all 0.2s ease;
}
.add-person-submit:hover:not(:disabled) {
background: #96703E;
box-shadow: 0 4px 16px rgba(166, 124, 82, 0.3);
}
.add-person-submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.add-person-cancel {
padding: 10px 20px;
border-radius: 12px;
border: none;
background: transparent;
color: #8B7355;
font-family: 'Red Hat Mono', ui-monospace, monospace;
font-size: 14px;
font-weight: 450;
cursor: pointer;
transition: all 0.2s ease;
}
.add-person-cancel:hover {
background: rgba(93, 82, 75, 0.08);
color: #5D524B;
}
/* --- Responsive Mobile --- */
@media (max-width: 768px) {
.add-person-panel {
width: 90vw;
}
.add-person-header {
padding: 16px 18px 12px;
}
.add-person-form {
padding: 16px 18px 20px;
}
}
+346
View File
@@ -0,0 +1,346 @@
import { useState, useEffect, useRef } from 'react'
import { UserPlus, X, Search, ChevronDown } from 'lucide-react'
import './AddPersonPanel.css'
/**
* Panneau slide-in gauche pour ajouter une personne
* Visible uniquement en mode édition
*
* Features:
* - Slide-in depuis la gauche (400px desktop, 90% mobile)
* - Focus auto sur Prénom à l'ouverture
* - Validation temps-réel (required)
* - Section Relations avec dropdown searchable
* - Reste ouvert après ajout réussi (reset form)
*
* @param {Object} props
* @param {boolean} props.visible - Afficher le panneau
* @param {Array} props.persons - Liste des personnes existantes pour les relations
* @param {boolean} props.loading - Soumission en cours
* @param {string} props.error - Message d'erreur
* @param {Function} props.onSubmit - Handler soumission (formData)
* @param {Function} props.onClose - Handler fermeture
*/
function AddPersonPanel({ visible, persons = [], loading = false, error = '', onSubmit, onClose }) {
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [birthName, setBirthName] = useState('')
const [birthDate, setBirthDate] = useState('')
const [deathDate, setDeathDate] = useState('')
const [relations, setRelations] = useState([])
const [relationSearch, setRelationSearch] = useState('')
const [relationDropdownOpen, setRelationDropdownOpen] = useState(false)
const [selectedRelationType, setSelectedRelationType] = useState('parent')
const [touched, setTouched] = useState({})
const [justAdded, setJustAdded] = useState(false)
const firstNameRef = useRef(null)
const panelRef = useRef(null)
// Focus sur Prénom à l'ouverture
useEffect(() => {
if (visible) {
setTimeout(() => firstNameRef.current?.focus(), 300)
}
}, [visible])
// ESC pour fermer
useEffect(() => {
if (!visible) return
function handleKeyDown(e) {
if (e.key === 'Escape') {
onClose()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [visible, onClose])
// Fermer dropdown relation au clic extérieur
useEffect(() => {
if (!relationDropdownOpen) return
function handleClick(e) {
if (!e.target.closest('.relation-dropdown-wrapper')) {
setRelationDropdownOpen(false)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [relationDropdownOpen])
// Reset form après ajout réussi
useEffect(() => {
if (justAdded && !loading && !error) {
setFirstName('')
setLastName('')
setBirthName('')
setBirthDate('')
setDeathDate('')
setRelations([])
setTouched({})
setJustAdded(false)
firstNameRef.current?.focus()
}
}, [justAdded, loading, error])
const filteredPersons = persons.filter((p) => {
if (!relationSearch) return true
const fullName = `${p.firstName || ''} ${p.lastName || ''}`.toLowerCase()
return fullName.includes(relationSearch.toLowerCase())
})
const handleAddRelation = (person) => {
// Éviter les doublons
if (relations.some((r) => r.personId === person.id)) return
setRelations((prev) => [
...prev,
{ personId: person.id, personName: `${person.firstName} ${person.lastName || ''}`.trim(), type: selectedRelationType },
])
setRelationSearch('')
setRelationDropdownOpen(false)
}
const handleRemoveRelation = (personId) => {
setRelations((prev) => prev.filter((r) => r.personId !== personId))
}
const handleSubmit = (e) => {
e.preventDefault()
if (!firstName.trim()) {
setTouched({ firstName: true })
firstNameRef.current?.focus()
return
}
setJustAdded(true)
onSubmit({
firstName: firstName.trim(),
lastName: lastName.trim(),
birthName: birthName.trim() || null,
birthDate: birthDate || null,
deathDate: deathDate || null,
relations,
})
}
const isValid = firstName.trim().length > 0
const relationTypeLabels = {
parent: 'Parent de',
child: 'Enfant de',
spouse: 'Conjoint·e de',
sibling: 'Frère·Sœur de',
}
return (
<div className={`add-person-panel ${visible ? 'add-person-panel--open' : ''}`} ref={panelRef}>
<div className="add-person-header">
<div className="add-person-header-title">
<UserPlus size={20} strokeWidth={2} />
<h2>Ajouter une personne</h2>
</div>
<button
type="button"
className="add-person-close"
onClick={onClose}
aria-label="Fermer"
>
<X size={20} strokeWidth={2} />
</button>
</div>
<form className="add-person-form" onSubmit={handleSubmit}>
{/* Prénom (requis) */}
<div className="add-person-field">
<label htmlFor="ap-firstName" className="add-person-label">
Prénom <span className="add-person-required">*</span>
</label>
<input
ref={firstNameRef}
id="ap-firstName"
type="text"
className={`add-person-input ${touched.firstName && !firstName.trim() ? 'add-person-input--error' : ''}`}
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
onBlur={() => setTouched((t) => ({ ...t, firstName: true }))}
placeholder="Ex : Marie"
autoComplete="off"
/>
{touched.firstName && !firstName.trim() && (
<span className="add-person-error-text">Le prénom est requis</span>
)}
</div>
{/* Nom */}
<div className="add-person-field">
<label htmlFor="ap-lastName" className="add-person-label">Nom</label>
<input
id="ap-lastName"
type="text"
className="add-person-input"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
placeholder="Ex : Dupont"
autoComplete="off"
/>
</div>
{/* Nom de naissance */}
<div className="add-person-field">
<label htmlFor="ap-birthName" className="add-person-label">Nom de naissance</label>
<input
id="ap-birthName"
type="text"
className="add-person-input"
value={birthName}
onChange={(e) => setBirthName(e.target.value)}
placeholder="Ex : Martin"
autoComplete="off"
/>
</div>
{/* Dates en ligne */}
<div className="add-person-row">
<div className="add-person-field">
<label htmlFor="ap-birthDate" className="add-person-label">Date de naissance</label>
<input
id="ap-birthDate"
type="date"
className="add-person-input"
value={birthDate}
onChange={(e) => setBirthDate(e.target.value)}
/>
</div>
<div className="add-person-field">
<label htmlFor="ap-deathDate" className="add-person-label">Date de décès</label>
<input
id="ap-deathDate"
type="date"
className="add-person-input"
value={deathDate}
onChange={(e) => setDeathDate(e.target.value)}
/>
</div>
</div>
{/* Section Relations */}
<div className="add-person-section">
<h3 className="add-person-section-title">Relations</h3>
{/* Type de relation */}
<div className="add-person-field">
<label htmlFor="ap-relationType" className="add-person-label">Type de lien</label>
<div className="relation-type-pills">
{Object.entries(relationTypeLabels).map(([type, label]) => (
<button
key={type}
type="button"
className={`relation-type-pill ${selectedRelationType === type ? 'relation-type-pill--active' : ''}`}
onClick={() => setSelectedRelationType(type)}
>
{label.split(' ')[0]}
</button>
))}
</div>
</div>
{/* Recherche personne existante */}
<div className="add-person-field relation-dropdown-wrapper">
<label className="add-person-label">
{relationTypeLabels[selectedRelationType]}
</label>
<div className="relation-search-wrapper">
<Search size={14} strokeWidth={2} className="relation-search-icon" />
<input
type="text"
className="add-person-input relation-search-input"
value={relationSearch}
onChange={(e) => {
setRelationSearch(e.target.value)
setRelationDropdownOpen(true)
}}
onFocus={() => setRelationDropdownOpen(true)}
placeholder="Rechercher une personne..."
autoComplete="off"
/>
<ChevronDown size={14} strokeWidth={2} className="relation-search-chevron" />
</div>
{relationDropdownOpen && filteredPersons.length > 0 && (
<ul className="relation-dropdown-list">
{filteredPersons.slice(0, 8).map((p) => (
<li key={p.id}>
<button
type="button"
className="relation-dropdown-item"
onClick={() => handleAddRelation(p)}
>
<span className="relation-dropdown-name">
{p.firstName} {p.lastName || ''}
</span>
{p.birthDate && (
<span className="relation-dropdown-date">{p.birthDate}</span>
)}
</button>
</li>
))}
</ul>
)}
</div>
{/* Relations ajoutées */}
{relations.length > 0 && (
<div className="relation-tags">
{relations.map((rel) => (
<div key={rel.personId} className="relation-tag">
<span className="relation-tag-type">{relationTypeLabels[rel.type].split(' ')[0]}</span>
<span className="relation-tag-name">{rel.personName}</span>
<button
type="button"
className="relation-tag-remove"
onClick={() => handleRemoveRelation(rel.personId)}
aria-label={`Retirer ${rel.personName}`}
>
<X size={12} strokeWidth={2.5} />
</button>
</div>
))}
</div>
)}
</div>
{/* Erreur */}
{error && (
<div className="add-person-error" role="alert">{error}</div>
)}
{/* Actions */}
<div className="add-person-actions">
<button
type="submit"
className="add-person-submit"
disabled={!isValid || loading}
>
{loading ? 'Ajout en cours...' : 'Ajouter cette personne'}
</button>
<button
type="button"
className="add-person-cancel"
onClick={onClose}
>
Annuler
</button>
</div>
</form>
</div>
)
}
export default AddPersonPanel
+456
View File
@@ -0,0 +1,456 @@
import { useEffect, useRef, useState } from 'react'
import {
deleteTree,
exportGedcom,
fetchTreeSettings,
getTree,
importGedcom,
updateTree,
updateTreeSettings,
} from '../api/treeApi'
function normalizeSlug(value) {
return String(value || '')
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
const AdminPanel = ({
visible,
treeId,
authToken,
onClose,
onTreeDeleted,
onGedcomImported,
}) => {
const [section, setSection] = useState('tree')
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [errorMessage, setErrorMessage] = useState('')
const [successMessage, setSuccessMessage] = useState('')
const [tree, setTree] = useState(null)
const [treeName, setTreeName] = useState('')
const [treeDescription, setTreeDescription] = useState('')
// Contributions de la famille : 'pending' = validées par l'admin, 'direct' = appliquées immédiatement
const [contributionPolicy, setContributionPolicy] = useState('pending')
const [savingPolicy, setSavingPolicy] = useState(false)
const [showDeleteModal, setShowDeleteModal] = useState(false)
const [deleteConfirmName, setDeleteConfirmName] = useState('')
const [gedcomFile, setGedcomFile] = useState(null)
const [importingGedcom, setImportingGedcom] = useState(false)
const gedcomInputRef = useRef(null)
useEffect(() => {
if (!visible || !treeId || !authToken) {
return
}
let active = true
setLoading(true)
setErrorMessage('')
setSuccessMessage('')
setSection('tree')
setShowDeleteModal(false)
setDeleteConfirmName('')
setGedcomFile(null)
setImportingGedcom(false)
fetchTreeSettings(treeId, authToken)
.then((settings) => {
if (!active || !settings) return
setContributionPolicy(settings.contributorPolicy === 'direct' ? 'direct' : 'pending')
})
.catch(() => {
// Réglage non chargé : on laisse la valeur par défaut affichée
})
getTree(treeId, authToken)
.then((result) => {
if (!active) return
const loadedTree = result?.tree || null
setTree(loadedTree)
setTreeName(loadedTree?.name || '')
setTreeDescription(loadedTree?.description || '')
})
.catch(() => {
if (!active) return
setErrorMessage("Impossible de charger les paramètres de cet arbre.")
})
.finally(() => {
if (active) setLoading(false)
})
return () => {
active = false
}
}, [visible, treeId, authToken])
const handlePolicyChange = async (nextPolicy) => {
if (!treeId || !authToken || savingPolicy || nextPolicy === contributionPolicy) return
const previous = contributionPolicy
setContributionPolicy(nextPolicy)
setSavingPolicy(true)
setErrorMessage('')
setSuccessMessage('')
try {
// Un seul réglage pour la famille, avec ou sans compte
await updateTreeSettings(treeId, { contributorPolicy: nextPolicy, memberContributionPolicy: nextPolicy }, authToken)
setSuccessMessage(nextPolicy === 'direct'
? 'Les modifications de la famille sont désormais appliquées directement.'
: 'Vous validez désormais chaque proposition de la famille.')
} catch {
setContributionPolicy(previous)
setErrorMessage("Impossible d'enregistrer ce réglage.")
} finally {
setSavingPolicy(false)
}
}
const handleSaveTree = async (event) => {
event.preventDefault()
if (!treeId || !authToken || saving || !tree) return
const nextName = treeName.trim()
if (nextName.length < 2) {
setErrorMessage("Le nom de l'arbre est trop court.")
return
}
setSaving(true)
setErrorMessage('')
setSuccessMessage('')
try {
const payload = {
name: nextName,
description: treeDescription.trim() || null,
}
const normalized = normalizeSlug(nextName)
const slugWillChange = normalized && normalized !== tree.slug
if (slugWillChange) {
const confirmSlugUpdate = window.confirm(
'Souhaitez-vous mettre à jour aussi le lien de partage avec ce nouveau nom ?',
)
if (confirmSlugUpdate) {
payload.slug = normalized
}
}
const updated = await updateTree(treeId, payload, authToken)
setTree(updated)
setTreeName(updated?.name || nextName)
setTreeDescription(updated?.description || '')
setSuccessMessage('Modifications enregistrées.')
} catch {
setErrorMessage("Impossible d'enregistrer les modifications.")
} finally {
setSaving(false)
}
}
const handleExportGedcom = async () => {
if (!treeId || !authToken || saving) return
setSaving(true)
setErrorMessage('')
setSuccessMessage('')
try {
const { blob, filename } = await exportGedcom(treeId, authToken)
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
link.click()
URL.revokeObjectURL(url)
} catch {
setErrorMessage('Échec du téléchargement GEDCOM.')
} finally {
setSaving(false)
}
}
const readFileAsText = (file) => new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result || ''))
reader.onerror = () => reject(new Error('read_failed'))
reader.readAsText(file)
})
const handleImportGedcom = async () => {
if (!treeId || !authToken || saving || !gedcomFile) {
return
}
setSaving(true)
setImportingGedcom(true)
setErrorMessage('')
setSuccessMessage('')
try {
const fileContent = await readFileAsText(gedcomFile)
if (!fileContent.trim()) {
setErrorMessage("Le fichier GEDCOM est vide.")
return
}
const result = await importGedcom(treeId, fileContent, authToken)
const personsCreated = Number(result?.personsCreated || 0)
const unionsCreated = Number(result?.unionsCreated || 0)
const linksCreated = Number(result?.linksCreated || 0)
setSuccessMessage(
`Import GEDCOM terminé : ${personsCreated} personnes, ${unionsCreated} unions, ${linksCreated} filiations.`,
)
setGedcomFile(null)
if (gedcomInputRef.current) {
gedcomInputRef.current.value = ''
}
await onGedcomImported?.(result)
} catch {
setErrorMessage("Impossible d'importer ce fichier GEDCOM.")
} finally {
setImportingGedcom(false)
setSaving(false)
}
}
const handleDeleteTree = async () => {
if (!treeId || !authToken || saving || !tree) return
if (deleteConfirmName.trim() !== tree.name) {
setErrorMessage('Le nom saisi ne correspond pas.')
return
}
setSaving(true)
setErrorMessage('')
setSuccessMessage('')
try {
await deleteTree(treeId, authToken)
setShowDeleteModal(false)
onTreeDeleted?.(treeId)
onClose?.()
} catch {
setErrorMessage('Impossible de supprimer cet arbre.')
} finally {
setSaving(false)
}
}
return (
<div
className={`contrib-overlay tree-settings-overlay ${visible ? 'contrib-overlay--open tree-settings-overlay--open' : ''}`}
onClick={() => {
if (visible && !showDeleteModal) onClose?.()
}}
aria-hidden={!visible}
>
<div
className="contrib-card tree-settings-card"
onClick={(event) => event.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label="Paramètres de l'arbre"
>
<div className="contrib-header tree-settings-header">
<div className="contrib-header-text">
<h1>Paramètres de l'arbre</h1>
<p>{tree?.name || 'Réglages de cet arbre familial'}</p>
</div>
<button type="button" className="ghost" onClick={onClose}>Fermer</button>
</div>
<div className="tree-settings-tabs">
<button type="button" className={`tree-settings-tab ${section === 'tree' ? 'active' : ''}`} onClick={() => setSection('tree')}>Votre arbre</button>
<button type="button" className={`tree-settings-tab ${section === 'data' ? 'active' : ''}`} onClick={() => setSection('data')}>Données</button>
<button type="button" className={`tree-settings-tab ${section === 'danger' ? 'active' : ''}`} onClick={() => setSection('danger')}>Paramètres avancés</button>
</div>
<div className="tree-settings-content">
{loading ? (
<div className="contrib-empty">Chargement...</div>
) : (
<>
{errorMessage && <div className="contrib-error">{errorMessage}</div>}
{successMessage && <div className="contrib-ok">{successMessage}</div>}
{section === 'tree' && (
<form className="tree-settings-panel" onSubmit={handleSaveTree}>
<h2>Votre arbre</h2>
<p className="tree-settings-help">Modifiez le nom et la description affichés à votre famille.</p>
<label htmlFor="settingsTreeName">Nom de l'arbre</label>
<input
id="settingsTreeName"
type="text"
value={treeName}
maxLength={120}
onChange={(event) => setTreeName(event.target.value)}
required
/>
<label htmlFor="settingsTreeDescription">Description (optionnelle)</label>
<input
id="settingsTreeDescription"
type="text"
value={treeDescription}
maxLength={500}
onChange={(event) => setTreeDescription(event.target.value)}
/>
<div className="tree-settings-actions">
<button type="submit" className="tree-settings-primary" disabled={saving}>
{saving ? 'Enregistrement...' : 'Enregistrer les modifications'}
</button>
</div>
<fieldset className="tree-settings-policy" disabled={savingPolicy}>
<legend>Contributions de la famille</legend>
<p className="tree-settings-help">
Ce que deviennent les ajouts et corrections faits avec le mot de passe de contribution.
</p>
<label className="tree-settings-radio">
<input
type="radio"
name="contributionPolicy"
value="pending"
checked={contributionPolicy === 'pending'}
onChange={() => handlePolicyChange('pending')}
/>
<span>
<strong>Je valide chaque proposition</strong>
<small>Rien n'apparaît dans l'arbre avant votre accord.</small>
</span>
</label>
<label className="tree-settings-radio">
<input
type="radio"
name="contributionPolicy"
value="direct"
checked={contributionPolicy === 'direct'}
onChange={() => handlePolicyChange('direct')}
/>
<span>
<strong>Les modifications sont appliquées directement</strong>
<small>La famille modifie l'arbre sans attendre. Vous gardez l'historique.</small>
</span>
</label>
</fieldset>
</form>
)}
{section === 'data' && (
<div className="tree-settings-panel">
<h2>Données de l'arbre</h2>
<p className="tree-settings-help">Récupérez vos informations à tout moment.</p>
<div className="tree-settings-row">
<div className="tree-settings-row-text">
<strong>Importer un fichier GEDCOM</strong>
<p>Ajoute les personnes et relations du fichier dans cet arbre.</p>
</div>
<div className="tree-settings-row-actions">
<input
ref={gedcomInputRef}
className="tree-settings-file-input"
type="file"
accept=".ged,text/x-gedcom,text/plain"
onChange={(event) => setGedcomFile(event.target.files?.[0] || null)}
/>
<button
type="button"
className="tree-settings-secondary"
onClick={() => gedcomInputRef.current?.click()}
disabled={saving}
>
Choisir un fichier
</button>
{gedcomFile && <span className="tree-settings-file-name">{gedcomFile.name}</span>}
<button
type="button"
className="tree-settings-primary"
onClick={handleImportGedcom}
disabled={saving || !gedcomFile}
>
{importingGedcom ? 'Import...' : 'Importer .ged'}
</button>
</div>
</div>
<div className="tree-settings-row">
<div className="tree-settings-row-text">
<strong>Fichier GEDCOM</strong>
<p>Format standard généalogique, compatible avec les principaux outils.</p>
</div>
<div className="tree-settings-row-actions tree-settings-row-actions--inline">
<button type="button" className="tree-settings-primary" onClick={handleExportGedcom} disabled={saving}>
Télécharger .ged
</button>
</div>
</div>
<div className="tree-settings-row tree-settings-row--muted">
<div className="tree-settings-row-text">
<strong>Sauvegarde complète</strong>
<p>Toutes les données et médias (phase 2).</p>
</div>
<div className="tree-settings-row-actions tree-settings-row-actions--inline">
<button type="button" className="tree-settings-secondary" disabled>
Télécharger .zip
</button>
</div>
</div>
</div>
)}
{section === 'danger' && (
<div className="tree-settings-panel tree-settings-panel--danger">
<h2>Paramètres avancés</h2>
<p className="tree-settings-help">Ces actions ont un impact définitif.</p>
<div className="tree-settings-danger-box">
<button type="button" className="danger-action" onClick={() => setShowDeleteModal(true)}>
Supprimer cet arbre
</button>
<p>Cette action est irréversible. Toutes les données et médias seront supprimés.</p>
</div>
</div>
)}
</>
)}
</div>
</div>
{showDeleteModal && (
<div className="settings-delete-modal-overlay" onClick={() => !saving && setShowDeleteModal(false)}>
<div className="settings-delete-modal" onClick={(event) => event.stopPropagation()}>
<h3>Confirmer la suppression</h3>
<p>Saisissez le nom de l'arbre pour confirmer : <strong>{tree?.name}</strong></p>
<input
type="text"
value={deleteConfirmName}
onChange={(event) => setDeleteConfirmName(event.target.value)}
placeholder="Nom exact de l'arbre"
/>
<div className="settings-delete-actions">
<button type="button" className="ghost" onClick={() => setShowDeleteModal(false)} disabled={saving}>
Annuler
</button>
<button type="button" className="danger-action" onClick={handleDeleteTree} disabled={saving}>
{saving ? 'Suppression...' : 'Supprimer définitivement'}
</button>
</div>
</div>
</div>
)}
</div>
)
}
export default AdminPanel
@@ -0,0 +1,580 @@
import { useEffect, useState } from 'react'
import { Check, X, ChevronDown } from 'lucide-react'
// ─── helpers ────────────────────────────────────────────────────────────────
function formatAction(action) {
if (action === 'create') return 'Création'
if (action === 'update') return 'Modification'
if (action === 'delete') return 'Suppression'
if (action === 'reorder') return 'Réorganisation'
return 'Changement'
}
function formatEntityType(entityType) {
if (entityType === 'person') return 'personne'
if (entityType === 'union') return 'union'
if (entityType === 'parent_child_link') return 'lien parent-enfant'
if (entityType === 'annotation') return 'annotation'
return 'élément'
}
function formatAnnotationType(type) {
if (type === 'sticker') return 'Sticker'
if (type === 'drawing') return 'Dessin'
if (type === 'text') return 'Texte'
if (type === 'photo') return 'Photo canvas'
return 'Annotation'
}
const PERSON_FIELD_LABELS = {
firstName: 'Prénom',
lastName: 'Nom',
birthName: 'Nom de naissance',
birthDate: 'Date de naissance',
deathDate: 'Date de décès',
birthPlace: 'Lieu de naissance',
profession: 'Métier',
nationality: 'Nationalité',
region: 'Région',
notes: 'Notes',
}
function displayVal(v) {
if (v === null || v === undefined || v === '') return null
return String(v)
}
function getPersonDisplayName(change) {
if (change.entityType === 'annotation') {
const data = change.afterJson || change.after || {}
return formatAnnotationType(data.type)
}
const data = (change.afterJson && typeof change.afterJson === 'object')
? change.afterJson
: (change.beforeJson && typeof change.beforeJson === 'object' ? change.beforeJson : null)
if (!data) return null
const name = `${data.firstName || ''} ${data.lastName || ''}`.trim()
return name || null
}
function getFieldRows(change) {
if (change.entityType !== 'person') return []
const after = change.afterJson
if (!after || typeof after !== 'object') return []
const before = (change.beforeJson && typeof change.beforeJson === 'object') ? change.beforeJson : null
return Object.entries(after)
.filter(([key]) => PERSON_FIELD_LABELS[key])
.map(([key, newVal]) => ({
key,
label: PERSON_FIELD_LABELS[key],
oldVal: before !== null ? before[key] : undefined,
newVal,
}))
.filter(({ newVal, oldVal }) => {
if (oldVal !== undefined) return true
return newVal !== null && newVal !== undefined && newVal !== ''
})
}
function getAnnotationPreview(change) {
const data = change.afterJson || change.after || {}
const type = data.type
if (!type) return null
if (type === 'sticker') return { type, emoji: data.content || '?' }
if (type === 'text') return { type, text: data.content || '' }
if (type === 'drawing') return { type, color: data.style?.color || '#5D524B' }
if (type === 'photo') {
try {
const parsed = typeof data.content === 'string' ? JSON.parse(data.content) : data.content
return { type, url: parsed?.url || null }
} catch { return { type } }
}
return { type }
}
function formatSessionDate(value) {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''
return date.toLocaleString('fr-FR', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
})
}
// ─── component ──────────────────────────────────────────────────────────────
const ContributionPanel = ({
visible,
treeId,
canModerate,
loading,
errorMessage,
inviteShareUrl,
inviteKnownPasswords,
inviteSaving,
inviteError,
inviteMessage,
sessions,
reviewingSessionId,
onClose,
onRefresh,
onRotatePasswords,
onReviewSession,
}) => {
const [decisionMap, setDecisionMap] = useState({})
const [collapsedSessions, setCollapsedSessions] = useState(new Set())
const [confirmApproveAll, setConfirmApproveAll] = useState(null)
const [showVisitorPassword, setShowVisitorPassword] = useState(false)
const [showContributorPassword, setShowContributorPassword] = useState(false)
const [editingVisitorPassword, setEditingVisitorPassword] = useState(false)
const [editingContributorPassword, setEditingContributorPassword] = useState(false)
const [newVisitorPassword, setNewVisitorPassword] = useState('')
const [newContributorPassword, setNewContributorPassword] = useState('')
const [inviteLocalError, setInviteLocalError] = useState('')
const [copyFeedback, setCopyFeedback] = useState('')
// Load persisted decisions from localStorage
useEffect(() => {
if (!treeId) return
try {
const saved = localStorage.getItem(`contrib_decisions_${treeId}`)
if (saved) setDecisionMap(JSON.parse(saved))
} catch { /* ignore */ }
}, [treeId])
// Persist decisions to localStorage on change
useEffect(() => {
if (!treeId) return
try {
localStorage.setItem(`contrib_decisions_${treeId}`, JSON.stringify(decisionMap))
} catch { /* ignore */ }
}, [treeId, decisionMap])
const toggleCollapse = (sessionId) => {
setCollapsedSessions((prev) => {
const next = new Set(prev)
next.has(sessionId) ? next.delete(sessionId) : next.add(sessionId)
return next
})
}
const toggleChangeDecision = (sessionId, changeId, decision) => {
setDecisionMap((current) => {
const prev = current[sessionId]?.[changeId] || ''
const next = prev === decision ? '' : decision
return { ...current, [sessionId]: { ...(current[sessionId] || {}), [changeId]: next } }
})
}
const getChangeDecision = (sessionId, changeId) =>
decisionMap?.[sessionId]?.[changeId] || ''
const buildChangeDecisions = (sessionId) => {
const raw = decisionMap[sessionId] || {}
return Object.entries(raw)
.filter(([, d]) => d === 'approved' || d === 'rejected')
.map(([changeId, decision]) => ({ changeId, decision }))
}
const getSessionSummary = (sessionId, changes) => {
const decisions = decisionMap[sessionId] || {}
let approved = 0, rejected = 0, pending = 0
for (const ch of (changes || [])) {
const d = decisions[ch.id] || ''
if (d === 'approved') approved++
else if (d === 'rejected') rejected++
else pending++
}
return { approved, rejected, pending, total: (changes || []).length }
}
const applyDecisions = (sessionId, changes) => {
const explicit = buildChangeDecisions(sessionId)
const explicitIds = new Set(explicit.map((d) => d.changeId))
const allDecisions = [
...explicit,
...(changes || [])
.filter((ch) => !explicitIds.has(ch.id))
.map((ch) => ({ changeId: ch.id, decision: 'rejected' })),
]
onReviewSession(sessionId, { decision: 'rejected', changeDecisions: allDecisions })
}
useEffect(() => {
if (!visible) return undefined
const handleKeyDown = (e) => { if (e.key === 'Escape') onClose?.() }
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [visible, onClose])
useEffect(() => {
if (!copyFeedback) return undefined
const t = window.setTimeout(() => setCopyFeedback(''), 1800)
return () => window.clearTimeout(t)
}, [copyFeedback])
useEffect(() => {
if (!visible) return
setEditingVisitorPassword(false)
setEditingContributorPassword(false)
setNewVisitorPassword('')
setNewContributorPassword('')
setInviteLocalError('')
}, [inviteKnownPasswords?.contributor, inviteKnownPasswords?.visitor, visible])
const handleCopy = async (value, label) => {
const text = String(value || '').trim()
if (!text) return
try { await navigator.clipboard.writeText(text); setCopyFeedback(label) }
catch { setCopyFeedback('Copie indisponible') }
}
const handleSaveVisitorPassword = async (e) => {
e.preventDefault()
setInviteLocalError('')
if (newVisitorPassword.trim().length < 8) { setInviteLocalError('Minimum 8 caractères.'); return }
if (inviteKnownPasswords?.contributor && newVisitorPassword.trim() === inviteKnownPasswords.contributor) {
setInviteLocalError('Les mots de passe Visiteur et Contributeur doivent être différents.'); return
}
const result = await onRotatePasswords?.({ visitorPassword: newVisitorPassword.trim(), contributorPassword: '' })
if (result?.ok) { setEditingVisitorPassword(false); setNewVisitorPassword('') }
}
const handleSaveContributorPassword = async (e) => {
e.preventDefault()
setInviteLocalError('')
if (newContributorPassword.trim().length < 8) { setInviteLocalError('Minimum 8 caractères.'); return }
if (inviteKnownPasswords?.visitor && newContributorPassword.trim() === inviteKnownPasswords.visitor) {
setInviteLocalError('Les mots de passe Visiteur et Contributeur doivent être différents.'); return
}
const result = await onRotatePasswords?.({ visitorPassword: '', contributorPassword: newContributorPassword.trim() })
if (result?.ok) { setEditingContributorPassword(false); setNewContributorPassword('') }
}
return (
<div
className={`contrib-overlay ${visible ? 'contrib-overlay--open' : ''}`}
onClick={() => visible && onClose?.()}
aria-hidden={!visible}
>
<div
className="contrib-card"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label="Panel contributions"
>
<div className="contrib-header">
<div className="contrib-header-text">
<h1>Contributions</h1>
<p>Validation des propositions de modification</p>
</div>
<button type="button" className="ghost" onClick={onClose}>Fermer</button>
</div>
{canModerate ? (
<div className="contrib-list-section">
{/* ── Section invitation ── */}
<div className="contrib-invite">
<div className="contrib-invite-top">
<strong className="contrib-invite-title">Invitez vos proches à alimenter l'arbre</strong>
{copyFeedback && <span className="contrib-copy-feedback">{copyFeedback}</span>}
</div>
<div className="contrib-invite-link">
<code className="contrib-link-url">{inviteShareUrl || 'Lien indisponible'}</code>
<button type="button" className="contrib-pill-btn contrib-pill-btn--ghost" disabled={!inviteShareUrl} onClick={() => handleCopy(inviteShareUrl, 'Lien copié')}>Copier</button>
</div>
<div className="contrib-invite-passwords">
{/* Visiteur */}
<div className="contrib-role-row">
<span className="contrib-role-badge">Visiteur</span>
{editingVisitorPassword ? (
<form className="contrib-pass-edit-inline" onSubmit={handleSaveVisitorPassword}>
<input className="contrib-pass-edit-input" type="password" value={newVisitorPassword} placeholder="Nouveau MDP (8+ car.)" autoFocus onChange={(e) => setNewVisitorPassword(e.target.value)} />
<button type="submit" className="contrib-pill-btn contrib-pill-btn--save" disabled={inviteSaving}>{inviteSaving ? '...' : 'Enregistrer'}</button>
<button type="button" className="contrib-pill-btn contrib-pill-btn--ghost" onClick={() => { setEditingVisitorPassword(false); setNewVisitorPassword(''); setInviteLocalError('') }}>Annuler</button>
</form>
) : (
<>
<button type="button" className={`contrib-pass-reveal${inviteKnownPasswords?.visitor && !showVisitorPassword ? ' contrib-pass-reveal--blurred' : ''}`} onClick={() => setShowVisitorPassword((v) => !v)} disabled={!inviteKnownPasswords?.visitor} title={showVisitorPassword ? 'Cliquer pour masquer' : 'Cliquer pour révéler'}>{inviteKnownPasswords?.visitor || 'Non défini'}</button>
<button type="button" className="contrib-pill-btn contrib-pill-btn--edit" disabled={inviteSaving} onClick={() => setEditingVisitorPassword(true)}>Éditer</button>
</>
)}
</div>
{/* Contributeur */}
<div className="contrib-role-row">
<span className="contrib-role-badge contrib-role-badge--contrib">Contributeur</span>
{editingContributorPassword ? (
<form className="contrib-pass-edit-inline" onSubmit={handleSaveContributorPassword}>
<input className="contrib-pass-edit-input" type="password" value={newContributorPassword} placeholder="Nouveau MDP (8+ car.)" autoFocus onChange={(e) => setNewContributorPassword(e.target.value)} />
<button type="submit" className="contrib-pill-btn contrib-pill-btn--save" disabled={inviteSaving}>{inviteSaving ? '...' : 'Enregistrer'}</button>
<button type="button" className="contrib-pill-btn contrib-pill-btn--ghost" onClick={() => { setEditingContributorPassword(false); setNewContributorPassword(''); setInviteLocalError('') }}>Annuler</button>
</form>
) : (
<>
<button type="button" className={`contrib-pass-reveal${inviteKnownPasswords?.contributor && !showContributorPassword ? ' contrib-pass-reveal--blurred' : ''}`} onClick={() => setShowContributorPassword((v) => !v)} disabled={!inviteKnownPasswords?.contributor} title={showContributorPassword ? 'Cliquer pour masquer' : 'Cliquer pour révéler'}>{inviteKnownPasswords?.contributor || 'Non défini'}</button>
<button type="button" className="contrib-pill-btn contrib-pill-btn--edit" disabled={inviteSaving} onClick={() => setEditingContributorPassword(true)}>Éditer</button>
</>
)}
</div>
</div>
{(inviteLocalError || inviteError) && <div className="contrib-error">{inviteLocalError || inviteError}</div>}
{inviteMessage && <div className="contrib-ok">{inviteMessage}</div>}
</div>
{/* ── En-tête sessions ── */}
<div className="contrib-list-header">
<div className="contrib-list-heading">
<h2>Propositions en attente</h2>
<p>Approuvez ou rejetez chaque modification individuellement</p>
</div>
<div className="contrib-list-header-actions">
<span className="contrib-count">{sessions.length || 0}</span>
<button type="button" className="ghost" onClick={onRefresh} disabled={loading}>Actualiser</button>
</div>
</div>
{errorMessage && <div className="contrib-error">{errorMessage}</div>}
{loading ? (
<div className="contrib-empty">Chargement...</div>
) : sessions.length === 0 ? (
<div className="contrib-empty">Aucune proposition en attente.</div>
) : (
<div className="contrib-list">
{sessions.map((session) => {
const summary = getSessionSummary(session.id, session.changes)
const isReviewing = reviewingSessionId === session.id
const hasDecisions = summary.approved + summary.rejected > 0
const isCollapsed = collapsedSessions.has(session.id)
return (
<div key={session.id} className="contrib-item contrib-item-stack">
{/* ── En-tête de session (cliquable pour collapse) ── */}
<div
className="contrib-item-main contrib-item-main--clickable"
onClick={() => toggleCollapse(session.id)}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && toggleCollapse(session.id)}
>
<div className="contrib-item-top">
<strong className="contrib-session-title">{session.title || 'Session sans titre'}</strong>
<div className="contrib-session-top-right">
{formatSessionDate(session.createdAt) && (
<span className="contrib-session-date">{formatSessionDate(session.createdAt)}</span>
)}
<ChevronDown
size={16}
strokeWidth={2}
className={`contrib-collapse-icon${isCollapsed ? '' : ' contrib-collapse-icon--up'}`}
/>
</div>
</div>
<div className="contrib-item-meta">
<span className="contrib-meta-pill">par {session.submittedByLabel || 'Contributeur'}</span>
<span className="contrib-meta-sep">·</span>
<span className="contrib-meta-pill">{summary.total} modification{summary.total > 1 ? 's' : ''}</span>
{hasDecisions && (
<>
<span className="contrib-meta-sep">·</span>
{summary.approved > 0 && <span className="contrib-decision-summary contrib-decision-summary--ok">{summary.approved} ✓</span>}
{summary.rejected > 0 && <span className="contrib-decision-summary contrib-decision-summary--ko">{summary.rejected} ✗</span>}
{summary.pending > 0 && <span className="contrib-decision-summary contrib-decision-summary--pending">{summary.pending} en attente</span>}
</>
)}
{summary.pending === 0 && summary.total > 0 && (
<span className="contrib-all-decided-badge">✓ Complet</span>
)}
</div>
{session.comment && (
<p className="contrib-session-comment">« {session.comment} »</p>
)}
</div>
{/* ── Cartes de changement (collapsibles) ── */}
{!isCollapsed && (() => {
// Grouper les changements par entityType
const groupOrder = ['person', 'union', 'parent_child_link', 'annotation']
const groupLabels = { person: 'Personnes', union: 'Unions', parent_child_link: 'Liens', annotation: 'Annotations' }
const groupMap = {}
for (const ch of (session.changes || [])) {
const key = ch.entityType || 'other'
if (!groupMap[key]) groupMap[key] = []
groupMap[key].push(ch)
}
const orderedKeys = [
...groupOrder.filter((k) => groupMap[k]),
...Object.keys(groupMap).filter((k) => !groupOrder.includes(k)),
]
const hasMultipleGroups = orderedKeys.length > 1
const renderChangeCard = (change) => {
const decision = getChangeDecision(session.id, change.id)
const isApproved = decision === 'approved'
const isRejected = decision === 'rejected'
const personName = getPersonDisplayName(change)
const fields = getFieldRows(change)
const annPreview = change.entityType === 'annotation' ? getAnnotationPreview(change) : null
return (
<div
key={change.id}
className={`contrib-change-card${isApproved ? ' contrib-change-card--approved' : ''}${isRejected ? ' contrib-change-card--rejected' : ''}`}
>
<div className="contrib-change-card-head">
<span className={`contrib-action-badge contrib-action-badge--${change.action}`}>
{formatAction(change.action)}
</span>
<span className="contrib-change-entity-name">
{personName || formatEntityType(change.entityType)}
</span>
{change.conflictState === 'needs_review' && (
<span className="contrib-conflict-badge">⚠ Conflit</span>
)}
<div className="contrib-card-decisions">
<button
type="button"
className={`contrib-decision-icon contrib-decision-icon--approve${isApproved ? ' active' : ''}`}
onClick={(e) => { e.stopPropagation(); toggleChangeDecision(session.id, change.id, 'approved') }}
disabled={isReviewing}
title="Approuver"
>
<Check size={13} strokeWidth={2.5} />
</button>
<button
type="button"
className={`contrib-decision-icon contrib-decision-icon--reject${isRejected ? ' active' : ''}`}
onClick={(e) => { e.stopPropagation(); toggleChangeDecision(session.id, change.id, 'rejected') }}
disabled={isReviewing}
title="Rejeter"
>
<X size={13} strokeWidth={2.5} />
</button>
</div>
</div>
{annPreview && (
<div className="contrib-annotation-preview">
{annPreview.type === 'sticker' && <span className="contrib-ann-emoji">{annPreview.emoji}</span>}
{annPreview.type === 'text' && <span className="contrib-ann-text">«&nbsp;{(annPreview.text || '').slice(0, 120)}&nbsp;»</span>}
{annPreview.type === 'drawing' && (
<span className="contrib-ann-drawing" style={{ borderLeftColor: annPreview.color }}>Dessin libre</span>
)}
{annPreview.type === 'photo' && annPreview.url && (
<img src={annPreview.url} className="contrib-ann-photo" alt="Photo canvas" onError={(e) => { e.currentTarget.style.display = 'none' }} />
)}
{annPreview.type === 'photo' && !annPreview.url && (
<span className="contrib-ann-text">Photo (aperçu indisponible)</span>
)}
</div>
)}
{fields.length > 0 && (
<div className="contrib-change-fields">
{fields.map(({ key, label, oldVal, newVal }) => (
<div key={key} className="contrib-field-row">
<span className="contrib-field-label">{label}</span>
{oldVal !== undefined ? (
<span className="contrib-field-diff">
<span className="contrib-field-old">{displayVal(oldVal) ?? '—'}</span>
<span className="contrib-field-arrow">→</span>
<span className="contrib-field-new">{displayVal(newVal) ?? '—'}</span>
</span>
) : (
<span className="contrib-field-new">{displayVal(newVal) ?? '—'}</span>
)}
</div>
))}
</div>
)}
</div>
)
}
return (
<>
<div className="contrib-change-list">
{orderedKeys.map((groupKey) => (
<div key={groupKey} className="contrib-change-group">
{hasMultipleGroups && (
<div className="contrib-group-header">{groupLabels[groupKey] || groupKey}</div>
)}
{groupMap[groupKey].map(renderChangeCard)}
</div>
))}
</div>
{/* ── Actions de session ── */}
<div className="contrib-session-footer">
{hasDecisions ? (
<>
<button
type="button"
className="contrib-apply-btn"
onClick={() => applyDecisions(session.id, session.changes)}
disabled={isReviewing}
>
{isReviewing ? 'Traitement...' : `Appliquer les décisions${summary.pending > 0 ? ` (${summary.pending} non décidée${summary.pending > 1 ? 's' : ''} → rejetée${summary.pending > 1 ? 's' : ''})` : ''}`}
</button>
<div className="contrib-session-quick">
{confirmApproveAll === session.id ? (
<div className="contrib-confirm-approve">
<span>Tout approuver ?</span>
<button type="button" className="contrib-confirm-yes" onClick={() => { setConfirmApproveAll(null); onReviewSession(session.id, { decision: 'approved' }) }} disabled={isReviewing}>Oui</button>
<button type="button" className="contrib-confirm-no" onClick={() => setConfirmApproveAll(null)}>Non</button>
</div>
) : (
<button type="button" className="contrib-quick-btn" onClick={() => setConfirmApproveAll(session.id)} disabled={isReviewing}>Tout approuver</button>
)}
<button type="button" className="contrib-quick-btn" onClick={() => onReviewSession(session.id, { decision: 'rejected' })} disabled={isReviewing}>Tout rejeter</button>
</div>
</>
) : (
<div className="contrib-session-quick contrib-session-quick--full">
{confirmApproveAll === session.id ? (
<div className="contrib-confirm-approve">
<span>Confirmer l'approbation de toutes les modifications ?</span>
<button type="button" className="contrib-confirm-yes" onClick={() => { setConfirmApproveAll(null); onReviewSession(session.id, { decision: 'approved' }) }} disabled={isReviewing}>Oui, tout approuver</button>
<button type="button" className="contrib-confirm-no" onClick={() => setConfirmApproveAll(null)}>Annuler</button>
</div>
) : (
<button
type="button"
className="contrib-apply-btn contrib-apply-btn--approve"
onClick={() => setConfirmApproveAll(session.id)}
disabled={isReviewing}
>
{isReviewing ? 'Traitement...' : 'Tout approuver'}
</button>
)}
<button type="button" className="contrib-quick-btn" onClick={() => onReviewSession(session.id, { decision: 'rejected' })} disabled={isReviewing}>Tout rejeter</button>
</div>
)}
</div>
</>
)
})()}
</div>
)
})}
</div>
)}
</div>
) : (
<div className="contrib-readonly">
Ce panneau est réservé à l'administration de l'arbre.
</div>
)}
</div>
</div>
)
}
export default ContributionPanel
@@ -0,0 +1,337 @@
/* ============================================================
Contribution Session Modal - Finalisation contribution
============================================================ */
.contrib-session-backdrop {
position: fixed;
inset: 0;
z-index: 2000;
background: rgba(30, 25, 20, 0.55);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
display: flex;
align-items: center;
justify-content: center;
animation: contribFadeIn 0.2s ease;
}
@keyframes contribFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.contrib-session-modal {
position: relative;
width: 440px;
max-width: calc(100vw - 32px);
max-height: calc(100vh - 64px);
overflow-y: auto;
padding: 32px 28px 24px;
background: rgba(254, 249, 237, 0.97);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border-radius: 20px;
border: 1px solid rgba(166, 124, 82, 0.2);
box-shadow:
0 20px 60px rgba(93, 82, 75, 0.25),
0 4px 16px rgba(93, 82, 75, 0.1);
animation: contribSlideUp 0.3s cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes contribSlideUp {
from {
opacity: 0;
transform: translateY(16px) scale(0.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.contrib-session-close {
position: absolute;
top: 16px;
right: 16px;
width: 32px;
height: 32px;
border-radius: 50%;
border: none;
background: transparent;
color: #8B7355;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease;
}
.contrib-session-close:hover {
background: rgba(93, 82, 75, 0.1);
color: #5D524B;
}
.contrib-session-header {
text-align: center;
margin-bottom: 20px;
}
.contrib-session-icon {
color: #FF9500;
margin-bottom: 12px;
}
.contrib-session-title {
font-family: 'Red Hat Mono', ui-monospace, monospace;
font-size: 18px;
font-weight: 700;
color: #5D524B;
margin: 0 0 6px;
}
.contrib-session-subtitle {
font-family: 'Red Hat Mono', ui-monospace, monospace;
font-size: 14px;
color: #8B7355;
margin: 0;
line-height: 1.4;
}
/* Récap modifications */
.contrib-session-recap {
display: flex;
justify-content: center;
gap: 8px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.contrib-recap-tag {
padding: 4px 10px;
border-radius: 12px;
font-family: 'Red Hat Mono', ui-monospace, monospace;
font-size: 14px;
font-weight: 700;
letter-spacing: 0.3px;
}
.contrib-recap-tag--added {
background: rgba(82, 178, 120, 0.12);
color: #3D9A5E;
}
.contrib-recap-tag--modified {
background: rgba(255, 149, 0, 0.12);
color: #E08600;
}
.contrib-recap-tag--deleted {
background: rgba(192, 57, 43, 0.1);
color: #C0392B;
}
/* Liste des changements */
.contrib-session-changes {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 20px;
max-height: 180px;
overflow-y: auto;
padding: 10px 12px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.5);
border: 1px solid rgba(166, 124, 82, 0.15);
}
.contrib-session-change-row {
display: flex;
align-items: center;
gap: 8px;
font-family: 'Red Hat Mono', ui-monospace, monospace;
font-size: 13px;
}
.contrib-session-change-badge {
padding: 2px 7px;
border-radius: 8px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.3px;
flex-shrink: 0;
}
.contrib-session-change-badge--create {
background: rgba(82, 178, 120, 0.15);
color: #3D9A5E;
}
.contrib-session-change-badge--update {
background: rgba(255, 149, 0, 0.12);
color: #E08600;
}
.contrib-session-change-badge--delete {
background: rgba(192, 57, 43, 0.1);
color: #C0392B;
}
.contrib-session-change-label {
color: #5D524B;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Form */
.contrib-session-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.contrib-session-field {
display: flex;
flex-direction: column;
gap: 6px;
position: relative;
}
.contrib-session-label {
font-family: 'Red Hat Mono', ui-monospace, monospace;
font-size: 14px;
font-weight: 450;
color: #8B7355;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.contrib-session-optional {
font-weight: 400;
text-transform: none;
letter-spacing: 0;
color: #B8A898;
}
.contrib-session-input {
padding: 10px 14px;
border-radius: 10px;
border: 1.5px solid rgba(166, 124, 82, 0.2);
background: rgba(255, 255, 255, 0.6);
font-family: 'Red Hat Mono', ui-monospace, monospace;
font-size: 14px;
color: #5D524B;
transition: all 0.2s ease;
outline: none;
}
.contrib-session-input:focus {
border-color: #A67C52;
box-shadow: 0 0 0 3px rgba(166, 124, 82, 0.12);
}
.contrib-session-input::placeholder {
color: #B8A898;
}
.contrib-session-textarea {
padding: 10px 14px;
border-radius: 10px;
border: 1.5px solid rgba(166, 124, 82, 0.2);
background: rgba(255, 255, 255, 0.6);
font-family: 'Red Hat Mono', ui-monospace, monospace;
font-size: 14px;
color: #5D524B;
transition: all 0.2s ease;
outline: none;
resize: vertical;
min-height: 60px;
}
.contrib-session-textarea:focus {
border-color: #A67C52;
box-shadow: 0 0 0 3px rgba(166, 124, 82, 0.12);
}
.contrib-session-textarea::placeholder {
color: #B8A898;
}
.contrib-session-count {
position: absolute;
bottom: 8px;
right: 12px;
font-family: 'Red Hat Mono', ui-monospace, monospace;
font-size: 12px;
color: #B8A898;
}
.contrib-session-error {
padding: 10px 14px;
border-radius: 10px;
background: rgba(192, 57, 43, 0.08);
border: 1px solid rgba(192, 57, 43, 0.2);
color: #C0392B;
font-family: 'Red Hat Mono', ui-monospace, monospace;
font-size: 14px;
}
/* Actions */
.contrib-session-actions {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 4px;
}
.contrib-session-submit {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 20px;
border-radius: 12px;
border: none;
background: #FF9500;
color: white;
font-family: 'Red Hat Mono', ui-monospace, monospace;
font-size: 14px;
font-weight: 700;
cursor: pointer;
transition: all 0.2s ease;
}
.contrib-session-submit:hover:not(:disabled) {
background: #E08600;
box-shadow: 0 4px 16px rgba(255, 149, 0, 0.3);
}
.contrib-session-submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.contrib-session-cancel {
padding: 10px 20px;
border-radius: 12px;
border: none;
background: transparent;
color: #8B7355;
font-family: 'Red Hat Mono', ui-monospace, monospace;
font-size: 14px;
font-weight: 450;
cursor: pointer;
transition: all 0.15s ease;
}
.contrib-session-cancel:hover {
background: rgba(93, 82, 75, 0.08);
color: #5D524B;
}
@media (max-width: 768px) {
.contrib-session-modal {
margin: 16px;
padding: 24px 20px 20px;
}
}
@@ -0,0 +1,164 @@
import { useState, useEffect } from 'react'
import { Send, X } from 'lucide-react'
import './ContributionSessionModal.css'
function formatAction(action) {
if (action === 'create') return 'Ajout'
if (action === 'update') return 'Modification'
if (action === 'delete') return 'Suppression'
return 'Changement'
}
/**
* Modal de finalisation de session contribution
* Pour les contributeurs : commentaire optionnel + récap + liste des changements
*
* @param {Object} props
* @param {boolean} props.visible - Afficher la modal
* @param {Array} props.changes - Liste des changements { entityType, action, label }
* @param {Object} props.recap - Récap des modifications { added, modified, deleted }
* @param {boolean} props.loading - Soumission en cours
* @param {string} props.error - Message d'erreur
* @param {Function} props.onSubmit - Handler soumission ({ comment })
* @param {Function} props.onCancel - Handler annulation
*/
function ContributionSessionModal({ visible, changes = [], recap = {}, loading = false, error = '', onSubmit, onCancel }) {
const [comment, setComment] = useState('')
// Reset à l'ouverture
useEffect(() => {
if (visible) {
setComment('')
}
}, [visible])
// ESC pour fermer
useEffect(() => {
if (!visible) return
function handleKeyDown(e) {
if (e.key === 'Escape') onCancel()
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [visible, onCancel])
const handleSubmit = (e) => {
e.preventDefault()
onSubmit({ comment: comment.trim() })
}
const totalChanges = (recap.added || 0) + (recap.modified || 0) + (recap.deleted || 0)
if (!visible) return null
return (
<div className="contrib-session-backdrop" onClick={onCancel}>
<div
className="contrib-session-modal"
role="dialog"
aria-modal="true"
aria-labelledby="contrib-session-title"
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
className="contrib-session-close"
onClick={onCancel}
aria-label="Fermer"
>
<X size={18} strokeWidth={2} />
</button>
<div className="contrib-session-header">
<Send size={24} strokeWidth={1.8} className="contrib-session-icon" />
<h2 id="contrib-session-title" className="contrib-session-title">
Finaliser votre contribution
</h2>
<p className="contrib-session-subtitle">
Vos modifications seront soumises à validation par le propriétaire de l'arbre.
</p>
</div>
{/* Récap modifications */}
{totalChanges > 0 && (
<div className="contrib-session-recap">
{recap.added > 0 && (
<span className="contrib-recap-tag contrib-recap-tag--added">
{recap.added} ajouté{recap.added > 1 ? 's' : ''}
</span>
)}
{recap.modified > 0 && (
<span className="contrib-recap-tag contrib-recap-tag--modified">
{recap.modified} modifié{recap.modified > 1 ? 's' : ''}
</span>
)}
{recap.deleted > 0 && (
<span className="contrib-recap-tag contrib-recap-tag--deleted">
{recap.deleted} supprimé{recap.deleted > 1 ? 's' : ''}
</span>
)}
</div>
)}
{/* Liste détaillée des changements */}
{changes.length > 0 && (
<div className="contrib-session-changes">
{changes.map((ch, i) => (
<div key={i} className="contrib-session-change-row">
<span className={`contrib-session-change-badge contrib-session-change-badge--${ch.action}`}>
{formatAction(ch.action)}
</span>
<span className="contrib-session-change-label">{ch.label}</span>
</div>
))}
</div>
)}
<form className="contrib-session-form" onSubmit={handleSubmit}>
<div className="contrib-session-field">
<label htmlFor="cs-comment" className="contrib-session-label">
Message <span className="contrib-session-optional">(optionnel)</span>
</label>
<textarea
id="cs-comment"
className="contrib-session-textarea"
value={comment}
onChange={(e) => setComment(e.target.value.slice(0, 500))}
placeholder="Ajoutez un mot pour le propriétaire de l'arbre..."
rows={3}
maxLength={500}
/>
<span className="contrib-session-count">{comment.length}/500</span>
</div>
{error && (
<div className="contrib-session-error" role="alert">{error}</div>
)}
<div className="contrib-session-actions">
<button
type="submit"
className="contrib-session-submit"
disabled={loading}
>
<Send size={16} strokeWidth={2} />
{loading ? 'Envoi en cours...' : 'Envoyer mes contributions'}
</button>
<button
type="button"
className="contrib-session-cancel"
onClick={onCancel}
disabled={loading}
>
Annuler
</button>
</div>
</form>
</div>
</div>
)
}
export default ContributionSessionModal
@@ -0,0 +1,261 @@
/* ============================================================
CreateMyTreeButton — Pill + Panel morph, bas-droite
Même langage visuel que la ContextualNavbar
============================================================ */
/* ----------------------------------------------------------
Pill bouton (état fermé) — même aesthetic que la navbar
---------------------------------------------------------- */
.cmt-pill {
position: fixed;
bottom: 100px;
right: 20px;
z-index: 1001;
display: flex;
align-items: center;
padding: 13px 22px;
border: 1px solid rgba(93, 82, 75, 0.1);
border-radius: 50px;
background: rgba(247, 236, 217, 0.97);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow:
0 4px 40px rgba(93, 82, 75, 0.22),
0 1px 6px rgba(93, 82, 75, 0.1);
font-family: 'Red Hat Mono', ui-monospace, monospace;
font-size: 14px;
font-weight: 500;
color: #5D524B;
cursor: pointer;
white-space: nowrap;
animation: cmtPillIn 0.5s cubic-bezier(0.22, 1, 0.36, 1);
transition: box-shadow 0.2s ease, transform 0.2s ease, background 0.2s ease;
}
@keyframes cmtPillIn {
from {
opacity: 0;
transform: translateY(12px) scale(0.9);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.cmt-pill:hover {
background: rgba(247, 236, 217, 1);
box-shadow:
0 8px 48px rgba(93, 82, 75, 0.28),
0 2px 8px rgba(93, 82, 75, 0.12);
transform: translateY(-2px);
}
.cmt-pill:active {
transform: translateY(0) scale(0.97);
}
/* ----------------------------------------------------------
Panel (état ouvert) — card qui morphe depuis la pill
---------------------------------------------------------- */
.cmt-panel {
position: fixed;
bottom: 100px;
right: 20px;
z-index: 1001;
width: 300px;
background: rgba(254, 249, 237, 0.97);
backdrop-filter: blur(28px);
-webkit-backdrop-filter: blur(28px);
border-radius: 20px;
border: 1px solid rgba(166, 124, 82, 0.18);
box-shadow:
0 12px 48px rgba(93, 82, 75, 0.22),
0 2px 10px rgba(93, 82, 75, 0.1);
transform-origin: bottom right;
animation: cmtMorphIn 0.38s cubic-bezier(0.22, 1, 0.36, 1);
overflow: hidden;
}
@keyframes cmtMorphIn {
0% {
opacity: 0;
transform: scale(0.55) translateY(8px);
border-radius: 50px;
}
60% {
border-radius: 22px;
}
100% {
opacity: 1;
transform: scale(1) translateY(0);
border-radius: 20px;
}
}
/* Header */
.cmt-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 18px 12px;
border-bottom: 1px solid rgba(166, 124, 82, 0.1);
}
.cmt-panel-title {
font-family: 'Red Hat Mono', ui-monospace, monospace;
font-size: 13px;
font-weight: 600;
color: #5D524B;
line-height: 1.3;
}
.cmt-panel-close {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 50%;
background: rgba(93, 82, 75, 0.07);
color: #8B7355;
font-size: 12px;
cursor: pointer;
flex-shrink: 0;
transition: background 0.15s, color 0.15s;
}
.cmt-panel-close:hover {
background: rgba(93, 82, 75, 0.13);
color: #5D524B;
}
/* Body */
.cmt-panel-body {
padding: 16px 18px 18px;
}
.cmt-label {
display: block;
font-family: 'Red Hat Mono', ui-monospace, monospace;
font-size: 12px;
color: #8B7355;
margin-bottom: 7px;
letter-spacing: 0.02em;
}
.cmt-input {
width: 100%;
box-sizing: border-box;
border-radius: 12px;
border: 1px solid rgba(166, 124, 82, 0.25);
background: rgba(255, 253, 248, 0.9);
color: #453d37;
padding: 10px 13px;
font-family: 'Red Hat Mono', ui-monospace, monospace;
font-size: 14px;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
}
.cmt-input:focus {
border-color: #b99762;
box-shadow: 0 0 0 3px rgba(185, 151, 98, 0.12);
}
.cmt-slug-preview {
margin-top: 8px;
font-family: 'Red Hat Mono', ui-monospace, monospace;
font-size: 11px;
color: #A08060;
}
.cmt-slug-preview code {
background: rgba(166, 124, 82, 0.1);
border-radius: 4px;
padding: 1px 5px;
color: #6a4f32;
}
.cmt-error {
margin-top: 8px;
background: rgba(171, 49, 49, 0.08);
border: 1px solid rgba(171, 49, 49, 0.25);
color: #862f2f;
border-radius: 10px;
padding: 9px 12px;
font-family: 'Red Hat Mono', ui-monospace, monospace;
font-size: 12px;
}
.cmt-btn-create {
margin-top: 14px;
width: 100%;
padding: 11px 16px;
border-radius: 12px;
border: none;
background: linear-gradient(135deg, #8b6a43, #6a4f32);
color: #fef9ed;
font-family: 'Red Hat Mono', ui-monospace, monospace;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, box-shadow 0.15s, transform 0.15s;
}
.cmt-btn-create:hover:not(:disabled) {
background: linear-gradient(135deg, #9c7a52, #7a5b3a);
box-shadow: 0 4px 14px rgba(166, 124, 82, 0.32);
transform: translateY(-1px);
}
.cmt-btn-create:active:not(:disabled) {
transform: translateY(0);
}
.cmt-btn-create:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ----------------------------------------------------------
Mobile — bottom sheet
---------------------------------------------------------- */
@media (max-width: 768px) {
.cmt-pill {
bottom: 84px;
right: 12px;
font-size: 13px;
padding: 11px 18px;
}
.cmt-panel {
bottom: 0;
right: 0;
left: 0;
width: 100%;
border-radius: 20px 20px 0 0;
transform-origin: bottom center;
}
@keyframes cmtMorphIn {
from {
opacity: 0;
transform: translateY(100%);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.cmt-panel-body {
padding: 16px 20px 32px;
}
}
@@ -0,0 +1,91 @@
import { useState, useMemo } from 'react'
import './CreateMyTreeButton.css'
function normalizeSlug(input) {
return input
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
const CreateMyTreeButton = ({ onCreateTree, loading }) => {
const [open, setOpen] = useState(false)
const [name, setName] = useState('')
const [error, setError] = useState('')
const slug = useMemo(() => normalizeSlug(name.trim()), [name])
const canSubmit = name.trim().length >= 2 && slug.length >= 3 && !loading
const handleOpen = () => {
setError('')
setOpen(true)
}
const handleClose = () => {
setOpen(false)
setName('')
setError('')
}
const handleSubmit = async (e) => {
e.preventDefault()
if (!canSubmit) return
setError('')
const result = await onCreateTree(name.trim())
if (result !== null && result !== undefined) {
setError(typeof result === 'string' ? result : 'Erreur lors de la création. Essayez un autre nom.')
}
}
return (
<>
{!open && (
<button className="cmt-pill" type="button" onClick={handleOpen}>
Créer mon arbre
</button>
)}
{open && (
<div className="cmt-panel">
<div className="cmt-panel-header">
<span className="cmt-panel-title">Créer votre arbre dès maintenant</span>
<button type="button" className="cmt-panel-close" onClick={handleClose} aria-label="Fermer">
✕
</button>
</div>
<div className="cmt-panel-body">
<form onSubmit={handleSubmit}>
<label className="cmt-label" htmlFor="firstTreeName">
Nom de la famille
</label>
<input
id="firstTreeName"
className="cmt-input"
type="text"
autoFocus
maxLength={120}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Ex : Famille Martin"
required
/>
{slug && (
<div className="cmt-slug-preview">
Adresse : <code>{slug}</code>
</div>
)}
{error && <div className="cmt-error">{error}</div>}
<button type="submit" className="cmt-btn-create" disabled={!canSubmit}>
{loading ? 'Création…' : 'Créer l\'arbre'}
</button>
</form>
</div>
</div>
)}
</>
)
}
export default CreateMyTreeButton
@@ -0,0 +1,127 @@
/* ============================================================
Draft Restore Banner - Restauration brouillon au reload
============================================================ */
.draft-restore-banner {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 1002;
animation: draftBannerSlideDown 0.5s cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes draftBannerSlideDown {
from {
opacity: 0;
transform: translateX(-50%) translateY(-24px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
.draft-restore-content {
display: flex;
align-items: center;
gap: 14px;
padding: 12px 18px;
background: rgba(254, 249, 237, 0.97);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border-radius: 50px;
border: 1px solid rgba(166, 124, 82, 0.25);
box-shadow:
0 8px 28px rgba(93, 82, 75, 0.18),
0 2px 8px rgba(93, 82, 75, 0.08);
}
.draft-restore-text {
font-family: 'Red Hat Mono', ui-monospace, monospace;
font-size: 14px;
font-weight: 450;
color: #5D524B;
white-space: nowrap;
}
.draft-restore-time {
font-weight: 400;
color: #8B7355;
}
.draft-restore-actions {
display: flex;
align-items: center;
gap: 6px;
}
.draft-restore-btn {
display: flex;
align-items: center;
gap: 5px;
padding: 6px 12px;
border-radius: 20px;
border: none;
font-family: 'Red Hat Mono', ui-monospace, monospace;
font-size: 14px;
font-weight: 450;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
}
.draft-restore-btn:focus-visible {
outline: 2px solid #A67C52;
outline-offset: 2px;
}
/* Restaurer - primary */
.draft-restore-btn--restore {
background: #A67C52;
color: #FEF9ED;
}
.draft-restore-btn--restore:hover {
background: #96703E;
box-shadow: 0 2px 8px rgba(166, 124, 82, 0.3);
}
/* Ignorer - ghost */
.draft-restore-btn--discard {
background: rgba(93, 82, 75, 0.08);
color: #8B7355;
}
.draft-restore-btn--discard:hover {
background: rgba(93, 82, 75, 0.15);
color: #5D524B;
}
@media (max-width: 768px) {
.draft-restore-banner {
top: 12px;
left: 12px;
right: 12px;
transform: none;
}
@keyframes draftBannerSlideDown {
from {
opacity: 0;
transform: translateY(-24px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.draft-restore-content {
flex-wrap: wrap;
justify-content: center;
border-radius: 16px;
padding: 10px 14px;
gap: 8px;
}
}
@@ -0,0 +1,71 @@
import { useMemo } from 'react'
import { RotateCcw, Trash2 } from 'lucide-react'
import './DraftRestoreBanner.css'
/**
* Formatage relatif du timestamp
*/
function formatRelativeTime(timestamp) {
if (!timestamp) return ''
const diff = Date.now() - timestamp
const minutes = Math.floor(diff / 60000)
const hours = Math.floor(diff / 3600000)
const days = Math.floor(diff / 86400000)
if (minutes < 1) return "il y a quelques secondes"
if (minutes < 60) return `il y a ${minutes} minute${minutes > 1 ? 's' : ''}`
if (hours < 24) return `il y a ${hours} heure${hours > 1 ? 's' : ''}`
return `il y a ${days} jour${days > 1 ? 's' : ''}`
}
/**
* Banner de restauration de brouillon
* Apparaît au chargement si un draft < 7 jours existe dans localStorage
*
* @param {Object} props
* @param {boolean} props.visible - Afficher le banner
* @param {number} props.draftTimestamp - Timestamp du dernier save
* @param {Function} props.onRestore - Handler restauration
* @param {Function} props.onDiscard - Handler suppression
*/
function DraftRestoreBanner({ visible, draftTimestamp, onRestore, onDiscard }) {
const timeAgo = useMemo(() => {
return formatRelativeTime(draftTimestamp)
}, [draftTimestamp])
if (!visible) return null
return (
<div className="draft-restore-banner" role="alert" aria-live="polite">
<div className="draft-restore-content">
<span className="draft-restore-text">
Un brouillon a été trouvé
{timeAgo && <span className="draft-restore-time"> ({timeAgo})</span>}
</span>
<div className="draft-restore-actions">
<button
type="button"
className="draft-restore-btn draft-restore-btn--restore"
onClick={onRestore}
>
<RotateCcw size={14} strokeWidth={2} />
Restaurer
</button>
<button
type="button"
className="draft-restore-btn draft-restore-btn--discard"
onClick={onDiscard}
>
<Trash2 size={14} strokeWidth={2} />
Ignorer
</button>
</div>
</div>
</div>
)
}
export default DraftRestoreBanner
@@ -0,0 +1,142 @@
/* ============================================================
Edit Confirm Modal - Confirmation désactivation mode édition
============================================================ */
.edit-confirm-backdrop {
position: fixed;
inset: 0;
z-index: 2000;
background: rgba(30, 25, 20, 0.55);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
display: flex;
align-items: center;
justify-content: center;
animation: confirmFadeIn 0.2s ease;
}
@keyframes confirmFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.edit-confirm-modal {
width: 380px;
max-width: calc(100vw - 32px);
padding: 32px 28px 24px;
background: rgba(254, 249, 237, 0.97);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border-radius: 20px;
border: 1px solid rgba(166, 124, 82, 0.2);
box-shadow:
0 20px 60px rgba(93, 82, 75, 0.25),
0 4px 16px rgba(93, 82, 75, 0.1);
text-align: center;
animation: confirmSlideUp 0.3s cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes confirmSlideUp {
from {
opacity: 0;
transform: translateY(16px) scale(0.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.edit-confirm-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
border-radius: 50%;
background: rgba(255, 149, 0, 0.1);
color: #E08600;
margin-bottom: 16px;
}
.edit-confirm-title {
font-family: 'Red Hat Mono', ui-monospace, monospace;
font-size: 18px;
font-weight: 700;
color: #5D524B;
margin: 0 0 8px;
}
.edit-confirm-message {
font-family: 'Red Hat Mono', ui-monospace, monospace;
font-size: 14px;
color: #8B7355;
margin: 0 0 24px;
line-height: 1.5;
}
.edit-confirm-actions {
display: flex;
flex-direction: column;
gap: 8px;
}
.edit-confirm-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 20px;
border-radius: 12px;
border: none;
font-family: 'Red Hat Mono', ui-monospace, monospace;
font-size: 14px;
font-weight: 450;
cursor: pointer;
transition: all 0.2s ease;
}
.edit-confirm-btn:focus-visible {
outline: 2px solid #A67C52;
outline-offset: 2px;
}
/* Continuer l'édition - primary outline */
.edit-confirm-btn--continue {
background: rgba(166, 124, 82, 0.08);
color: #A67C52;
border: 1.5px solid rgba(166, 124, 82, 0.25);
}
.edit-confirm-btn--continue:hover {
background: rgba(166, 124, 82, 0.15);
border-color: rgba(166, 124, 82, 0.4);
}
/* Enregistrer et quitter - primary solid */
.edit-confirm-btn--save {
background: #A67C52;
color: #FEF9ED;
}
.edit-confirm-btn--save:hover {
background: #96703E;
box-shadow: 0 4px 12px rgba(166, 124, 82, 0.3);
}
/* Abandonner - danger text */
.edit-confirm-btn--discard {
background: transparent;
color: #C0392B;
font-weight: 500;
}
.edit-confirm-btn--discard:hover {
background: rgba(192, 57, 43, 0.08);
}
@media (max-width: 768px) {
.edit-confirm-modal {
padding: 24px 20px 20px;
}
}
@@ -0,0 +1,90 @@
import { useEffect, useRef } from 'react'
import { AlertTriangle, Save, X, ArrowLeft } from 'lucide-react'
import './EditConfirmModal.css'
/**
* Modal de confirmation à la désactivation du mode édition
* Apparaît si des modifications non-sauvegardées existent
*
* 3 options :
* - Continuer l'édition (retour)
* - Enregistrer brouillon et quitter
* - Abandonner les modifications
*/
function EditConfirmModal({ visible, onContinue, onSaveAndQuit, onDiscard }) {
const continueRef = useRef(null)
// Focus sur "Continuer" à l'ouverture + trap ESC
useEffect(() => {
if (!visible) return
continueRef.current?.focus()
function handleKeyDown(e) {
if (e.key === 'Escape') {
onContinue()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [visible, onContinue])
if (!visible) return null
return (
<div className="edit-confirm-backdrop" onClick={onContinue}>
<div
className="edit-confirm-modal"
role="dialog"
aria-modal="true"
aria-labelledby="edit-confirm-title"
onClick={(e) => e.stopPropagation()}
>
<div className="edit-confirm-icon">
<AlertTriangle size={28} strokeWidth={1.8} />
</div>
<h2 id="edit-confirm-title" className="edit-confirm-title">
Quitter le mode édition ?
</h2>
<p className="edit-confirm-message">
Vous avez des modifications non enregistrées sur le serveur.
</p>
<div className="edit-confirm-actions">
<button
ref={continueRef}
type="button"
className="edit-confirm-btn edit-confirm-btn--continue"
onClick={onContinue}
>
<ArrowLeft size={16} strokeWidth={2} />
Continuer l'édition
</button>
<button
type="button"
className="edit-confirm-btn edit-confirm-btn--save"
onClick={onSaveAndQuit}
>
<Save size={16} strokeWidth={2} />
Enregistrer et quitter
</button>
<button
type="button"
className="edit-confirm-btn edit-confirm-btn--discard"
onClick={onDiscard}
>
<X size={16} strokeWidth={2} />
Abandonner
</button>
</div>
</div>
</div>
)
}
export default EditConfirmModal
+137
View File
@@ -0,0 +1,137 @@
.edit-mode-border {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 999;
border: 2px solid transparent;
}
.edit-mode-border--contributor {
border-color: rgba(255, 149, 0, 0.58);
box-shadow:
inset 0 0 32px rgba(255, 149, 0, 0.10),
0 0 32px rgba(255, 149, 0, 0.14);
animation: editPulseContributor 3s ease-in-out infinite;
}
.edit-mode-border--admin {
border-color: rgba(82, 178, 120, 0.52);
box-shadow:
inset 0 0 32px rgba(82, 178, 120, 0.10),
0 0 32px rgba(82, 178, 120, 0.13);
animation: editPulseAdmin 3s ease-in-out infinite;
}
@keyframes editPulseContributor {
0%, 100% {
border-color: rgba(255, 149, 0, 0.58);
}
50% {
border-color: rgba(255, 149, 0, 0.72);
}
}
@keyframes editPulseAdmin {
0%, 100% {
border-color: rgba(82, 178, 120, 0.52);
}
50% {
border-color: rgba(82, 178, 120, 0.68);
}
}
.edit-mode-toast {
position: fixed;
top: 16px;
left: 50%;
transform: translateX(-50%);
z-index: 1001;
display: flex;
align-items: center;
gap: 10px;
padding: 9px 12px;
border-radius: 999px;
border: 1px solid rgba(255, 149, 0, 0.28);
background: rgba(247, 236, 217, 0.96);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: 0 6px 20px rgba(93, 82, 75, 0.12);
}
.edit-mode-toast--admin {
border-color: rgba(82, 178, 120, 0.3);
}
.edit-mode-toast-text {
font-family: 'Red Hat Mono', ui-monospace, monospace;
font-size: 14px;
font-weight: 450;
color: #5D524B;
white-space: nowrap;
}
.edit-mode-submit {
border: none;
border-radius: 999px;
padding: 6px 12px;
font-family: 'Red Hat Mono', ui-monospace, monospace;
font-size: 14px;
font-weight: 700;
color: #FEF9ED;
background: #A67C52;
cursor: pointer;
transition: background 0.2s ease, opacity 0.2s ease;
}
.edit-mode-submit:hover:not(:disabled) {
background: #8E6840;
}
.edit-mode-submit:disabled {
opacity: 0.45;
cursor: default;
}
.edit-mode-submit--admin {
background: #52B278;
}
.edit-mode-submit--admin:hover:not(:disabled) {
background: #439A66;
}
.edit-mode-toast-close {
width: 24px;
height: 24px;
border: none;
border-radius: 999px;
background: transparent;
color: #7C6B5C;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.edit-mode-toast-close:hover {
background: rgba(93, 82, 75, 0.12);
color: #5D524B;
}
@media (max-width: 768px) {
.edit-mode-toast {
left: 12px;
right: 12px;
transform: none;
border-radius: 12px;
justify-content: space-between;
gap: 8px;
}
.edit-mode-toast-text {
min-width: 0;
white-space: normal;
}
}
@@ -0,0 +1,49 @@
import { useMemo } from 'react'
import { X } from 'lucide-react'
import './EditModeOverlay.css'
function EditModeOverlay({ active, hasDraft, role = 'contributor', onDeactivate, onSubmit }) {
const isAdmin = role === 'admin'
const borderClass = useMemo(() => (
`edit-mode-border ${isAdmin ? 'edit-mode-border--admin' : 'edit-mode-border--contributor'}`
), [isAdmin])
const toastLabel = isAdmin ? 'Mode edition actif' : 'Mode contribution actif'
const submitLabel = isAdmin ? 'Mettre a jour l\'arbre' : 'Envoyer les modifications'
if (!active) return null
return (
<>
<div className={borderClass} aria-hidden="true" />
<div className={`edit-mode-toast ${isAdmin ? 'edit-mode-toast--admin' : ''}`} role="status" aria-live="polite">
<span className="edit-mode-toast-text">{toastLabel}</span>
{onSubmit && (
<button
type="button"
className={`edit-mode-submit ${isAdmin ? 'edit-mode-submit--admin' : ''}`}
onClick={onSubmit}
disabled={!hasDraft}
title={hasDraft ? submitLabel : 'Aucune modification a soumettre'}
>
{submitLabel}
</button>
)}
<button
type="button"
className="edit-mode-toast-close"
onClick={onDeactivate}
aria-label="Quitter le mode edition"
>
<X size={16} strokeWidth={2} />
</button>
</div>
</>
)
}
export default EditModeOverlay
Loaded 100 of 244 files, more files were not shown because too many files have changed in this diff. Show more