mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-06 23:32:08 +00:00
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:
commit
e8e2485dcd
244 files changed
+54104
No files matched your search
@@ -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
|
||||||
@@ -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=
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Auto detect text files and perform LF normalization
|
||||||
|
* text=auto
|
||||||
@@ -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
@@ -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_*
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.local
|
||||||
|
storage
|
||||||
|
test
|
||||||
|
openapi
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.log
|
||||||
@@ -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=""
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
.local/
|
||||||
|
*.log
|
||||||
@@ -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
Generated
+2728
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
}
|
||||||
|
|
||||||
|
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')
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
@@ -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 })
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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 })
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
Vendored
+19
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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?
|
||||||
@@ -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.
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
@@ -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>
|
||||||
Generated
+4334
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow: /
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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 |
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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">« {(annPreview.text || '').slice(0, 120)} »</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
|
||||||
@@ -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
Reference in new issue
Block a user