commit e8e2485dcdcf57c0e59c331897033f21c695fa6d Author: IliesAllali Date: Mon Sep 14 15:49:05 2026 +0200 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) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e6e8707 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0e7ba0f --- /dev/null +++ b/.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= diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.github/workflows/deploy-hostinger.yml b/.github/workflows/deploy-hostinger.yml new file mode 100644 index 0000000..7b653c4 --- /dev/null +++ b/.github/workflows/deploy-hostinger.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..40075cd --- /dev/null +++ b/.gitignore @@ -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_* diff --git a/Dockerfile.web b/Dockerfile.web new file mode 100644 index 0000000..8b1aa98 --- /dev/null +++ b/Dockerfile.web @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a1a08b3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,133 @@ +Required Notice: Copyright Ilies Allali (https://parenthese.io) + +# PolyForm Noncommercial License 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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..fa2ce4f --- /dev/null +++ b/README.md @@ -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/` 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. diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..0893edb --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,10 @@ +node_modules +dist +.local +storage +test +openapi +.env +.env.* +!.env.example +*.log diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..07e7c50 --- /dev/null +++ b/backend/.env.example @@ -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="" diff --git a/backend/.env.production.example b/backend/.env.production.example new file mode 100644 index 0000000..99fbbd0 --- /dev/null +++ b/backend/.env.production.example @@ -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 " +RESEND_WAITLIST_REPLY_TO="hello@parenthese.io" +RESEND_WAITLIST_UNSUBSCRIBE_URL="https://parenthese.io/waitlist/unsubscribe" diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..55ad233 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.env +.local/ +*.log diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..ca61d20 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/openapi/v1.yaml b/backend/openapi/v1.yaml new file mode 100644 index 0000000..7276498 --- /dev/null +++ b/backend/openapi/v1.yaml @@ -0,0 +1,1926 @@ +openapi: 3.0.3 +info: + title: Arbre Genealogique API + version: 1.0.0 + description: Contrats V1 prioritaires (auth, trees, partage, graph read). +servers: + - url: http://localhost:4000 + +tags: + - name: Auth + - name: Trees + - name: Graph + - name: Contributions + - name: Public + +components: + securitySchemes: + BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + schemas: + ErrorResponse: + type: object + properties: + error: + type: string + required: [error] + Credentials: + type: object + properties: + email: + type: string + format: email + password: + type: string + minLength: 8 + maxLength: 128 + required: [email, password] + User: + type: object + properties: + id: + type: string + email: + type: string + format: email + required: [id, email] + AuthResponse: + type: object + properties: + token: + type: string + user: + $ref: '#/components/schemas/User' + required: [token, user] + Tree: + type: object + properties: + id: + type: string + slug: + type: string + name: + type: string + description: + type: string + nullable: true + rootPersonId: + type: string + nullable: true + required: [id, slug, name] + CreateTreeRequest: + type: object + properties: + name: + type: string + slug: + type: string + pattern: '^[a-z0-9-]{3,64}$' + description: + type: string + nullable: true + visitorPassword: + type: string + minLength: 8 + contributorPassword: + type: string + minLength: 8 + required: [name, slug, visitorPassword, contributorPassword] + PatchTreeRequest: + type: object + properties: + name: + type: string + slug: + type: string + pattern: '^[a-z0-9-]{3,64}$' + description: + type: string + nullable: true + rootPersonId: + type: string + nullable: true + UnlockRequest: + type: object + properties: + password: + type: string + required: [password] + UnlockResponse: + type: object + properties: + token: + type: string + treeId: + type: string + role: + type: string + enum: [visitor, contributor] + required: [token, treeId, role] + PatchPasswordsRequest: + type: object + properties: + visitorPassword: + type: string + minLength: 8 + contributorPassword: + type: string + minLength: 8 + PatchTreeSettingsRequest: + type: object + properties: + memberContributionPolicy: + type: string + enum: [direct, pending] + contributorPolicy: + type: string + enum: [direct, pending] + TreeSettings: + type: object + properties: + treeId: + type: string + memberContributionPolicy: + type: string + enum: [direct, pending] + visitorPolicy: + type: string + enum: [read_only] + contributorPolicy: + type: string + enum: [direct, pending] + updatedAt: + type: string + format: date-time + required: [treeId, memberContributionPolicy, visitorPolicy, contributorPolicy] + TreeStats: + type: object + properties: + persons: + type: integer + unions: + type: integer + filiations: + type: integer + medias: + type: integer + pendingContributions: + type: integer + totalContributions: + type: integer + required: [persons, unions, filiations, medias, pendingContributions, totalContributions] + AuditLogEntry: + type: object + properties: + id: + type: string + treeId: + type: string + actorType: + type: string + actorId: + type: string + nullable: true + action: + type: string + entityType: + type: string + entityId: + type: string + payloadJson: + nullable: true + createdAt: + type: string + format: date-time + required: [id, treeId, actorType, action, entityType, entityId, createdAt] + CreatePersonRequest: + type: object + properties: + firstName: + type: string + lastName: + type: string + birthDate: + type: string + nullable: true + deathDate: + type: string + nullable: true + sex: + type: string + nullable: true + notes: + type: string + nullable: true + required: [firstName, lastName] + PatchPersonRequest: + type: object + properties: + firstName: + type: string + lastName: + type: string + birthDate: + type: string + nullable: true + deathDate: + type: string + nullable: true + sex: + type: string + nullable: true + notes: + type: string + nullable: true + CreateUnionRequest: + type: object + properties: + partner1PersonId: + type: string + partner2PersonId: + type: string + nullable: true + unionType: + type: string + nullable: true + startDate: + type: string + nullable: true + endDate: + type: string + nullable: true + displayOrder: + type: integer + required: [partner1PersonId] + PatchUnionRequest: + type: object + properties: + partner1PersonId: + type: string + partner2PersonId: + type: string + nullable: true + unionType: + type: string + nullable: true + startDate: + type: string + nullable: true + endDate: + type: string + nullable: true + displayOrder: + type: integer + CreateParentChildLinkRequest: + type: object + properties: + parentPersonId: + type: string + childPersonId: + type: string + viaUnionId: + type: string + nullable: true + parentageType: + type: string + nullable: true + displayOrder: + type: integer + required: [parentPersonId, childPersonId] + PatchParentChildLinkRequest: + type: object + properties: + parentPersonId: + type: string + childPersonId: + type: string + viaUnionId: + type: string + nullable: true + parentageType: + type: string + nullable: true + displayOrder: + type: integer + UploadMediaRequest: + type: object + properties: + type: + type: string + enum: [photo, video, audio, document, citation] + fileName: + type: string + mimeType: + type: string + sizeBytes: + type: integer + dataBase64: + type: string + caption: + type: string + nullable: true + required: [type, fileName, mimeType, sizeBytes, dataBase64] + ReorderMediaRequest: + type: object + properties: + orderedMediaIds: + type: array + items: + type: string + required: [orderedMediaIds] + GraphPerson: + type: object + properties: + id: + type: string + firstName: + type: string + lastName: + type: string + birthDate: + type: string + nullable: true + deathDate: + type: string + nullable: true + sex: + type: string + nullable: true + notes: + type: string + nullable: true + required: [id, firstName, lastName] + GraphUnion: + type: object + properties: + id: + type: string + partner1PersonId: + type: string + partner2PersonId: + type: string + nullable: true + unionType: + type: string + nullable: true + startDate: + type: string + nullable: true + endDate: + type: string + nullable: true + displayOrder: + type: integer + required: [id, partner1PersonId, displayOrder] + GraphFiliation: + type: object + properties: + id: + type: string + parentPersonId: + type: string + childPersonId: + type: string + viaUnionId: + type: string + nullable: true + parentageType: + type: string + nullable: true + displayOrder: + type: integer + required: [id, parentPersonId, childPersonId, displayOrder] + GraphMedia: + type: object + properties: + id: + type: string + personId: + type: string + type: + type: string + mimeType: + type: string + urlPath: + type: string + caption: + type: string + nullable: true + displayOrder: + type: integer + isFeatured: + type: boolean + required: [id, personId, type, mimeType, urlPath, displayOrder, isFeatured] + ContributionChangeInput: + type: object + properties: + entityType: + type: string + enum: [person, union, parent_child_link] + action: + type: string + enum: [create, update, delete, reorder] + entityId: + type: string + nullable: true + before: + nullable: true + after: + nullable: true + conflictState: + type: string + enum: [none, needs_review] + required: [entityType, action] + ContributionSessionCreateRequest: + type: object + properties: + title: + type: string + submittedByLabel: + type: string + changes: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/ContributionChangeInput' + required: [title, changes] + ContributionSession: + type: object + properties: + id: + type: string + treeId: + type: string + submittedByUserId: + type: string + nullable: true + submittedByLabel: + type: string + mode: + type: string + enum: [user, tree_access] + status: + type: string + enum: [pending, approved, rejected] + title: + type: string + changes: + type: array + items: + $ref: '#/components/schemas/ContributionSessionChange' + required: [id, treeId, submittedByLabel, mode, status, title] + ContributionSessionChange: + type: object + properties: + id: + type: string + entityType: + type: string + enum: [person, union, parent_child_link] + entityId: + type: string + nullable: true + action: + type: string + enum: [create, update, delete, reorder] + beforeJson: + nullable: true + afterJson: + nullable: true + conflictState: + type: string + enum: [none, needs_review] + decision: + type: string + enum: [approved, rejected] + nullable: true + required: [id, entityType, action, conflictState] + ContributionReviewRequest: + type: object + properties: + decision: + type: string + enum: [approved, rejected] + changeDecisions: + type: array + items: + type: object + properties: + changeId: + type: string + decision: + type: string + enum: [approved, rejected] + required: [changeId, decision] + required: [decision] + +paths: + /auth/register: + post: + tags: [Auth] + summary: Register user account + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Credentials' + responses: + '201': + description: Registered + content: + application/json: + schema: + $ref: '#/components/schemas/AuthResponse' + '400': + description: Invalid payload + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: Email already exists + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '429': + description: Too many requests + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /auth/login: + post: + tags: [Auth] + summary: Login user + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Credentials' + responses: + '200': + description: Logged in + content: + application/json: + schema: + $ref: '#/components/schemas/AuthResponse' + '400': + description: Invalid payload + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Invalid credentials + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '429': + description: Too many requests + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /auth/logout: + post: + tags: [Auth] + summary: Logout current session + security: + - BearerAuth: [] + responses: + '200': + description: Logged out + '401': + description: Authentication required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /trees: + get: + tags: [Trees] + summary: List trees for current user + security: + - BearerAuth: [] + responses: + '200': + description: Trees list + content: + application/json: + schema: + type: object + properties: + trees: + type: array + items: + allOf: + - $ref: '#/components/schemas/Tree' + - type: object + properties: + role: + type: string + enum: [owner, admin, member] + '401': + description: Authentication required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + post: + tags: [Trees] + summary: Create tree + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateTreeRequest' + responses: + '201': + description: Tree created + content: + application/json: + schema: + type: object + properties: + tree: + $ref: '#/components/schemas/Tree' + '400': + description: Invalid payload + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Authentication required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: Slug already exists + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /trees/{id}: + get: + tags: [Trees] + summary: Get tree metadata + security: + - BearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: + type: string + responses: + '200': + description: Tree metadata + '400': + description: Invalid tree id + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Authentication required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Tree not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + patch: + tags: [Trees] + summary: Update tree metadata + security: + - BearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PatchTreeRequest' + responses: + '200': + description: Tree updated + '400': + description: Invalid payload or tree id + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Authentication required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Tree not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: Slug already exists + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /trees/{id}/access/unlock: + post: + tags: [Trees] + summary: Unlock tree access with visitor/contributor password + parameters: + - in: path + name: id + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UnlockRequest' + responses: + '200': + description: Access granted + content: + application/json: + schema: + $ref: '#/components/schemas/UnlockResponse' + '400': + description: Invalid payload or tree id + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Invalid password + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Tree not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '429': + description: Too many requests + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /trees/{id}/access/passwords: + patch: + tags: [Trees] + summary: Rotate visitor/contributor passwords + security: + - BearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PatchPasswordsRequest' + responses: + '200': + description: Passwords updated + '400': + description: Invalid payload or tree id + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Authentication required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Tree not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /trees/{id}/stats: + get: + tags: [Trees] + summary: Get admin statistics for a tree + security: + - BearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: + type: string + responses: + '200': + description: Tree statistics + content: + application/json: + schema: + type: object + properties: + stats: + $ref: '#/components/schemas/TreeStats' + required: [stats] + '400': + description: Invalid tree id + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Authentication required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /trees/{id}/settings: + get: + tags: [Trees] + summary: Get tree contribution settings (admin only) + security: + - BearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: + type: string + responses: + '200': + description: Tree settings + content: + application/json: + schema: + type: object + properties: + settings: + $ref: '#/components/schemas/TreeSettings' + required: [settings] + '400': + description: Invalid tree id + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Authentication required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + patch: + tags: [Trees] + summary: Update tree contribution settings (admin only) + security: + - BearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PatchTreeSettingsRequest' + responses: + '200': + description: Tree settings updated + content: + application/json: + schema: + type: object + properties: + settings: + $ref: '#/components/schemas/TreeSettings' + required: [settings] + '400': + description: Invalid payload or tree id + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Authentication required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /trees/{id}/audit: + get: + tags: [Trees] + summary: List admin audit logs for a tree + security: + - BearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: + type: string + - in: query + name: limit + required: false + schema: + type: integer + minimum: 1 + maximum: 100 + - in: query + name: offset + required: false + schema: + type: integer + minimum: 0 + - in: query + name: action + required: false + schema: + type: string + responses: + '200': + description: Audit log entries + content: + application/json: + schema: + type: object + properties: + logs: + type: array + items: + $ref: '#/components/schemas/AuditLogEntry' + nextOffset: + type: integer + nullable: true + required: [logs, nextOffset] + '400': + description: Invalid query or tree id + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Authentication required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /trees/{id}/graph: + get: + tags: [Graph] + summary: Read tree graph payload + security: + - BearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: + type: string + responses: + '200': + description: Graph payload + content: + application/json: + schema: + type: object + properties: + treeId: + type: string + rootPersonId: + type: string + nullable: true + graph: + type: object + properties: + persons: + type: array + items: + $ref: '#/components/schemas/GraphPerson' + unions: + type: array + items: + $ref: '#/components/schemas/GraphUnion' + filiations: + type: array + items: + $ref: '#/components/schemas/GraphFiliation' + medias: + type: array + items: + $ref: '#/components/schemas/GraphMedia' + '400': + description: Invalid tree id + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Authentication required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Tree not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /trees/{id}/persons: + post: + tags: [Trees] + summary: Create a person in the tree + security: + - BearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreatePersonRequest' + responses: + '201': + description: Person created + '400': + description: Invalid payload + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Authentication required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Tree not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /trees/{id}/persons/{personId}: + patch: + tags: [Trees] + summary: Update a person in the tree + security: + - BearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: + type: string + - in: path + name: personId + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PatchPersonRequest' + responses: + '200': + description: Person updated + '400': + description: Invalid payload or params + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Authentication required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Person not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + delete: + tags: [Trees] + summary: Soft-delete a person in the tree + security: + - BearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: + type: string + - in: path + name: personId + required: true + schema: + type: string + responses: + '200': + description: Person soft-deleted + '400': + description: Invalid params + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Authentication required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Person not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /trees/{id}/unions: + post: + tags: [Trees] + summary: Create a union in the tree + security: + - BearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateUnionRequest' + responses: + '201': + description: Union created + '400': + description: Invalid payload or reference + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Authentication required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Tree not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /trees/{id}/unions/{unionId}: + patch: + tags: [Trees] + summary: Update a union in the tree + security: + - BearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: + type: string + - in: path + name: unionId + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PatchUnionRequest' + responses: + '200': + description: Union updated + '400': + description: Invalid payload, params or person reference + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Authentication required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Union not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + delete: + tags: [Trees] + summary: Soft-delete a union in the tree + security: + - BearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: + type: string + - in: path + name: unionId + required: true + schema: + type: string + responses: + '200': + description: Union soft-deleted + '400': + description: Invalid params + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Authentication required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Union not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /trees/{id}/links: + post: + tags: [Trees] + summary: Create a parent-child link in the tree + security: + - BearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateParentChildLinkRequest' + responses: + '201': + description: Link created + '400': + description: Invalid payload or reference + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Authentication required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Tree not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /trees/{id}/links/{linkId}: + patch: + tags: [Trees] + summary: Update a parent-child link in the tree + security: + - BearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: + type: string + - in: path + name: linkId + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PatchParentChildLinkRequest' + responses: + '200': + description: Link updated + '400': + description: Invalid payload, params or references + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Authentication required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Link not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + delete: + tags: [Trees] + summary: Soft-delete a parent-child link in the tree + security: + - BearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: + type: string + - in: path + name: linkId + required: true + schema: + type: string + responses: + '200': + description: Link soft-deleted + '400': + description: Invalid params + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Authentication required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Link not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /trees/{id}/persons/{personId}/media: + post: + tags: [Trees] + summary: Upload media for a person + security: + - BearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: + type: string + - in: path + name: personId + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UploadMediaRequest' + responses: + '201': + description: Media uploaded + '400': + description: Invalid payload + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Authentication required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Tree or person not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /trees/{id}/persons/{personId}/media/reorder: + patch: + tags: [Trees] + summary: Reorder person media + security: + - BearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: + type: string + - in: path + name: personId + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ReorderMediaRequest' + responses: + '200': + description: Media reordered + '400': + description: Invalid payload + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Authentication required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /trees/{id}/persons/{personId}/media/{mediaId}: + delete: + tags: [Trees] + summary: Soft-delete person media + security: + - BearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: + type: string + - in: path + name: personId + required: true + schema: + type: string + - in: path + name: mediaId + required: true + schema: + type: string + responses: + '200': + description: Media deleted + '401': + description: Authentication required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Media not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /trees/{id}/media/{mediaId}: + get: + tags: [Graph] + summary: Download media file by tree and media id + parameters: + - in: path + name: id + required: true + schema: + type: string + - in: path + name: mediaId + required: true + schema: + type: string + - in: query + name: token + required: false + schema: + type: string + responses: + '200': + description: Media binary content + '401': + description: Authentication required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Media not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /trees/{id}/contributions/sessions: + post: + tags: [Contributions] + summary: Submit a contribution session + security: + - BearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ContributionSessionCreateRequest' + responses: + '201': + description: Contribution session submitted + content: + application/json: + schema: + type: object + properties: + session: + $ref: '#/components/schemas/ContributionSession' + changesCount: + type: integer + required: [session, changesCount] + '400': + description: Invalid payload or tree id + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Authentication required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Tree not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + get: + tags: [Contributions] + summary: List contribution sessions (admin only) + security: + - BearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: + type: string + - in: query + name: status + required: false + schema: + type: string + enum: [pending, approved, rejected] + responses: + '200': + description: Sessions list + content: + application/json: + schema: + type: object + properties: + sessions: + type: array + items: + $ref: '#/components/schemas/ContributionSession' + required: [sessions] + '400': + description: Invalid query or tree id + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Authentication required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /trees/{id}/contributions/sessions/{sessionId}/review: + post: + tags: [Contributions] + summary: Review contribution session (admin only) + security: + - BearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: + type: string + - in: path + name: sessionId + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ContributionReviewRequest' + responses: + '200': + description: Session reviewed + content: + application/json: + schema: + type: object + properties: + session: + $ref: '#/components/schemas/ContributionSession' + approvedCount: + type: integer + rejectedCount: + type: integer + required: [session, approvedCount, rejectedCount] + '400': + description: Invalid payload or params + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Authentication required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Contribution session or tree not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: Contribution session already reviewed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /arbre/{slug}: + get: + tags: [Public] + summary: Resolve slug to tree metadata + parameters: + - in: path + name: slug + required: true + schema: + type: string + responses: + '200': + description: Tree found + content: + application/json: + schema: + type: object + properties: + tree: + $ref: '#/components/schemas/Tree' + '400': + description: Invalid slug + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Tree not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..0b8f262 --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,2728 @@ +{ + "name": "backend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "backend", + "version": "0.1.0", + "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" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.5", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" + } + }, + "node_modules/@fastify/cors": { + "version": "10.1.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^5.0.0", + "mnemonist": "0.40.0" + } + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.0.3", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^6.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/jwt": { + "version": "9.1.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "@lukeed/ms": "^2.0.2", + "fast-jwt": "^5.0.0", + "fastify-plugin": "^5.0.0", + "steed": "^1.1.3" + } + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@fastify/rate-limit": { + "version": "10.3.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "fastify-plugin": "^5.0.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "dev": true, + "license": "MIT" + }, + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "license": "MIT" + }, + "node_modules/@prisma/client": { + "version": "6.19.2", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/config": { + "version": "6.19.2", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "c12": "3.1.0", + "deepmerge-ts": "7.1.5", + "effect": "3.18.4", + "empathic": "2.0.0" + } + }, + "node_modules/@prisma/config/node_modules/effect": { + "version": "3.18.4", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz", + "integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, + "node_modules/@prisma/config/node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@prisma/debug": { + "version": "6.19.2", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "6.19.2", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.2", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/fetch-engine": "6.19.2", + "@prisma/get-platform": "6.19.2" + } + }, + "node_modules/@prisma/engines-version": { + "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "6.19.2", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.2", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/get-platform": "6.19.2" + } + }, + "node_modules/@prisma/get-platform": { + "version": "6.19.2", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.2" + } + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.10.13", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "license": "MIT" + }, + "node_modules/ajv": { + "version": "8.17.1", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/asn1.js": { + "version": "5.4.1", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.1.0", + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, + "node_modules/bn.js": { + "version": "4.12.2", + "license": "MIT" + }, + "node_modules/c12": { + "version": "3.1.0", + "devOptional": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.3", + "confbox": "^0.2.2", + "defu": "^6.1.4", + "dotenv": "^16.6.1", + "exsolve": "^1.0.7", + "giget": "^2.0.0", + "jiti": "^2.4.2", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^1.0.0", + "pkg-types": "^2.2.0", + "rc9": "^2.1.2" + }, + "peerDependencies": { + "magicast": "^0.3.5" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/cac": { + "version": "6.7.14", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "devOptional": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/citty": { + "version": "0.1.6", + "devOptional": true, + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + } + }, + "node_modules/confbox": { + "version": "0.2.4", + "devOptional": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/defu": { + "version": "6.1.4", + "devOptional": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "devOptional": true, + "license": "MIT" + }, + "node_modules/dotenv": { + "version": "16.6.1", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/empathic": { + "version": "2.0.0", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.3", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/esbuild/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/exsolve": { + "version": "1.0.8", + "devOptional": true, + "license": "MIT" + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "6.3.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-jwt": { + "version": "5.0.6", + "license": "Apache-2.0", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "asn1.js": "^5.4.1", + "ecdsa-sig-formatter": "^1.0.11", + "mnemonist": "^0.40.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastfall": { + "version": "1.5.1", + "license": "MIT", + "dependencies": { + "reusify": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fastify": { + "version": "5.7.4", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^6.0.0", + "find-my-way": "^9.0.0", + "light-my-request": "^6.0.0", + "pino": "^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "5.1.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fastparallel": { + "version": "2.4.1", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4", + "xtend": "^4.0.2" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fastseries": { + "version": "1.7.2", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.0", + "xtend": "^4.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/find-my-way": { + "version": "9.4.0", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/giget": { + "version": "2.0.0", + "devOptional": true, + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.6", + "nypm": "^0.6.0", + "pathe": "^2.0.3" + }, + "bin": { + "giget": "dist/cli.mjs" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "2.3.0", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "devOptional": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "license": "MIT" + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "license": "ISC" + }, + "node_modules/mnemonist": { + "version": "0.40.0", + "license": "MIT", + "dependencies": { + "obliterator": "^2.0.4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "devOptional": true, + "license": "MIT" + }, + "node_modules/nypm": { + "version": "0.6.5", + "devOptional": true, + "license": "MIT", + "dependencies": { + "citty": "^0.2.0", + "pathe": "^2.0.3", + "tinyexec": "^1.0.2" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/nypm/node_modules/citty": { + "version": "0.2.0", + "devOptional": true, + "license": "MIT" + }, + "node_modules/obliterator": { + "version": "2.0.5", + "license": "MIT" + }, + "node_modules/ohash": { + "version": "2.0.11", + "devOptional": true, + "license": "MIT" + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "devOptional": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "devOptional": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pino": { + "version": "10.3.1", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "license": "MIT" + }, + "node_modules/pkg-types": { + "version": "2.3.0", + "devOptional": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, + "node_modules/postal-mime": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.3.tgz", + "integrity": "sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw==", + "license": "MIT-0" + }, + "node_modules/postcss": { + "version": "8.5.6", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prisma": { + "version": "6.19.2", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/config": "6.19.2", + "@prisma/engines": "6.19.2" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/process-warning": { + "version": "5.0.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "license": "MIT" + }, + "node_modules/rc9": { + "version": "2.1.2", + "devOptional": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "destr": "^2.0.3" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resend": { + "version": "6.9.2", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.9.2.tgz", + "integrity": "sha512-uIM6CQ08tS+hTCRuKBFbOBvHIGaEhqZe8s4FOgqsVXSbQLAhmNWpmUhG3UAtRnmcwTWFUqnHa/+Vux8YGPyDBA==", + "license": "MIT", + "dependencies": { + "postal-mime": "2.7.3", + "svix": "1.84.1" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@react-email/render": "*" + }, + "peerDependenciesMeta": { + "@react-email/render": { + "optional": true + } + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.57.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup/node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/rollup/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex2": { + "version": "5.0.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "license": "MIT" + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.7.4", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "dev": true, + "license": "MIT" + }, + "node_modules/steed": { + "version": "1.1.3", + "license": "MIT", + "dependencies": { + "fastfall": "^1.5.0", + "fastparallel": "^2.2.0", + "fastq": "^1.3.0", + "fastseries": "^1.7.0", + "reusify": "^1.0.0" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/svix": { + "version": "1.84.1", + "resolved": "https://registry.npmjs.org/svix/-/svix-1.84.1.tgz", + "integrity": "sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ==", + "license": "MIT", + "dependencies": { + "standardwebhooks": "1.0.0", + "uuid": "^10.0.0" + } + }, + "node_modules/thread-stream": { + "version": "4.0.0", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/toad-cache": { + "version": "3.7.0", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "dev": true, + "license": "MIT" + }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/tinyexec": { + "version": "0.3.2", + "dev": true, + "license": "MIT" + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..8134323 --- /dev/null +++ b/backend/package.json @@ -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" + } +} diff --git a/backend/prisma/migrations/20260211_001_init/migration.sql b/backend/prisma/migrations/20260211_001_init/migration.sql new file mode 100644 index 0000000..8e33f1b --- /dev/null +++ b/backend/prisma/migrations/20260211_001_init/migration.sql @@ -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; \ No newline at end of file diff --git a/backend/prisma/migrations/20260211_002_contributions/migration.sql b/backend/prisma/migrations/20260211_002_contributions/migration.sql new file mode 100644 index 0000000..a0f40e5 --- /dev/null +++ b/backend/prisma/migrations/20260211_002_contributions/migration.sql @@ -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; diff --git a/backend/prisma/migrations/20260211_003_media_items/migration.sql b/backend/prisma/migrations/20260211_003_media_items/migration.sql new file mode 100644 index 0000000..9e3f359 --- /dev/null +++ b/backend/prisma/migrations/20260211_003_media_items/migration.sql @@ -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; diff --git a/backend/prisma/migrations/20260214155347_prout/migration.sql b/backend/prisma/migrations/20260214155347_prout/migration.sql new file mode 100644 index 0000000..3fd687c --- /dev/null +++ b/backend/prisma/migrations/20260214155347_prout/migration.sql @@ -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; diff --git a/backend/prisma/migrations/20260214161846_add_annotations/migration.sql b/backend/prisma/migrations/20260214161846_add_annotations/migration.sql new file mode 100644 index 0000000..76d346e --- /dev/null +++ b/backend/prisma/migrations/20260214161846_add_annotations/migration.sql @@ -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; diff --git a/backend/prisma/migrations/20260214_001_person_profile_fields/migration.sql b/backend/prisma/migrations/20260214_001_person_profile_fields/migration.sql new file mode 100644 index 0000000..ee701a0 --- /dev/null +++ b/backend/prisma/migrations/20260214_001_person_profile_fields/migration.sql @@ -0,0 +1,5 @@ +ALTER TABLE "persons" +ADD COLUMN "birthPlace" TEXT, +ADD COLUMN "profession" TEXT, +ADD COLUMN "region" TEXT, +ADD COLUMN "nationality" TEXT; diff --git a/backend/prisma/migrations/20260214_002_person_avatar_fields/migration.sql b/backend/prisma/migrations/20260214_002_person_avatar_fields/migration.sql new file mode 100644 index 0000000..bde75a5 --- /dev/null +++ b/backend/prisma/migrations/20260214_002_person_avatar_fields/migration.sql @@ -0,0 +1,3 @@ +ALTER TABLE "persons" +ADD COLUMN "avatarPath" TEXT, +ADD COLUMN "avatarMimeType" TEXT; diff --git a/backend/prisma/migrations/20260214_003_person_birth_name/migration.sql b/backend/prisma/migrations/20260214_003_person_birth_name/migration.sql new file mode 100644 index 0000000..7059007 --- /dev/null +++ b/backend/prisma/migrations/20260214_003_person_birth_name/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "persons" +ADD COLUMN "birthName" TEXT; diff --git a/backend/prisma/migrations/20260214_004_user_tree_access_and_last_opened/migration.sql b/backend/prisma/migrations/20260214_004_user_tree_access_and_last_opened/migration.sql new file mode 100644 index 0000000..4e87f06 --- /dev/null +++ b/backend/prisma/migrations/20260214_004_user_tree_access_and_last_opened/migration.sql @@ -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; diff --git a/backend/prisma/migrations/20260220_001_add_photo_annotation/migration.sql b/backend/prisma/migrations/20260220_001_add_photo_annotation/migration.sql new file mode 100644 index 0000000..2632668 --- /dev/null +++ b/backend/prisma/migrations/20260220_001_add_photo_annotation/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "AnnotationType" ADD VALUE 'photo'; diff --git a/backend/prisma/migrations/20260223000000_add_waitlist/migration.sql b/backend/prisma/migrations/20260223000000_add_waitlist/migration.sql new file mode 100644 index 0000000..16dea5f --- /dev/null +++ b/backend/prisma/migrations/20260223000000_add_waitlist/migration.sql @@ -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"); diff --git a/backend/prisma/migrations/20260224_001_media_source/migration.sql b/backend/prisma/migrations/20260224_001_media_source/migration.sql new file mode 100644 index 0000000..369d72c --- /dev/null +++ b/backend/prisma/migrations/20260224_001_media_source/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "media_items" +ADD COLUMN "source" TEXT; diff --git a/backend/prisma/migrations/migration_lock.toml b/backend/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/backend/prisma/migrations/migration_lock.toml @@ -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" diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma new file mode 100644 index 0000000..07ca347 --- /dev/null +++ b/backend/prisma/schema.prisma @@ -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") +} diff --git a/backend/scripts/db-local.mjs b/backend/scripts/db-local.mjs new file mode 100644 index 0000000..b7d46f2 --- /dev/null +++ b/backend/scripts/db-local.mjs @@ -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() diff --git a/backend/scripts/setup-resend-waitlist.mjs b/backend/scripts/setup-resend-waitlist.mjs new file mode 100644 index 0000000..1961420 --- /dev/null +++ b/backend/scripts/setup-resend-waitlist.mjs @@ -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 ', + warning: 'No verified custom domain found. Using onboarding@resend.dev temporarily.', + } + } + + return { + from: `Parenthese `, + 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) +}) diff --git a/backend/src/app.ts b/backend/src/app.ts new file mode 100644 index 0000000..d12b1b2 --- /dev/null +++ b/backend/src/app.ts @@ -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() + + 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 +} diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts new file mode 100644 index 0000000..062f5b8 --- /dev/null +++ b/backend/src/config/env.ts @@ -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') + } +} diff --git a/backend/src/lib/auth.ts b/backend/src/lib/auth.ts new file mode 100644 index 0000000..edc534b --- /dev/null +++ b/backend/src/lib/auth.ts @@ -0,0 +1,11 @@ +import bcrypt from 'bcryptjs' + +const HASH_ROUNDS = 12 + +export async function hashPassword(value: string): Promise { + return bcrypt.hash(value, HASH_ROUNDS) +} + +export async function verifyPassword(value: string, hash: string): Promise { + return bcrypt.compare(value, hash) +} \ No newline at end of file diff --git a/backend/src/lib/prisma.ts b/backend/src/lib/prisma.ts new file mode 100644 index 0000000..f205366 --- /dev/null +++ b/backend/src/lib/prisma.ts @@ -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, + }, + }, +}) diff --git a/backend/src/lib/token-revocation.ts b/backend/src/lib/token-revocation.ts new file mode 100644 index 0000000..44fe0fa --- /dev/null +++ b/backend/src/lib/token-revocation.ts @@ -0,0 +1,24 @@ +const revokedUserTokens = new Map() + +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 +} diff --git a/backend/src/lib/waitlist-mailer.ts b/backend/src/lib/waitlist-mailer.ts new file mode 100644 index 0000000..326600f --- /dev/null +++ b/backend/src/lib/waitlist-mailer.ts @@ -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, ''') +} + +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 ` + + + + + + Parenthèse - inscription confirmée + + + + + + +
+ + + + + + + + + + +
+
+ P + Parenthèse +
+

Merci pour votre inscription

+

+ Bonjour ${escapedFirstName}, votre place sur la waitlist Parenthèse est confirmée. +

+
+

+ Nous vous préviendrons dès que les prochaines invitations seront ouvertes, avec uniquement des nouvelles utiles. +

+

+ Merci de faire partie des premières familles qui nous font confiance. +

+ + + + +
+ + Partager Parenthèse + +
+
+

+ À bientôt,
Parenthèse +

+

+ Vous recevez cet email suite à votre inscription volontaire à la waitlist. +

+

+ Se désinscrire de la waitlist +

+
+
+ +`.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 { + 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 { + 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 { + 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') +} diff --git a/backend/src/routes/annotations.ts b/backend/src/routes/annotations.ts new file mode 100644 index 0000000..00d8f6f --- /dev/null +++ b/backend/src/routes/annotations.ts @@ -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>): 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 = {} + 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 = {} + 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 }) + }) +} diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts new file mode 100644 index 0000000..28d22eb --- /dev/null +++ b/backend/src/routes/auth.ts @@ -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() + 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 }) + }) +} diff --git a/backend/src/routes/contributions.ts b/backend/src/routes/contributions.ts new file mode 100644 index 0000000..97bf423 --- /dev/null +++ b/backend/src/routes/contributions.ts @@ -0,0 +1,1185 @@ +import type { FastifyInstance, FastifyPluginAsync } from 'fastify' +import { Prisma } from '@prisma/client' +import { z } from 'zod' + +import type { Actor, MembershipRole } from '../types/auth.js' +import { findParentChildValidationError, findUnionValidationError } from '../utils/relationship-guards.js' + +const isoDateRegex = /^\d{4}-\d{2}-\d{2}$/ + +const treeIdParamSchema = z.object({ + id: z.string().min(1), +}) + +const sessionParamsSchema = z.object({ + id: z.string().min(1), + sessionId: z.string().min(1), +}) + +const contributionChangeSchema = z.object({ + entityType: z.enum(['person', 'union', 'parent_child_link', 'annotation']), + action: z.enum(['create', 'update', 'delete', 'reorder']), + entityId: z.string().min(1).optional().nullable(), + before: z.unknown().optional(), + after: z.unknown().optional(), + conflictState: z.enum(['none', 'needs_review']).optional(), +}) + +const createSessionSchema = z.object({ + title: z.string().min(2).max(120), + submittedByLabel: z.string().min(1).max(120).optional(), + changes: z.array(contributionChangeSchema).min(1), +}) + +const listSessionsQuerySchema = z.object({ + status: z.enum(['pending', 'approved', 'rejected']).optional(), +}) + +const reviewSessionSchema = z.object({ + decision: z.enum(['approved', 'rejected']), + changeDecisions: z + .array( + z.object({ + changeId: z.string().min(1), + decision: z.enum(['approved', 'rejected']), + }), + ) + .optional(), +}) + +const personCreateSchema = z.object({ + firstName: z.string().min(1).max(120), + lastName: z.string().min(1).max(120), + birthName: z.string().max(120).optional().nullable(), + birthDate: z.string().regex(isoDateRegex).optional().nullable(), + deathDate: z.string().regex(isoDateRegex).optional().nullable(), + birthPlace: z.string().max(200).optional().nullable(), + profession: z.string().max(200).optional().nullable(), + region: z.string().max(120).optional().nullable(), + nationality: z.string().max(120).optional().nullable(), + sex: z.string().max(20).optional().nullable(), + notes: z.string().max(2000).optional().nullable(), +}) + +const personUpdateSchema = z + .object({ + firstName: z.string().min(1).max(120).optional(), + lastName: z.string().min(1).max(120).optional(), + birthName: z.string().max(120).optional().nullable(), + birthDate: z.string().regex(isoDateRegex).optional().nullable(), + deathDate: z.string().regex(isoDateRegex).optional().nullable(), + birthPlace: z.string().max(200).optional().nullable(), + profession: z.string().max(200).optional().nullable(), + region: z.string().max(120).optional().nullable(), + nationality: z.string().max(120).optional().nullable(), + sex: z.string().max(20).optional().nullable(), + notes: z.string().max(2000).optional().nullable(), + }) + .refine((value) => Object.keys(value).length > 0, { + message: 'at_least_one_person_field_required', + }) + +const unionCreateSchema = z.object({ + partner1PersonId: z.string().min(1), + partner2PersonId: z.string().min(1).optional().nullable(), + unionType: z.string().max(50).optional().nullable(), + startDate: z.string().regex(isoDateRegex).optional().nullable(), + endDate: z.string().regex(isoDateRegex).optional().nullable(), + displayOrder: z.number().int().positive().optional(), +}) + +const unionUpdateSchema = z + .object({ + partner1PersonId: z.string().min(1).optional(), + partner2PersonId: z.string().min(1).optional().nullable(), + unionType: z.string().max(50).optional().nullable(), + startDate: z.string().regex(isoDateRegex).optional().nullable(), + endDate: z.string().regex(isoDateRegex).optional().nullable(), + displayOrder: z.number().int().positive().optional(), + }) + .refine((value) => Object.keys(value).length > 0, { + message: 'at_least_one_union_field_required', + }) + +const linkCreateSchema = z.object({ + parentPersonId: z.string().min(1), + childPersonId: z.string().min(1), + viaUnionId: z.string().min(1).optional().nullable(), + parentageType: z.string().max(50).optional().nullable(), + displayOrder: z.number().int().positive().optional(), +}) + +const linkUpdateSchema = z + .object({ + parentPersonId: z.string().min(1).optional(), + childPersonId: z.string().min(1).optional(), + viaUnionId: z.string().min(1).optional().nullable(), + parentageType: z.string().max(50).optional().nullable(), + displayOrder: z.number().int().positive().optional(), + }) + .refine((value) => Object.keys(value).length > 0, { + message: 'at_least_one_link_field_required', + }) + +const reorderSchema = z.object({ + displayOrder: z.number().int().positive(), +}) + +type ContributionStatus = 'pending' | 'approved' | 'rejected' + +type ParsedChange = z.infer + +type TxClient = FastifyInstance['prisma'] + +class ContributionRouteError extends Error { + statusCode: number + code: string + + constructor(statusCode: number, code: string) { + super(code) + this.statusCode = statusCode + this.code = code + } +} + +function hasAdminRight(role: MembershipRole): boolean { + return role === 'owner' || role === 'admin' +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function toDateOrNull(value?: string | null): Date | null { + if (!value) { + return null + } + + return new Date(`${value}T00:00:00.000Z`) +} + +function toTrimmedStringOrNull(value?: string | null): string | null { + if (value === undefined || value === null) { + return null + } + + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : null +} + +function toPrismaJson( + value: unknown, +): Prisma.InputJsonValue | Prisma.NullTypes.DbNull | Prisma.NullTypes.JsonNull | undefined { + if (value === undefined) { + return undefined + } + + if (value === null) { + return Prisma.JsonNull + } + + return value as Prisma.InputJsonValue +} + +async function assertTreeIsActive(tx: TxClient, treeId: string): Promise { + const tree = await tx.tree.findFirst({ + where: { + id: treeId, + deletedAt: null, + }, + select: { + id: true, + }, + }) + + if (!tree) { + throw new ContributionRouteError(404, 'tree_not_found') + } +} + +async function assertPersonExists(tx: TxClient, treeId: string, personId: string): Promise { + const person = await tx.person.findFirst({ + where: { + id: personId, + treeId, + deletedAt: null, + }, + select: { + id: true, + }, + }) + + if (!person) { + throw new ContributionRouteError(400, 'invalid_person_reference') + } +} + +async function assertUnionExists( + tx: TxClient, + treeId: string, + unionId: string, +): Promise<{ id: string; partner1PersonId: string; partner2PersonId: string | null }> { + const union = await tx.union.findFirst({ + where: { + id: unionId, + treeId, + deletedAt: null, + }, + select: { + id: true, + partner1PersonId: true, + partner2PersonId: true, + }, + }) + + if (!union) { + throw new ContributionRouteError(400, 'invalid_union_reference') + } + + return union +} + +async function applyContributionChange( + tx: TxClient, + treeId: string, + change: ParsedChange, + actorUserId: string | null, +): Promise<{ entityId: string | null }> { + if (change.entityType === 'person') { + if (change.action === 'create') { + if (!isRecord(change.after)) { + throw new ContributionRouteError(400, 'invalid_change_after') + } + + const parsed = personCreateSchema.safeParse(change.after) + if (!parsed.success) { + throw new ContributionRouteError(400, 'invalid_change_after') + } + + const created = await tx.person.create({ + data: { + treeId, + firstName: parsed.data.firstName.trim(), + lastName: parsed.data.lastName.trim(), + birthName: toTrimmedStringOrNull(parsed.data.birthName), + birthDate: toDateOrNull(parsed.data.birthDate), + deathDate: toDateOrNull(parsed.data.deathDate), + birthPlace: toTrimmedStringOrNull(parsed.data.birthPlace), + profession: toTrimmedStringOrNull(parsed.data.profession), + region: toTrimmedStringOrNull(parsed.data.region), + nationality: toTrimmedStringOrNull(parsed.data.nationality), + sex: parsed.data.sex ?? null, + notes: parsed.data.notes ?? null, + createdBy: actorUserId, + updatedBy: actorUserId, + }, + select: { + id: true, + }, + }) + + return { entityId: created.id } + } + + if (!change.entityId) { + throw new ContributionRouteError(400, 'missing_change_entity_id') + } + + if (change.action === 'update') { + if (!isRecord(change.after)) { + throw new ContributionRouteError(400, 'invalid_change_after') + } + + const parsed = personUpdateSchema.safeParse(change.after) + if (!parsed.success) { + throw new ContributionRouteError(400, 'invalid_change_after') + } + + const result = await tx.person.updateMany({ + where: { + id: change.entityId, + treeId, + deletedAt: null, + }, + data: { + firstName: parsed.data.firstName !== undefined ? parsed.data.firstName.trim() : undefined, + lastName: parsed.data.lastName !== undefined ? parsed.data.lastName.trim() : undefined, + birthName: parsed.data.birthName !== undefined ? toTrimmedStringOrNull(parsed.data.birthName) : undefined, + birthDate: parsed.data.birthDate !== undefined ? toDateOrNull(parsed.data.birthDate) : undefined, + deathDate: parsed.data.deathDate !== undefined ? toDateOrNull(parsed.data.deathDate) : undefined, + birthPlace: parsed.data.birthPlace !== undefined ? toTrimmedStringOrNull(parsed.data.birthPlace) : undefined, + profession: parsed.data.profession !== undefined ? toTrimmedStringOrNull(parsed.data.profession) : undefined, + region: parsed.data.region !== undefined ? toTrimmedStringOrNull(parsed.data.region) : undefined, + nationality: parsed.data.nationality !== undefined ? toTrimmedStringOrNull(parsed.data.nationality) : undefined, + sex: parsed.data.sex, + notes: parsed.data.notes, + updatedBy: actorUserId, + }, + }) + + if (result.count === 0) { + throw new ContributionRouteError(400, 'invalid_entity_reference') + } + + return { entityId: change.entityId } + } + + if (change.action === 'delete') { + const result = await tx.person.updateMany({ + where: { + id: change.entityId, + treeId, + deletedAt: null, + }, + data: { + deletedAt: new Date(), + updatedBy: actorUserId, + }, + }) + + if (result.count === 0) { + throw new ContributionRouteError(400, 'invalid_entity_reference') + } + + return { entityId: change.entityId } + } + + throw new ContributionRouteError(400, 'unsupported_change_action_for_person') + } + + if (change.entityType === 'union') { + if (change.action === 'create') { + if (!isRecord(change.after)) { + throw new ContributionRouteError(400, 'invalid_change_after') + } + + const parsed = unionCreateSchema.safeParse(change.after) + if (!parsed.success) { + throw new ContributionRouteError(400, 'invalid_change_after') + } + + await assertPersonExists(tx, treeId, parsed.data.partner1PersonId) + if (parsed.data.partner2PersonId) { + await assertPersonExists(tx, treeId, parsed.data.partner2PersonId) + } + + const unionValidationError = await findUnionValidationError( + tx, + treeId, + parsed.data.partner1PersonId, + parsed.data.partner2PersonId ?? null, + ) + if (unionValidationError) { + throw new ContributionRouteError(400, unionValidationError) + } + + const created = await tx.union.create({ + data: { + treeId, + partner1PersonId: parsed.data.partner1PersonId, + partner2PersonId: parsed.data.partner2PersonId ?? null, + unionType: parsed.data.unionType ?? null, + startDate: toDateOrNull(parsed.data.startDate), + endDate: toDateOrNull(parsed.data.endDate), + displayOrder: parsed.data.displayOrder ?? 1, + }, + select: { + id: true, + }, + }) + + return { entityId: created.id } + } + + if (!change.entityId) { + throw new ContributionRouteError(400, 'missing_change_entity_id') + } + + if (change.action === 'update') { + if (!isRecord(change.after)) { + throw new ContributionRouteError(400, 'invalid_change_after') + } + + const parsed = unionUpdateSchema.safeParse(change.after) + if (!parsed.success) { + throw new ContributionRouteError(400, 'invalid_change_after') + } + + if (parsed.data.partner1PersonId) { + await assertPersonExists(tx, treeId, parsed.data.partner1PersonId) + } + + if (parsed.data.partner2PersonId) { + await assertPersonExists(tx, treeId, parsed.data.partner2PersonId) + } + + const existingUnion = await tx.union.findFirst({ + where: { + id: change.entityId, + treeId, + deletedAt: null, + }, + select: { + partner1PersonId: true, + partner2PersonId: true, + }, + }) + if (!existingUnion) { + throw new ContributionRouteError(400, 'invalid_entity_reference') + } + + const hasPartner2Field = Object.prototype.hasOwnProperty.call(parsed.data, 'partner2PersonId') + const finalPartner1PersonId = parsed.data.partner1PersonId ?? existingUnion.partner1PersonId + const finalPartner2PersonId = hasPartner2Field ? parsed.data.partner2PersonId ?? null : existingUnion.partner2PersonId + const unionValidationError = await findUnionValidationError( + tx, + treeId, + finalPartner1PersonId, + finalPartner2PersonId, + ) + if (unionValidationError) { + throw new ContributionRouteError(400, unionValidationError) + } + + const result = await tx.union.updateMany({ + where: { + id: change.entityId, + treeId, + deletedAt: null, + }, + data: { + partner1PersonId: parsed.data.partner1PersonId, + partner2PersonId: parsed.data.partner2PersonId, + unionType: parsed.data.unionType, + startDate: parsed.data.startDate !== undefined ? toDateOrNull(parsed.data.startDate) : undefined, + endDate: parsed.data.endDate !== undefined ? toDateOrNull(parsed.data.endDate) : undefined, + displayOrder: parsed.data.displayOrder, + }, + }) + + if (result.count === 0) { + throw new ContributionRouteError(400, 'invalid_entity_reference') + } + + return { entityId: change.entityId } + } + + if (change.action === 'delete') { + const result = await tx.union.updateMany({ + where: { + id: change.entityId, + treeId, + deletedAt: null, + }, + data: { + deletedAt: new Date(), + }, + }) + + if (result.count === 0) { + throw new ContributionRouteError(400, 'invalid_entity_reference') + } + + return { entityId: change.entityId } + } + + if (change.action === 'reorder') { + if (!isRecord(change.after)) { + throw new ContributionRouteError(400, 'invalid_change_after') + } + + const parsed = reorderSchema.safeParse(change.after) + if (!parsed.success) { + throw new ContributionRouteError(400, 'invalid_change_after') + } + + const result = await tx.union.updateMany({ + where: { + id: change.entityId, + treeId, + deletedAt: null, + }, + data: { + displayOrder: parsed.data.displayOrder, + }, + }) + + if (result.count === 0) { + throw new ContributionRouteError(400, 'invalid_entity_reference') + } + + return { entityId: change.entityId } + } + + throw new ContributionRouteError(400, 'unsupported_change_action_for_union') + } + + if (change.action === 'create') { + if (!isRecord(change.after)) { + throw new ContributionRouteError(400, 'invalid_change_after') + } + + const parsed = linkCreateSchema.safeParse(change.after) + if (!parsed.success) { + throw new ContributionRouteError(400, 'invalid_change_after') + } + + await Promise.all([ + assertPersonExists(tx, treeId, parsed.data.parentPersonId), + assertPersonExists(tx, treeId, parsed.data.childPersonId), + ]) + + const resolvedViaUnion = parsed.data.viaUnionId ? await assertUnionExists(tx, treeId, parsed.data.viaUnionId) : null + + const linkValidationError = await findParentChildValidationError( + tx, + treeId, + parsed.data.parentPersonId, + parsed.data.childPersonId, + { + viaUnionId: parsed.data.viaUnionId ?? null, + resolvedViaUnion, + }, + ) + if (linkValidationError) { + throw new ContributionRouteError(400, linkValidationError) + } + + const created = await tx.parentChildLink.create({ + data: { + treeId, + parentPersonId: parsed.data.parentPersonId, + childPersonId: parsed.data.childPersonId, + viaUnionId: parsed.data.viaUnionId ?? null, + parentageType: parsed.data.parentageType ?? 'biologique', + displayOrder: parsed.data.displayOrder ?? 1, + }, + select: { + id: true, + }, + }) + + return { entityId: created.id } + } + + if (!change.entityId) { + throw new ContributionRouteError(400, 'missing_change_entity_id') + } + + if (change.action === 'update') { + if (!isRecord(change.after)) { + throw new ContributionRouteError(400, 'invalid_change_after') + } + + const parsed = linkUpdateSchema.safeParse(change.after) + if (!parsed.success) { + throw new ContributionRouteError(400, 'invalid_change_after') + } + + if (parsed.data.parentPersonId) { + await assertPersonExists(tx, treeId, parsed.data.parentPersonId) + } + + if (parsed.data.childPersonId) { + await assertPersonExists(tx, treeId, parsed.data.childPersonId) + } + + let resolvedViaUnion: { id: string; partner1PersonId: string; partner2PersonId: string | null } | null = null + if (parsed.data.viaUnionId) { + resolvedViaUnion = await assertUnionExists(tx, treeId, parsed.data.viaUnionId) + } + + const existingLink = await tx.parentChildLink.findFirst({ + where: { + id: change.entityId, + treeId, + deletedAt: null, + }, + select: { + parentPersonId: true, + childPersonId: true, + viaUnionId: true, + }, + }) + if (!existingLink) { + throw new ContributionRouteError(400, 'invalid_entity_reference') + } + + const hasViaUnionIdField = Object.prototype.hasOwnProperty.call(parsed.data, 'viaUnionId') + const finalParentPersonId = parsed.data.parentPersonId ?? existingLink.parentPersonId + const finalChildPersonId = parsed.data.childPersonId ?? existingLink.childPersonId + const finalViaUnionId = hasViaUnionIdField ? parsed.data.viaUnionId ?? null : existingLink.viaUnionId + const linkValidationError = await findParentChildValidationError( + tx, + treeId, + finalParentPersonId, + finalChildPersonId, + { + excludeLinkId: change.entityId, + viaUnionId: finalViaUnionId, + resolvedViaUnion: finalViaUnionId ? resolvedViaUnion : null, + }, + ) + if (linkValidationError) { + throw new ContributionRouteError(400, linkValidationError) + } + + const result = await tx.parentChildLink.updateMany({ + where: { + id: change.entityId, + treeId, + deletedAt: null, + }, + data: { + parentPersonId: parsed.data.parentPersonId, + childPersonId: parsed.data.childPersonId, + viaUnionId: parsed.data.viaUnionId, + parentageType: parsed.data.parentageType, + displayOrder: parsed.data.displayOrder, + }, + }) + + if (result.count === 0) { + throw new ContributionRouteError(400, 'invalid_entity_reference') + } + + return { entityId: change.entityId } + } + + if (change.action === 'delete') { + const result = await tx.parentChildLink.updateMany({ + where: { + id: change.entityId, + treeId, + deletedAt: null, + }, + data: { + deletedAt: new Date(), + }, + }) + + if (result.count === 0) { + throw new ContributionRouteError(400, 'invalid_entity_reference') + } + + return { entityId: change.entityId } + } + + if (change.action === 'reorder') { + if (!isRecord(change.after)) { + throw new ContributionRouteError(400, 'invalid_change_after') + } + + const parsed = reorderSchema.safeParse(change.after) + if (!parsed.success) { + throw new ContributionRouteError(400, 'invalid_change_after') + } + + const result = await tx.parentChildLink.updateMany({ + where: { + id: change.entityId, + treeId, + deletedAt: null, + }, + data: { + displayOrder: parsed.data.displayOrder, + }, + }) + + if (result.count === 0) { + throw new ContributionRouteError(400, 'invalid_entity_reference') + } + + return { entityId: change.entityId } + } + + if (change.entityType === 'annotation') { + return { entityId: change.entityId ?? null } + } + + throw new ContributionRouteError(400, 'unsupported_change_action_for_link') +} + +async function detectConflict( + tx: TxClient, + treeId: string, + change: { entityType: string; action: string; entityId: string | null; beforeJson: unknown }, +): Promise { + if (change.action !== 'update' || !change.entityId || !isRecord(change.beforeJson)) { + return false + } + + const before = change.beforeJson + + if (change.entityType === 'person') { + const current = await tx.person.findFirst({ + where: { id: change.entityId, treeId, deletedAt: null }, + select: { + firstName: true, + lastName: true, + birthName: true, + birthDate: true, + deathDate: true, + birthPlace: true, + profession: true, + region: true, + nationality: true, + sex: true, + notes: true, + }, + }) + if (!current) return true + const snap: Record = { + ...current, + birthDate: current.birthDate?.toISOString().slice(0, 10) ?? null, + deathDate: current.deathDate?.toISOString().slice(0, 10) ?? null, + } + for (const key of Object.keys(before)) { + if (key in snap && String(snap[key] ?? '') !== String(before[key] ?? '')) return true + } + return false + } + + if (change.entityType === 'union') { + const current = await tx.union.findFirst({ + where: { id: change.entityId, treeId, deletedAt: null }, + select: { partner1PersonId: true, partner2PersonId: true, unionType: true, startDate: true, endDate: true, displayOrder: true }, + }) + if (!current) return true + const snap: Record = { + ...current, + startDate: current.startDate?.toISOString().slice(0, 10) ?? null, + endDate: current.endDate?.toISOString().slice(0, 10) ?? null, + } + for (const key of Object.keys(before)) { + if (key in snap && String(snap[key] ?? '') !== String(before[key] ?? '')) return true + } + return false + } + + if (change.entityType === 'parent_child_link') { + const current = await tx.parentChildLink.findFirst({ + where: { id: change.entityId, treeId, deletedAt: null }, + select: { parentPersonId: true, childPersonId: true, viaUnionId: true, parentageType: true, displayOrder: true }, + }) + if (!current) return true + const snap = current as Record + for (const key of Object.keys(before)) { + if (key in snap && String(snap[key] ?? '') !== String(before[key] ?? '')) return true + } + return false + } + + return false +} + +async function requireAdminMembership(app: FastifyInstance, actor: Actor, treeId: string) { + if (!actor || actor.kind !== 'user') { + throw new ContributionRouteError(401, 'authentication_required') + } + + const membership = await app.prisma.treeMembership.findUnique({ + where: { + treeId_userId: { + treeId, + userId: actor.userId, + }, + }, + include: { + tree: { + select: { + deletedAt: true, + }, + }, + }, + }) + + if (!membership || membership.tree.deletedAt || !hasAdminRight(membership.role)) { + throw new ContributionRouteError(403, 'forbidden') + } + + return membership +} + +async function resolveSubmissionContext(app: FastifyInstance, actor: Actor, treeId: string) { + if (!actor) { + throw new ContributionRouteError(401, 'authentication_required') + } + + if (actor.kind === 'tree_access') { + if (actor.treeId !== treeId || actor.role !== 'contributor') { + throw new ContributionRouteError(403, 'forbidden') + } + + const tree = await app.prisma.tree.findFirst({ + where: { + id: treeId, + deletedAt: null, + }, + select: { + id: true, + settings: { + select: { + contributorPolicy: true, + }, + }, + }, + }) + + if (!tree) { + throw new ContributionRouteError(404, 'tree_not_found') + } + + const policy = tree.settings?.contributorPolicy ?? 'pending' + return { + autoStatus: policy === 'direct' ? ('approved' as const) : ('pending' as const), + mode: 'tree_access' as const, + submittedByUserId: null, + submittedByLabel: 'Contributeur', + actorId: null, + actorType: 'tree_access' as const, + } + } + + const membership = await app.prisma.treeMembership.findUnique({ + where: { + treeId_userId: { + treeId, + userId: actor.userId, + }, + }, + include: { + tree: { + select: { + deletedAt: true, + settings: { + select: { + memberContributionPolicy: true, + }, + }, + }, + }, + }, + }) + + if (membership && !membership.tree.deletedAt) { + let autoStatus: ContributionStatus = 'pending' + if (hasAdminRight(membership.role)) { + autoStatus = 'approved' + } else { + const policy = membership.contributionModeOverride ?? membership.tree.settings?.memberContributionPolicy ?? 'pending' + autoStatus = policy === 'direct' ? 'approved' : 'pending' + } + + return { + autoStatus, + mode: 'user' as const, + submittedByUserId: actor.userId, + submittedByLabel: actor.email, + actorId: actor.userId, + actorType: 'user' as const, + } + } + + const shared = await app.prisma.userTreeAccess.findUnique({ + where: { + treeId_userId: { + treeId, + userId: actor.userId, + }, + }, + include: { + tree: { + select: { + deletedAt: true, + settings: { + select: { + contributorPolicy: true, + }, + }, + }, + }, + }, + }) + + if (!shared || shared.tree.deletedAt || shared.role !== 'contributor') { + throw new ContributionRouteError(403, 'forbidden') + } + + const policy = shared.tree.settings?.contributorPolicy ?? 'pending' + + return { + autoStatus: policy === 'direct' ? ('approved' as const) : ('pending' as const), + mode: 'tree_access' as const, + submittedByUserId: actor.userId, + submittedByLabel: actor.email, + actorId: actor.userId, + actorType: 'user' as const, + } +} + +export const contributionRoutes: FastifyPluginAsync = async (app) => { + app.post('/trees/:id/contributions/sessions', async (request, reply) => { + const params = treeIdParamSchema.safeParse(request.params) + if (!params.success) { + return reply.code(400).send({ error: 'invalid_tree_id' }) + } + + const payload = createSessionSchema.safeParse(request.body) + if (!payload.success) { + return reply.code(400).send({ error: 'invalid_payload', details: payload.error.flatten() }) + } + + try { + const context = await resolveSubmissionContext(app, request.actor, params.data.id) + const now = new Date() + const session = await app.prisma.contributionSession.create({ + data: { + treeId: params.data.id, + submittedByUserId: context.submittedByUserId, + submittedByLabel: payload.data.submittedByLabel?.trim() || context.submittedByLabel, + mode: context.mode, + status: context.autoStatus, + title: payload.data.title.trim(), + reviewedAt: context.autoStatus === 'approved' ? now : undefined, + reviewedBy: context.autoStatus === 'approved' ? context.submittedByUserId : undefined, + }, + }) + + const createdChanges = [] + + for (const change of payload.data.changes) { + let effectiveEntityId = change.entityId ?? null + + if (context.autoStatus === 'approved') { + const applied = await applyContributionChange(app.prisma, params.data.id, change, context.submittedByUserId) + effectiveEntityId = applied.entityId + } + + const createdChange = await app.prisma.contributionChange.create({ + data: { + sessionId: session.id, + entityType: change.entityType, + entityId: effectiveEntityId, + action: change.action, + beforeJson: toPrismaJson(change.before), + afterJson: toPrismaJson(change.after), + conflictState: change.conflictState ?? 'none', + decision: context.autoStatus === 'approved' ? 'approved' : undefined, + }, + }) + + createdChanges.push(createdChange) + } + + await app.prisma.auditLog.create({ + data: { + treeId: params.data.id, + actorType: context.actorType, + actorId: context.actorId, + action: 'contribution_session_submitted', + entityType: 'contribution_session', + entityId: session.id, + payloadJson: { + status: context.autoStatus, + changesCount: createdChanges.length, + }, + }, + }) + + return reply.code(201).send({ + session, + changesCount: createdChanges.length, + }) + } catch (error) { + if (error instanceof ContributionRouteError) { + return reply.code(error.statusCode).send({ error: error.code }) + } + + throw error + } + }) + + app.get('/trees/:id/contributions/sessions', async (request, reply) => { + const params = treeIdParamSchema.safeParse(request.params) + if (!params.success) { + return reply.code(400).send({ error: 'invalid_tree_id' }) + } + + const query = listSessionsQuerySchema.safeParse(request.query) + if (!query.success) { + return reply.code(400).send({ error: 'invalid_query', details: query.error.flatten() }) + } + + try { + await requireAdminMembership(app, request.actor, params.data.id) + + const sessions = await app.prisma.contributionSession.findMany({ + where: { + treeId: params.data.id, + status: query.data.status ?? 'pending', + }, + include: { + changes: { + select: { + id: true, + entityType: true, + entityId: true, + action: true, + beforeJson: true, + afterJson: true, + conflictState: true, + decision: true, + }, + }, + }, + orderBy: { + createdAt: 'asc', + }, + }) + + return reply.send({ sessions }) + } catch (error) { + if (error instanceof ContributionRouteError) { + return reply.code(error.statusCode).send({ error: error.code }) + } + + throw error + } + }) + + app.post('/trees/:id/contributions/sessions/:sessionId/review', async (request, reply) => { + const params = sessionParamsSchema.safeParse(request.params) + if (!params.success) { + return reply.code(400).send({ error: 'invalid_params' }) + } + + const payload = reviewSessionSchema.safeParse(request.body) + if (!payload.success) { + return reply.code(400).send({ error: 'invalid_payload', details: payload.error.flatten() }) + } + + try { + const membership = await requireAdminMembership(app, request.actor, params.data.id) + await assertTreeIsActive(app.prisma, params.data.id) + + const session = await app.prisma.contributionSession.findFirst({ + where: { + id: params.data.sessionId, + treeId: params.data.id, + }, + include: { + changes: true, + }, + }) + + if (!session) { + throw new ContributionRouteError(404, 'contribution_session_not_found') + } + + if (session.status !== 'pending') { + throw new ContributionRouteError(409, 'contribution_session_already_reviewed') + } + + const explicitDecisions = new Map() + for (const changeDecision of payload.data.changeDecisions ?? []) { + explicitDecisions.set(changeDecision.changeId, changeDecision.decision) + } + + let approvedCount = 0 + let rejectedCount = 0 + let conflictCount = 0 + + for (const change of session.changes) { + let decision = explicitDecisions.get(change.id) ?? payload.data.decision + + const hasConflict = await detectConflict(app.prisma, params.data.id, { + entityType: change.entityType, + action: change.action, + entityId: change.entityId, + beforeJson: change.beforeJson, + }) + + if (hasConflict && change.conflictState !== 'needs_review') { + await app.prisma.contributionChange.update({ + where: { id: change.id }, + data: { conflictState: 'needs_review' }, + }) + conflictCount += 1 + } + + if (decision === 'approved') { + await applyContributionChange( + app.prisma, + params.data.id, + { + entityType: change.entityType, + action: change.action, + entityId: change.entityId, + before: change.beforeJson, + after: change.afterJson, + conflictState: change.conflictState, + }, + membership.userId, + ) + + approvedCount += 1 + } else { + rejectedCount += 1 + } + + await app.prisma.contributionChange.update({ + where: { + id: change.id, + }, + data: { + decision, + }, + }) + } + + const status: ContributionStatus = approvedCount > 0 ? 'approved' : 'rejected' + + const updatedSession = await app.prisma.contributionSession.update({ + where: { + id: session.id, + }, + data: { + status, + reviewedAt: new Date(), + reviewedBy: membership.userId, + }, + }) + + await app.prisma.auditLog.create({ + data: { + treeId: params.data.id, + actorType: 'user', + actorId: membership.userId, + action: 'contribution_session_reviewed', + entityType: 'contribution_session', + entityId: session.id, + payloadJson: { + approvedCount, + rejectedCount, + conflictCount, + finalStatus: status, + }, + }, + }) + + return reply.send({ + session: updatedSession, + approvedCount, + rejectedCount, + conflictCount, + }) + } catch (error) { + if (error instanceof ContributionRouteError) { + return reply.code(error.statusCode).send({ error: error.code }) + } + + throw error + } + }) +} diff --git a/backend/src/routes/gedcom.ts b/backend/src/routes/gedcom.ts new file mode 100644 index 0000000..8f52df6 --- /dev/null +++ b/backend/src/routes/gedcom.ts @@ -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 = { + 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() + 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() + 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() + 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>() + 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() + 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() + 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() + 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() + + 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 }) + }) +} diff --git a/backend/src/routes/graph.ts b/backend/src/routes/graph.ts new file mode 100644 index 0000000..9e0c15e --- /dev/null +++ b/backend/src/routes/graph.ts @@ -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, + }) + }) +} diff --git a/backend/src/routes/media.ts b/backend/src/routes/media.ts new file mode 100644 index 0000000..d50823a --- /dev/null +++ b/backend/src/routes/media.ts @@ -0,0 +1,1088 @@ +import { randomUUID } from 'node:crypto' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' + +import type { FastifyPluginAsync, FastifyRequest } from 'fastify' +import { z } from 'zod' + +import { env } from '../config/env.js' +import { isUserTokenRevoked } from '../lib/token-revocation.js' +import type { AnyJwtPayload, MembershipRole } from '../types/auth.js' + +const MAX_IMAGE_BYTES = 5 * 1024 * 1024 +const MAX_FILE_BYTES = 20 * 1024 * 1024 + +const treeIdParamSchema = z.object({ + id: z.string().min(1), +}) + +const treePersonParamsSchema = z.object({ + id: z.string().min(1), + personId: z.string().min(1), +}) + +const mediaParamsSchema = z.object({ + id: z.string().min(1), + mediaId: z.string().min(1), +}) + +const mediaSourceSchema = z.string().max(500).optional().nullable() + +const downloadQuerySchema = z.object({ + token: z.string().min(1).optional(), +}) + +const uploadBinaryMediaSchema = z.object({ + type: z.enum(['photo', 'video', 'audio', 'document', 'geojson', 'gpx']), + fileName: z.string().min(1).max(255), + mimeType: z.string().min(1).max(120), + sizeBytes: z.number().int().positive().max(MAX_FILE_BYTES), + dataBase64: z.string().min(1), + caption: z.string().max(280).optional().nullable(), + source: mediaSourceSchema, +}) + +const uploadCitationMediaSchema = z.object({ + type: z.literal('citation'), + caption: z.string().trim().min(1).max(2000), + source: mediaSourceSchema, +}) + +const YOUTUBE_URL_RE = /^https:\/\/(www\.)?(youtube\.com|youtu\.be)\// + +const uploadYoutubeVideoSchema = z.object({ + type: z.literal('video'), + mimeType: z.literal('video/youtube'), + youtubeUrl: z.string().regex(YOUTUBE_URL_RE), + caption: z.string().max(280).optional().nullable(), + source: mediaSourceSchema, +}) + +const uploadMediaSchema = z.union([uploadBinaryMediaSchema, uploadCitationMediaSchema, uploadYoutubeVideoSchema]) + +const uploadAvatarSchema = z.object({ + fileName: z.string().min(1).max(255), + mimeType: z.string().min(1).max(120), + sizeBytes: z.number().int().positive().max(MAX_IMAGE_BYTES), + dataBase64: z.string().min(1), +}) + +const reorderMediaSchema = z.object({ + orderedMediaIds: z.array(z.string().min(1)).min(1), +}) + +function hasAdminRight(role: MembershipRole): boolean { + return role === 'owner' || role === 'admin' +} + +function getStorageRoot(): string { + return path.resolve(env.MEDIA_STORAGE_PATH) +} + +function stripDataUrlPrefix(value: string): string { + const index = value.indexOf(',') + if (value.startsWith('data:') && index >= 0) { + return value.slice(index + 1) + } + + return value +} + +function normalizeOptionalText(value: string | null | undefined): string | null { + if (typeof value !== 'string') { + return null + } + + const normalized = value.trim() + return normalized.length > 0 ? normalized : null +} + +function inferFileExtension(fileName: string, mimeType: string): string { + const rawExt = path.extname(fileName).toLowerCase().replace(/[^.a-z0-9]/g, '') + if (rawExt.length >= 2 && rawExt.length <= 8) { + return rawExt + } + + const map: Record = { + 'image/jpeg': '.jpg', + 'image/png': '.png', + 'image/webp': '.webp', + 'image/gif': '.gif', + 'image/svg+xml': '.svg', + 'video/mp4': '.mp4', + 'audio/mpeg': '.mp3', + 'audio/wav': '.wav', + 'application/pdf': '.pdf', + 'application/geo+json': '.geojson', + 'application/gpx+xml': '.gpx', + 'application/xml': '.xml', + 'text/xml': '.xml', + 'application/json': '.json', + 'text/plain': '.txt', + } + + return map[mimeType] || '.bin' +} + +function isMimeTypeAllowedForMediaType(type: 'photo' | 'video' | 'audio' | 'document' | 'geojson' | 'gpx', mimeType: string): boolean { + const normalizedMimeType = mimeType.toLowerCase() + + if (type === 'photo') { + return normalizedMimeType.startsWith('image/') + } + + if (type === 'video') { + return normalizedMimeType.startsWith('video/') + } + + if (type === 'audio') { + return normalizedMimeType.startsWith('audio/') + } + + if (type === 'geojson') { + return ( + normalizedMimeType === 'application/geo+json' || + normalizedMimeType === 'application/json' || + normalizedMimeType === 'text/geojson' || + normalizedMimeType === 'text/plain' + ) + } + + if (type === 'gpx') { + return ( + normalizedMimeType === 'application/gpx+xml' || + normalizedMimeType === 'application/xml' || + normalizedMimeType === 'text/xml' || + normalizedMimeType === 'text/plain' || + normalizedMimeType === 'application/octet-stream' + ) + } + + return ( + normalizedMimeType === 'application/pdf' || + normalizedMimeType === 'application/msword' || + normalizedMimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || + normalizedMimeType === 'text/plain' || + normalizedMimeType === 'application/octet-stream' + ) +} + +async function syncPersonMediaOrder( + app: FastifyRequest['server'], + treeId: string, + personId: string, +): Promise { + const media = await app.prisma.mediaItem.findMany({ + where: { + treeId, + personId, + deletedAt: null, + }, + orderBy: [{ displayOrder: 'asc' }, { createdAt: 'asc' }, { id: 'asc' }], + select: { + id: true, + }, + }) + + for (let index = 0; index < media.length; index += 1) { + const item = media[index] + await app.prisma.mediaItem.update({ + where: { + id: item.id, + }, + data: { + displayOrder: index + 1, + isFeatured: index < 3, + }, + }) + } +} + +async function requireAdminMembership(app: FastifyRequest['server'], userId: string, treeId: string) { + const membership = await app.prisma.treeMembership.findUnique({ + where: { + treeId_userId: { + treeId, + userId, + }, + }, + include: { + tree: { + select: { + deletedAt: true, + }, + }, + }, + }) + + if (!membership || membership.tree.deletedAt || !hasAdminRight(membership.role)) { + return null + } + + return membership +} + +async function assertTreeReadableByActor(app: FastifyRequest['server'], treeId: string, actor: FastifyRequest['actor']): Promise { + if (!actor) { + return false + } + + if (actor.kind === 'user') { + 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 true + } + + const shared = await app.prisma.userTreeAccess.findUnique({ + where: { + treeId_userId: { + treeId, + userId: actor.userId, + }, + }, + include: { + tree: { + select: { + deletedAt: true, + }, + }, + }, + }) + + return Boolean(shared && !shared.tree.deletedAt) + } + + if (actor.treeId !== treeId) { + return false + } + + const tree = await app.prisma.tree.findFirst({ + where: { + id: treeId, + deletedAt: null, + }, + select: { + id: true, + }, + }) + + return Boolean(tree) +} + +async function assertTreeReadableByQueryToken(app: FastifyRequest['server'], treeId: string, token: string): Promise { + let payload: AnyJwtPayload + + try { + payload = await app.jwt.verify(token) + } catch { + return false + } + + if (payload.kind === 'user') { + if (isUserTokenRevoked(payload.jti)) { + return false + } + + const membership = await app.prisma.treeMembership.findUnique({ + where: { + treeId_userId: { + treeId, + userId: payload.userId, + }, + }, + include: { + tree: { + select: { + deletedAt: true, + }, + }, + }, + }) + + if (membership && !membership.tree.deletedAt) { + return true + } + + const shared = await app.prisma.userTreeAccess.findUnique({ + where: { + treeId_userId: { + treeId, + userId: payload.userId, + }, + }, + include: { + tree: { + select: { + deletedAt: true, + }, + }, + }, + }) + + return Boolean(shared && !shared.tree.deletedAt) + } + + if (payload.treeId !== treeId) { + return false + } + + const accessState = await app.prisma.treeAccessPasswords.findUnique({ + where: { + treeId, + }, + select: { + updatedAt: true, + tree: { + select: { + deletedAt: true, + }, + }, + }, + }) + + if (!accessState || accessState.tree.deletedAt) { + return false + } + + return accessState.updatedAt.getTime() === payload.accessVersion +} + +export const mediaRoutes: FastifyPluginAsync = async (app) => { + app.post('/trees/:id/persons/:personId/media', async (request, reply) => { + if (!request.actor || request.actor.kind !== 'user') { + return reply.code(401).send({ error: 'authentication_required' }) + } + + const params = treePersonParamsSchema.safeParse(request.params) + if (!params.success) { + return reply.code(400).send({ error: 'invalid_params' }) + } + + const payload = uploadMediaSchema.safeParse(request.body) + if (!payload.success) { + return reply.code(400).send({ error: 'invalid_payload', details: payload.error.flatten() }) + } + + const membership = await requireAdminMembership(app, request.actor.userId, params.data.id) + if (!membership) { + return reply.code(403).send({ error: 'forbidden' }) + } + + const person = await app.prisma.person.findFirst({ + where: { + id: params.data.personId, + treeId: params.data.id, + deletedAt: null, + }, + select: { + id: true, + }, + }) + + if (!person) { + return reply.code(404).send({ error: 'person_not_found' }) + } + + // --- YouTube video (no file upload) --- + if ('youtubeUrl' in payload.data) { + const mediaCount = await app.prisma.mediaItem.count({ + where: { treeId: params.data.id, personId: params.data.personId, deletedAt: null }, + }) + const normalizedSource = normalizeOptionalText(payload.data.source) + const mediaId = randomUUID() + const media = await app.prisma.mediaItem.create({ + data: { + id: mediaId, + treeId: params.data.id, + personId: params.data.personId, + type: 'video', + mimeType: 'video/youtube', + filePath: payload.data.youtubeUrl, + caption: payload.data.caption ?? null, + source: normalizedSource, + sizeBytes: 0, + displayOrder: mediaCount + 1, + isFeatured: mediaCount < 3, + uploadedBy: request.actor.userId, + }, + }) + await syncPersonMediaOrder(app, params.data.id, params.data.personId) + await app.prisma.auditLog.create({ + data: { + treeId: params.data.id, + actorType: 'user', + actorId: request.actor.userId, + action: 'media_uploaded', + entityType: 'media_item', + entityId: mediaId, + payloadJson: { personId: params.data.personId, type: 'video', youtubeUrl: payload.data.youtubeUrl }, + }, + }) + return reply.code(201).send({ media }) + } + + let fileBuffer: Buffer + let normalizedMimeType: string + let normalizedFileName: string + let normalizedSizeBytes: number + let normalizedCaption: string | null + const normalizedSource = normalizeOptionalText(payload.data.source) + + if (payload.data.type === 'citation') { + const citationText = payload.data.caption.trim() + if (!citationText) { + return reply.code(400).send({ error: 'citation_text_required' }) + } + + fileBuffer = Buffer.from(citationText, 'utf8') + normalizedMimeType = 'text/plain' + normalizedFileName = 'citation.txt' + normalizedSizeBytes = fileBuffer.length + normalizedCaption = citationText + } else { + if (!isMimeTypeAllowedForMediaType(payload.data.type, payload.data.mimeType)) { + return reply.code(400).send({ error: 'invalid_media_mime_type' }) + } + + const encoded = stripDataUrlPrefix(payload.data.dataBase64) + try { + fileBuffer = Buffer.from(encoded, 'base64') + } catch { + return reply.code(400).send({ error: 'invalid_base64_data' }) + } + + if (!fileBuffer.length) { + return reply.code(400).send({ error: 'empty_media_file' }) + } + + if (fileBuffer.length !== payload.data.sizeBytes) { + return reply.code(400).send({ error: 'size_mismatch' }) + } + + if (payload.data.type === 'photo' && fileBuffer.length > MAX_IMAGE_BYTES) { + return reply.code(400).send({ error: 'image_too_large' }) + } + + normalizedMimeType = payload.data.mimeType + normalizedFileName = payload.data.fileName + normalizedSizeBytes = payload.data.sizeBytes + normalizedCaption = payload.data.caption ?? null + } + + const extension = inferFileExtension(normalizedFileName, normalizedMimeType) + const mediaId = randomUUID() + const relativePath = path.posix.join(params.data.id, params.data.personId, `${mediaId}${extension}`) + const storageRoot = getStorageRoot() + const absolutePath = path.resolve(storageRoot, relativePath) + + if (!absolutePath.startsWith(storageRoot)) { + return reply.code(400).send({ error: 'invalid_media_path' }) + } + + await mkdir(path.dirname(absolutePath), { recursive: true }) + await writeFile(absolutePath, fileBuffer) + + const mediaCount = await app.prisma.mediaItem.count({ + where: { + treeId: params.data.id, + personId: params.data.personId, + deletedAt: null, + }, + }) + + const media = await app.prisma.mediaItem.create({ + data: { + id: mediaId, + treeId: params.data.id, + personId: params.data.personId, + type: payload.data.type, + mimeType: normalizedMimeType, + filePath: relativePath, + caption: normalizedCaption, + source: normalizedSource, + sizeBytes: normalizedSizeBytes, + displayOrder: mediaCount + 1, + isFeatured: mediaCount < 3, + uploadedBy: request.actor.userId, + }, + }) + + await syncPersonMediaOrder(app, params.data.id, params.data.personId) + + await app.prisma.auditLog.create({ + data: { + treeId: params.data.id, + actorType: 'user', + actorId: request.actor.userId, + action: 'media_uploaded', + entityType: 'media_item', + entityId: mediaId, + payloadJson: { + personId: params.data.personId, + type: payload.data.type, + fileName: normalizedFileName, + sizeBytes: normalizedSizeBytes, + }, + }, + }) + + return reply.code(201).send({ media }) + }) + + app.post('/trees/:id/persons/:personId/avatar', async (request, reply) => { + if (!request.actor || request.actor.kind !== 'user') { + return reply.code(401).send({ error: 'authentication_required' }) + } + + const params = treePersonParamsSchema.safeParse(request.params) + if (!params.success) { + return reply.code(400).send({ error: 'invalid_params' }) + } + + const payload = uploadAvatarSchema.safeParse(request.body) + if (!payload.success) { + return reply.code(400).send({ error: 'invalid_payload', details: payload.error.flatten() }) + } + + const membership = await requireAdminMembership(app, request.actor.userId, params.data.id) + if (!membership) { + return reply.code(403).send({ error: 'forbidden' }) + } + + const person = await app.prisma.person.findFirst({ + where: { + id: params.data.personId, + treeId: params.data.id, + deletedAt: null, + }, + select: { + id: true, + }, + }) + + if (!person) { + return reply.code(404).send({ error: 'person_not_found' }) + } + + if (!payload.data.mimeType.startsWith('image/')) { + return reply.code(400).send({ error: 'invalid_avatar_mime_type' }) + } + + const encoded = stripDataUrlPrefix(payload.data.dataBase64) + let fileBuffer: Buffer + + try { + fileBuffer = Buffer.from(encoded, 'base64') + } catch { + return reply.code(400).send({ error: 'invalid_base64_data' }) + } + + if (!fileBuffer.length) { + return reply.code(400).send({ error: 'empty_media_file' }) + } + + if (fileBuffer.length !== payload.data.sizeBytes) { + return reply.code(400).send({ error: 'size_mismatch' }) + } + + if (fileBuffer.length > MAX_IMAGE_BYTES) { + return reply.code(400).send({ error: 'image_too_large' }) + } + + const extension = inferFileExtension(payload.data.fileName, payload.data.mimeType) + const relativePath = path.posix.join( + params.data.id, + params.data.personId, + `avatar-${randomUUID()}${extension}`, + ) + const storageRoot = getStorageRoot() + const absolutePath = path.resolve(storageRoot, relativePath) + + if (!absolutePath.startsWith(storageRoot)) { + return reply.code(400).send({ error: 'invalid_media_path' }) + } + + await mkdir(path.dirname(absolutePath), { recursive: true }) + await writeFile(absolutePath, fileBuffer) + + await app.prisma.person.updateMany({ + where: { + id: params.data.personId, + treeId: params.data.id, + deletedAt: null, + }, + data: { + avatarPath: relativePath, + avatarMimeType: payload.data.mimeType, + updatedBy: request.actor.userId, + }, + }) + + await app.prisma.auditLog.create({ + data: { + treeId: params.data.id, + actorType: 'user', + actorId: request.actor.userId, + action: 'person_avatar_updated', + entityType: 'person', + entityId: params.data.personId, + }, + }) + + return reply.code(201).send({ ok: true }) + }) + + app.delete('/trees/:id/persons/:personId/avatar', async (request, reply) => { + if (!request.actor || request.actor.kind !== 'user') { + return reply.code(401).send({ error: 'authentication_required' }) + } + + const params = treePersonParamsSchema.safeParse(request.params) + if (!params.success) { + return reply.code(400).send({ error: 'invalid_params' }) + } + + const membership = await requireAdminMembership(app, request.actor.userId, params.data.id) + if (!membership) { + return reply.code(403).send({ error: 'forbidden' }) + } + + const updated = await app.prisma.person.updateMany({ + where: { + id: params.data.personId, + treeId: params.data.id, + deletedAt: null, + }, + data: { + avatarPath: null, + avatarMimeType: null, + updatedBy: request.actor.userId, + }, + }) + + if (updated.count === 0) { + return reply.code(404).send({ error: 'person_not_found' }) + } + + await app.prisma.auditLog.create({ + data: { + treeId: params.data.id, + actorType: 'user', + actorId: request.actor.userId, + action: 'person_avatar_removed', + entityType: 'person', + entityId: params.data.personId, + }, + }) + + return reply.send({ ok: true }) + }) + + app.patch('/trees/:id/persons/:personId/media/reorder', async (request, reply) => { + if (!request.actor || request.actor.kind !== 'user') { + return reply.code(401).send({ error: 'authentication_required' }) + } + + const params = treePersonParamsSchema.safeParse(request.params) + if (!params.success) { + return reply.code(400).send({ error: 'invalid_params' }) + } + + const payload = reorderMediaSchema.safeParse(request.body) + if (!payload.success) { + return reply.code(400).send({ error: 'invalid_payload', details: payload.error.flatten() }) + } + + const membership = await requireAdminMembership(app, request.actor.userId, params.data.id) + if (!membership) { + return reply.code(403).send({ error: 'forbidden' }) + } + + const existing = await app.prisma.mediaItem.findMany({ + where: { + treeId: params.data.id, + personId: params.data.personId, + deletedAt: null, + }, + select: { + id: true, + }, + }) + + const existingIds = existing.map((item: { id: string }) => item.id) + const orderedIds = payload.data.orderedMediaIds + + if (existingIds.length !== orderedIds.length) { + return reply.code(400).send({ error: 'invalid_media_order_payload' }) + } + + const existingSet = new Set(existingIds) + const orderedSet = new Set(orderedIds) + if (orderedSet.size !== orderedIds.length || orderedIds.some((id) => !existingSet.has(id))) { + return reply.code(400).send({ error: 'invalid_media_order_payload' }) + } + + for (let index = 0; index < orderedIds.length; index += 1) { + await app.prisma.mediaItem.updateMany({ + where: { + id: orderedIds[index], + treeId: params.data.id, + personId: params.data.personId, + deletedAt: null, + }, + data: { + displayOrder: index + 1, + }, + }) + } + + await syncPersonMediaOrder(app, params.data.id, params.data.personId) + + await app.prisma.auditLog.create({ + data: { + treeId: params.data.id, + actorType: 'user', + actorId: request.actor.userId, + action: 'media_reordered', + entityType: 'media_item', + entityId: params.data.personId, + payloadJson: { + personId: params.data.personId, + orderedMediaIds: payload.data.orderedMediaIds, + }, + }, + }) + + return reply.send({ ok: true }) + }) + + app.delete('/trees/:id/persons/:personId/media/:mediaId', async (request, reply) => { + if (!request.actor || request.actor.kind !== 'user') { + return reply.code(401).send({ error: 'authentication_required' }) + } + + const params = z + .object({ + id: z.string().min(1), + personId: z.string().min(1), + mediaId: z.string().min(1), + }) + .safeParse(request.params) + + if (!params.success) { + return reply.code(400).send({ error: 'invalid_params' }) + } + + const membership = await requireAdminMembership(app, request.actor.userId, params.data.id) + if (!membership) { + return reply.code(403).send({ error: 'forbidden' }) + } + + const updateResult = await app.prisma.mediaItem.updateMany({ + where: { + id: params.data.mediaId, + treeId: params.data.id, + personId: params.data.personId, + deletedAt: null, + }, + data: { + deletedAt: new Date(), + }, + }) + + if (updateResult.count === 0) { + return reply.code(404).send({ error: 'media_not_found' }) + } + + await syncPersonMediaOrder(app, params.data.id, params.data.personId) + + await app.prisma.auditLog.create({ + data: { + treeId: params.data.id, + actorType: 'user', + actorId: request.actor.userId, + action: 'media_deleted', + entityType: 'media_item', + entityId: params.data.mediaId, + payloadJson: { + personId: params.data.personId, + }, + }, + }) + + return reply.send({ ok: true }) + }) + + app.get('/trees/:id/persons/:personId/avatar', async (request, reply) => { + const params = treePersonParamsSchema.safeParse(request.params) + if (!params.success) { + return reply.code(400).send({ error: 'invalid_params' }) + } + + const query = downloadQuerySchema.safeParse(request.query) + if (!query.success) { + return reply.code(400).send({ error: 'invalid_query' }) + } + + const readableByActor = await assertTreeReadableByActor(app, params.data.id, request.actor) + const readableByQueryToken = query.data.token + ? await assertTreeReadableByQueryToken(app, params.data.id, query.data.token) + : false + + if (!readableByActor && !readableByQueryToken) { + return reply.code(401).send({ error: 'authentication_required' }) + } + + const person = await app.prisma.person.findFirst({ + where: { + id: params.data.personId, + treeId: params.data.id, + deletedAt: null, + }, + select: { + avatarPath: true, + avatarMimeType: true, + }, + }) + + if (!person?.avatarPath) { + return reply.code(404).send({ error: 'avatar_not_found' }) + } + + const storageRoot = getStorageRoot() + const absolutePath = path.resolve(storageRoot, person.avatarPath) + if (!absolutePath.startsWith(storageRoot)) { + return reply.code(400).send({ error: 'invalid_media_path' }) + } + + let fileBuffer: Buffer + try { + fileBuffer = await readFile(absolutePath) + } catch { + return reply.code(404).send({ error: 'media_file_missing' }) + } + + reply.header('Cache-Control', 'private, max-age=300') + reply.type(person.avatarMimeType || 'application/octet-stream') + + return reply.send(fileBuffer) + }) + + app.get('/trees/:id/media/:mediaId', async (request, reply) => { + const params = mediaParamsSchema.safeParse(request.params) + if (!params.success) { + return reply.code(400).send({ error: 'invalid_params' }) + } + + const query = downloadQuerySchema.safeParse(request.query) + if (!query.success) { + return reply.code(400).send({ error: 'invalid_query' }) + } + + const readableByActor = await assertTreeReadableByActor(app, params.data.id, request.actor) + const readableByQueryToken = query.data.token + ? await assertTreeReadableByQueryToken(app, params.data.id, query.data.token) + : false + + if (!readableByActor && !readableByQueryToken) { + return reply.code(401).send({ error: 'authentication_required' }) + } + + const media = await app.prisma.mediaItem.findFirst({ + where: { + id: params.data.mediaId, + treeId: params.data.id, + deletedAt: null, + }, + select: { + filePath: true, + mimeType: true, + }, + }) + + if (!media) { + return reply.code(404).send({ error: 'media_not_found' }) + } + + // YouTube videos: filePath is the YouTube URL, redirect the browser + if (media.mimeType === 'video/youtube') { + return reply.code(302).redirect(media.filePath) + } + + const storageRoot = getStorageRoot() + const absolutePath = path.resolve(storageRoot, media.filePath) + if (!absolutePath.startsWith(storageRoot)) { + return reply.code(400).send({ error: 'invalid_media_path' }) + } + + let fileBuffer: Buffer + try { + fileBuffer = await readFile(absolutePath) + } catch { + return reply.code(404).send({ error: 'media_file_missing' }) + } + + reply.header('Cache-Control', 'private, max-age=300') + reply.type(media.mimeType) + + return reply.send(fileBuffer) + }) + + // ---- Annotation Photos ---- + const MAX_ANNOTATION_PHOTO_BYTES = 3 * 1024 * 1024 + + const uploadAnnotationPhotoSchema = z.object({ + fileName: z.string().min(1).max(255), + mimeType: z.string().min(1).max(120), + sizeBytes: z.number().int().positive().max(MAX_ANNOTATION_PHOTO_BYTES), + dataBase64: z.string().min(1), + }) + + const annotationPhotoParamsSchema = z.object({ + id: z.string().min(1), + photoId: z.string().min(1), + }) + + app.post('/trees/:id/annotation-photos', async (request, reply) => { + if (!request.actor || request.actor.kind !== 'user') { + return reply.code(401).send({ error: 'authentication_required' }) + } + + const params = treeIdParamSchema.safeParse(request.params) + if (!params.success) { + return reply.code(400).send({ error: 'invalid_params' }) + } + + const payload = uploadAnnotationPhotoSchema.safeParse(request.body) + if (!payload.success) { + return reply.code(400).send({ error: 'invalid_payload', details: payload.error.flatten() }) + } + + const membership = await requireAdminMembership(app, request.actor.userId, params.data.id) + if (!membership) { + return reply.code(403).send({ error: 'forbidden' }) + } + + if (!payload.data.mimeType.startsWith('image/')) { + return reply.code(400).send({ error: 'invalid_annotation_photo_mime_type' }) + } + + const encoded = stripDataUrlPrefix(payload.data.dataBase64) + let fileBuffer: Buffer + try { + fileBuffer = Buffer.from(encoded, 'base64') + } catch { + return reply.code(400).send({ error: 'invalid_base64_data' }) + } + + if (!fileBuffer.length) { + return reply.code(400).send({ error: 'empty_photo_file' }) + } + + if (fileBuffer.length !== payload.data.sizeBytes) { + return reply.code(400).send({ error: 'size_mismatch' }) + } + + if (fileBuffer.length > MAX_ANNOTATION_PHOTO_BYTES) { + return reply.code(400).send({ error: 'photo_too_large' }) + } + + const photoId = randomUUID() + const extension = inferFileExtension(payload.data.fileName, payload.data.mimeType) + const relativePath = path.posix.join(params.data.id, 'annotation-photos', `${photoId}${extension}`) + const storageRoot = getStorageRoot() + const absolutePath = path.resolve(storageRoot, relativePath) + + if (!absolutePath.startsWith(storageRoot)) { + return reply.code(400).send({ error: 'invalid_photo_path' }) + } + + await mkdir(path.dirname(absolutePath), { recursive: true }) + await writeFile(absolutePath, fileBuffer) + + return reply.code(201).send({ + photoId, + photoPath: `/trees/${params.data.id}/annotation-photos/${photoId}`, + }) + }) + + app.get('/trees/:id/annotation-photos/:photoId', async (request, reply) => { + const params = annotationPhotoParamsSchema.safeParse(request.params) + if (!params.success) { + return reply.code(400).send({ error: 'invalid_params' }) + } + + const query = downloadQuerySchema.safeParse(request.query) + if (!query.success) { + return reply.code(400).send({ error: 'invalid_query' }) + } + + const readableByActor = await assertTreeReadableByActor(app, params.data.id, request.actor) + const readableByQueryToken = query.data.token + ? await assertTreeReadableByQueryToken(app, params.data.id, query.data.token) + : false + + if (!readableByActor && !readableByQueryToken) { + return reply.code(401).send({ error: 'authentication_required' }) + } + + // Find the file: scan for photoId with any extension + const storageRoot = getStorageRoot() + const photoDir = path.resolve(storageRoot, params.data.id, 'annotation-photos') + const photoIdSafe = params.data.photoId.replace(/[^a-zA-Z0-9-]/g, '') + if (!photoIdSafe) { + return reply.code(400).send({ error: 'invalid_photo_id' }) + } + + // Try common extensions + const extensions = ['.jpg', '.jpeg', '.png', '.webp', '.gif'] + let foundPath: string | null = null + let foundMime = 'image/jpeg' + const mimeMap: Record = { + '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', + '.webp': 'image/webp', '.gif': 'image/gif', + } + + for (const ext of extensions) { + const candidate = path.resolve(photoDir, `${photoIdSafe}${ext}`) + if (!candidate.startsWith(storageRoot)) continue + try { + await readFile(candidate) // existence check + foundPath = candidate + foundMime = mimeMap[ext] || 'image/jpeg' + break + } catch { /* not found, try next */ } + } + + if (!foundPath) { + return reply.code(404).send({ error: 'photo_not_found' }) + } + + let fileBuffer: Buffer + try { + fileBuffer = await readFile(foundPath) + } catch { + return reply.code(404).send({ error: 'photo_file_missing' }) + } + + reply.header('Cache-Control', 'private, max-age=300') + reply.type(foundMime) + + return reply.send(fileBuffer) + }) +} diff --git a/backend/src/routes/public.ts b/backend/src/routes/public.ts new file mode 100644 index 0000000..2470c30 --- /dev/null +++ b/backend/src/routes/public.ts @@ -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, + }, + }) + }) +} diff --git a/backend/src/routes/trees.ts b/backend/src/routes/trees.ts new file mode 100644 index 0000000..ba0b7c5 --- /dev/null +++ b/backend/src/routes/trees.ts @@ -0,0 +1,1981 @@ +import type { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify' +import { Prisma } from '@prisma/client' +import { z } from 'zod' + +import { env } from '../config/env.js' +import { hashPassword, verifyPassword } from '../lib/auth.js' +import type { MembershipRole } from '../types/auth.js' +import { findParentChildValidationError, findUnionValidationError } from '../utils/relationship-guards.js' + +const treeIdParamSchema = z.object({ + id: z.string().min(1), +}) + +const createTreeSchema = z.object({ + name: z.string().min(2).max(120), + slug: z.string().min(1).max(128), + description: z.string().max(500).optional().nullable(), + visitorPassword: z.string().min(8).max(128), + contributorPassword: z.string().min(8).max(128), +}) + +const patchTreeSchema = z + .object({ + name: z.string().min(2).max(120).optional(), + slug: z.string().min(1).max(128).optional(), + description: z.string().max(500).optional().nullable(), + rootPersonId: z.string().min(1).optional().nullable(), + }) + .refine((value) => Object.keys(value).length > 0, { + message: 'at_least_one_field_required', + }) + +const unlockSchema = z.object({ + password: z.string().min(1).max(128), +}) + +const linkAccessSchema = z.object({ + accessToken: z.string().min(1), +}) + +const patchPasswordsSchema = z + .object({ + visitorPassword: z.string().min(8).max(128).optional(), + contributorPassword: z.string().min(8).max(128).optional(), + }) + .refine((value) => Boolean(value.visitorPassword || value.contributorPassword), { + message: 'at_least_one_password_required', + }) + +const patchTreeSettingsSchema = z + .object({ + memberContributionPolicy: z.enum(['direct', 'pending']).optional(), + contributorPolicy: z.enum(['direct', 'pending']).optional(), + }) + .refine((value) => Object.keys(value).length > 0, { + message: 'at_least_one_setting_required', + }) + +const listAuditQuerySchema = z.object({ + limit: z.coerce.number().int().min(1).max(100).default(20), + offset: z.coerce.number().int().min(0).default(0), + action: z.string().min(1).max(120).optional(), +}) + +const isoDateRegex = /^\d{4}-\d{2}-\d{2}$/ + +const createPersonSchema = z.object({ + firstName: z.string().min(1).max(120), + lastName: z.string().min(1).max(120), + birthName: z.string().max(120).optional().nullable(), + birthDate: z.string().regex(isoDateRegex).optional().nullable(), + deathDate: z.string().regex(isoDateRegex).optional().nullable(), + birthPlace: z.string().max(200).optional().nullable(), + profession: z.string().max(200).optional().nullable(), + region: z.string().max(120).optional().nullable(), + nationality: z.string().max(120).optional().nullable(), + sex: z.string().max(20).optional().nullable(), + notes: z.string().max(2000).optional().nullable(), +}) + +const patchPersonSchema = z + .object({ + firstName: z.string().min(1).max(120).optional(), + lastName: z.string().min(1).max(120).optional(), + birthName: z.string().max(120).optional().nullable(), + birthDate: z.string().regex(isoDateRegex).optional().nullable(), + deathDate: z.string().regex(isoDateRegex).optional().nullable(), + birthPlace: z.string().max(200).optional().nullable(), + profession: z.string().max(200).optional().nullable(), + region: z.string().max(120).optional().nullable(), + nationality: z.string().max(120).optional().nullable(), + sex: z.string().max(20).optional().nullable(), + notes: z.string().max(2000).optional().nullable(), + }) + .refine((value) => Object.keys(value).length > 0, { + message: 'at_least_one_field_required', + }) + +const createUnionSchema = z.object({ + partner1PersonId: z.string().min(1), + partner2PersonId: z.string().min(1).optional().nullable(), + unionType: z.string().max(50).optional().nullable(), + startDate: z.string().regex(isoDateRegex).optional().nullable(), + endDate: z.string().regex(isoDateRegex).optional().nullable(), + displayOrder: z.number().int().positive().optional(), +}) + +const patchUnionSchema = z + .object({ + partner1PersonId: z.string().min(1).optional(), + partner2PersonId: z.string().min(1).optional().nullable(), + unionType: z.string().max(50).optional().nullable(), + startDate: z.string().regex(isoDateRegex).optional().nullable(), + endDate: z.string().regex(isoDateRegex).optional().nullable(), + displayOrder: z.number().int().positive().optional(), + }) + .refine((value) => Object.keys(value).length > 0, { + message: 'at_least_one_field_required', + }) + +const createParentChildLinkSchema = z.object({ + parentPersonId: z.string().min(1), + childPersonId: z.string().min(1), + viaUnionId: z.string().min(1).optional().nullable(), + parentageType: z.string().max(50).optional().nullable(), + displayOrder: z.number().int().positive().optional(), +}) + +const patchParentChildLinkSchema = z + .object({ + parentPersonId: z.string().min(1).optional(), + childPersonId: z.string().min(1).optional(), + viaUnionId: z.string().min(1).optional().nullable(), + parentageType: z.string().max(50).optional().nullable(), + displayOrder: z.number().int().positive().optional(), + }) + .refine((value) => Object.keys(value).length > 0, { + message: 'at_least_one_field_required', + }) + +const personParamsSchema = z.object({ + id: z.string().min(1), + personId: z.string().min(1), +}) + +const unionParamsSchema = z.object({ + id: z.string().min(1), + unionId: z.string().min(1), +}) + +const linkParamsSchema = z.object({ + id: z.string().min(1), + linkId: z.string().min(1), +}) + +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 getMembership(app: FastifyInstance, userId: string, treeId: string) { + return app.prisma.treeMembership.findUnique({ + where: { + treeId_userId: { + treeId, + userId, + }, + }, + }) +} + +function toDateOrNull(value?: string | null): Date | null { + if (!value) { + return null + } + + return new Date(`${value}T00:00:00.000Z`) +} + +function toTrimmedStringOrNull(value?: string | null): string | null { + if (value === undefined || value === null) { + return null + } + + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : null +} + +function normalizeSlug(input: string): string { + return input + .trim() + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') +} + +function isValidSlug(value: string): boolean { + return /^[a-z0-9-]{3,64}$/.test(value) +} + +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: + | Prisma.TreeMembershipGetPayload<{ + include: { + tree: { + select: { + deletedAt: true + } + } + } + }> + | null, +): boolean { + return Boolean(membership && !membership.tree.deletedAt && hasAdminRight(membership.role)) +} + +export const treeRoutes: FastifyPluginAsync = async (app) => { + app.get('/trees', async (request, reply) => { + const user = requireUser(request, reply) + if (!user) { + return + } + + const [memberships, sharedAccesses, userState] = await Promise.all([ + app.prisma.treeMembership.findMany({ + where: { + userId: user.userId, + tree: { + deletedAt: null, + }, + }, + include: { + tree: { + select: { + id: true, + slug: true, + name: true, + description: true, + rootPersonId: true, + createdAt: true, + updatedAt: true, + }, + }, + }, + }), + app.prisma.userTreeAccess.findMany({ + where: { + userId: user.userId, + tree: { + deletedAt: null, + }, + }, + include: { + tree: { + select: { + id: true, + slug: true, + name: true, + description: true, + rootPersonId: true, + createdAt: true, + updatedAt: true, + }, + }, + }, + orderBy: { + updatedAt: 'desc', + }, + }), + app.prisma.user.findUnique({ + where: { + id: user.userId, + }, + select: { + lastOpenedTreeId: true, + lastOpenedAccessMode: true, + lastOpenedRole: true, + lastOpenedAt: true, + }, + }), + ]) + + const membershipTreeIds = new Set(memberships.map((membership: { treeId: string }) => membership.treeId)) + const mergedTrees: Array<{ + id: string + slug: string + name: string + description: string | null + rootPersonId: string | null + createdAt: Date + updatedAt: Date + role: string + accessMode: 'member' | 'share' + lastAccessedAt: Date | null + }> = [] + + for (const membership of memberships) { + mergedTrees.push({ + ...membership.tree, + role: membership.role, + accessMode: 'member', + lastAccessedAt: null, + }) + } + + for (const shared of sharedAccesses) { + if (membershipTreeIds.has(shared.treeId)) { + continue + } + + mergedTrees.push({ + ...shared.tree, + role: shared.role, + accessMode: 'share', + lastAccessedAt: shared.updatedAt, + }) + } + + mergedTrees.sort((left, right) => { + const leftTime = left.lastAccessedAt?.getTime() ?? left.updatedAt.getTime() + const rightTime = right.lastAccessedAt?.getTime() ?? right.updatedAt.getTime() + return rightTime - leftTime + }) + + const lastOpenedTree = userState?.lastOpenedTreeId + ? mergedTrees.find((tree) => { + if (tree.id !== userState.lastOpenedTreeId) return false + if (!userState.lastOpenedAccessMode) return true + return tree.accessMode === userState.lastOpenedAccessMode + }) + : null + + return reply.send({ + trees: mergedTrees, + lastOpenedTree: lastOpenedTree + ? { + treeId: lastOpenedTree.id, + accessMode: lastOpenedTree.accessMode, + role: userState?.lastOpenedRole || lastOpenedTree.role, + lastOpenedAt: userState?.lastOpenedAt || lastOpenedTree.lastAccessedAt || null, + } + : null, + }) + }) + + app.post('/trees', async (request, reply) => { + const user = requireUser(request, reply) + if (!user) { + return + } + + const parsed = createTreeSchema.safeParse(request.body) + if (!parsed.success) { + return reply.code(400).send({ error: 'invalid_payload', details: parsed.error.flatten() }) + } + + const normalizedSlug = normalizeSlug(parsed.data.slug) + if (!isValidSlug(normalizedSlug)) { + return reply.code(400).send({ + error: 'invalid_payload', + details: { + formErrors: [], + fieldErrors: { + slug: ['Slug invalide apres normalisation. Utiliser 3-64 caracteres alphanumeriques ou tirets.'], + }, + }, + }) + } + + const visitorHash = await hashPassword(parsed.data.visitorPassword) + const contributorHash = await hashPassword(parsed.data.contributorPassword) + + try { + const tree = await app.prisma.tree.create({ + data: { + ownerUserId: user.userId, + name: parsed.data.name, + slug: normalizedSlug, + description: parsed.data.description ?? null, + accessPasswords: { + create: { + visitorHash, + contributorHash, + }, + }, + memberships: { + create: { + userId: user.userId, + role: 'owner', + }, + }, + settings: { + create: { + memberContributionPolicy: 'pending', + visitorPolicy: 'read_only', + contributorPolicy: 'pending', + }, + }, + }, + }) + + await app.prisma.user.update({ + where: { + id: user.userId, + }, + data: { + lastOpenedTreeId: tree.id, + lastOpenedAccessMode: 'member', + lastOpenedRole: 'owner', + lastOpenedAt: new Date(), + }, + }) + + return reply.code(201).send({ tree }) + } catch (error: unknown) { + if (typeof error === 'object' && error !== null && 'code' in error && (error as { code?: string }).code === 'P2002') { + return reply.code(409).send({ error: 'slug_already_exists' }) + } + + throw error + } + }) + + app.get('/trees/:id', async (request, reply) => { + const user = requireUser(request, reply) + if (!user) { + return + } + + const params = treeIdParamSchema.safeParse(request.params) + if (!params.success) { + return reply.code(400).send({ error: 'invalid_tree_id' }) + } + + const membership = await getMembership(app, user.userId, params.data.id) + if (!membership) { + return reply.code(403).send({ error: 'forbidden' }) + } + + const tree = await app.prisma.tree.findFirst({ + where: { + id: params.data.id, + deletedAt: null, + }, + include: { + settings: true, + }, + }) + + if (!tree) { + return reply.code(404).send({ error: 'tree_not_found' }) + } + + return reply.send({ + tree, + role: membership.role, + }) + }) + + app.patch('/trees/:id', 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 = patchTreeSchema.safeParse(request.body) + if (!payload.success) { + return reply.code(400).send({ error: 'invalid_payload', details: payload.error.flatten() }) + } + + const membership = await getMembership(app, user.userId, params.data.id) + if (!membership || !hasAdminRight(membership.role)) { + return reply.code(403).send({ error: 'forbidden' }) + } + + try { + const updateData: { + name?: string + slug?: string + description?: string | null + rootPersonId?: string | null + } = {} + + if (payload.data.name !== undefined) { + updateData.name = payload.data.name + } + + if (payload.data.slug !== undefined) { + const normalizedSlug = normalizeSlug(payload.data.slug) + if (!isValidSlug(normalizedSlug)) { + return reply.code(400).send({ + error: 'invalid_payload', + details: { + formErrors: [], + fieldErrors: { + slug: ['Slug invalide apres normalisation. Utiliser 3-64 caracteres alphanumeriques ou tirets.'], + }, + }, + }) + } + + updateData.slug = normalizedSlug + } + + if (payload.data.description !== undefined) { + updateData.description = payload.data.description + } + + if (payload.data.rootPersonId !== undefined) { + if (payload.data.rootPersonId === null) { + updateData.rootPersonId = null + } else { + const rootPerson = await app.prisma.person.findFirst({ + where: { + id: payload.data.rootPersonId, + treeId: params.data.id, + deletedAt: null, + }, + select: { + id: true, + }, + }) + + if (!rootPerson) { + return reply.code(400).send({ error: 'invalid_root_person_id' }) + } + + updateData.rootPersonId = payload.data.rootPersonId + } + } + + const changedFields = Object.keys(updateData) + const updateResult = await app.prisma.tree.updateMany({ + where: { + id: params.data.id, + deletedAt: null, + }, + data: updateData, + }) + + if (updateResult.count === 0) { + return reply.code(404).send({ error: 'tree_not_found' }) + } + + const updated = await app.prisma.tree.findUnique({ + where: { id: params.data.id }, + }) + + await app.prisma.auditLog.create({ + data: { + treeId: params.data.id, + actorType: 'user', + actorId: user.userId, + action: 'tree_updated', + entityType: 'tree', + entityId: params.data.id, + payloadJson: { + changedFields, + }, + }, + }) + + return reply.send({ tree: updated }) + } catch (error: unknown) { + if (typeof error === 'object' && error !== null && 'code' in error && (error as { code?: string }).code === 'P2002') { + return reply.code(409).send({ error: 'slug_already_exists' }) + } + + throw error + } + }) + + app.delete('/trees/:id', async (request, reply) => { + const user = requireUser(request, reply) + if (!user) { + return + } + + const params = treeIdParamSchema.safeParse(request.params) + if (!params.success) { + return reply.code(400).send({ error: 'invalid_tree_id' }) + } + + const membership = await getMembership(app, user.userId, params.data.id) + if (!membership || membership.role !== 'owner') { + return reply.code(403).send({ error: 'forbidden' }) + } + + const now = new Date() + const deletedTree = await app.prisma.tree.updateMany({ + where: { + id: params.data.id, + deletedAt: null, + }, + data: { + deletedAt: now, + }, + }) + + if (deletedTree.count === 0) { + return reply.code(404).send({ error: 'tree_not_found' }) + } + + await app.prisma.user.updateMany({ + where: { + lastOpenedTreeId: params.data.id, + }, + data: { + lastOpenedTreeId: null, + lastOpenedAccessMode: null, + lastOpenedRole: null, + lastOpenedAt: null, + }, + }) + + await app.prisma.auditLog.create({ + data: { + treeId: params.data.id, + actorType: 'user', + actorId: user.userId, + action: 'tree_deleted', + entityType: 'tree', + entityId: params.data.id, + }, + }) + + return reply.send({ ok: true }) + }) + + app.post( + '/trees/:id/access/unlock', + { + config: { + rateLimit: { + max: 10, + timeWindow: '1 minute', + }, + }, + }, + async (request, reply) => { + const params = treeIdParamSchema.safeParse(request.params) + if (!params.success) { + return reply.code(400).send({ error: 'invalid_tree_id' }) + } + + const payload = unlockSchema.safeParse(request.body) + if (!payload.success) { + return reply.code(400).send({ error: 'invalid_payload', details: payload.error.flatten() }) + } + + const accessConfig = await app.prisma.treeAccessPasswords.findUnique({ + where: { + treeId: params.data.id, + }, + include: { + tree: { + select: { + deletedAt: true, + }, + }, + }, + }) + + if (!accessConfig || accessConfig.tree.deletedAt) { + return reply.code(404).send({ error: 'tree_not_found' }) + } + + let role: 'visitor' | 'contributor' | null = null + if (await verifyPassword(payload.data.password, accessConfig.contributorHash)) { + role = 'contributor' + } else if (await verifyPassword(payload.data.password, accessConfig.visitorHash)) { + role = 'visitor' + } + + if (!role) { + return reply.code(401).send({ error: 'invalid_password' }) + } + + const token = app.jwt.sign( + { + kind: 'tree_access', + sub: `tree:${params.data.id}:${role}`, + treeId: params.data.id, + role, + accessVersion: accessConfig.updatedAt.getTime(), + }, + { + expiresIn: env.JWT_TREE_ACCESS_EXPIRES_IN, + }, + ) + + if (request.actor && request.actor.kind === 'user') { + const now = new Date() + + await app.prisma.userTreeAccess.upsert({ + where: { + treeId_userId: { + treeId: params.data.id, + userId: request.actor.userId, + }, + }, + create: { + treeId: params.data.id, + userId: request.actor.userId, + role, + }, + update: { + role, + updatedAt: now, + }, + }) + + await app.prisma.user.update({ + where: { + id: request.actor.userId, + }, + data: { + lastOpenedTreeId: params.data.id, + lastOpenedAccessMode: 'share', + lastOpenedRole: role, + lastOpenedAt: now, + }, + }) + } + + return reply.send({ + token, + treeId: params.data.id, + role, + }) + }, + ) + + // Rattache au compte connecté un accès partagé déjà ouvert : le jeton d'arbre (obtenu par mot de passe) + // tient lieu de preuve, on ne redemande pas le mot de passe. Même effet que l'unlock avec un compte. + app.post('/trees/:id/access/link', 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 = linkAccessSchema.safeParse(request.body) + if (!payload.success) { + return reply.code(400).send({ error: 'invalid_payload', details: payload.error.flatten() }) + } + + let accessPayload: { kind?: string; treeId?: string; role?: string; accessVersion?: number } + try { + accessPayload = app.jwt.verify(payload.data.accessToken) + } catch { + return reply.code(401).send({ error: 'invalid_access_token' }) + } + + const role = accessPayload.role === 'contributor' || accessPayload.role === 'visitor' ? accessPayload.role : null + if (accessPayload.kind !== 'tree_access' || accessPayload.treeId !== params.data.id || !role) { + return reply.code(401).send({ error: 'invalid_access_token' }) + } + + const accessConfig = await app.prisma.treeAccessPasswords.findUnique({ + where: { + treeId: params.data.id, + }, + include: { + tree: { + select: { + deletedAt: true, + }, + }, + }, + }) + + if (!accessConfig || accessConfig.tree.deletedAt) { + return reply.code(404).send({ error: 'tree_not_found' }) + } + + if (accessConfig.updatedAt.getTime() !== accessPayload.accessVersion) { + return reply.code(401).send({ error: 'invalid_access_token' }) + } + + const now = new Date() + await app.prisma.userTreeAccess.upsert({ + where: { + treeId_userId: { + treeId: params.data.id, + userId: user.userId, + }, + }, + create: { + treeId: params.data.id, + userId: user.userId, + role, + }, + update: { + role, + updatedAt: now, + }, + }) + + await app.prisma.user.update({ + where: { + id: user.userId, + }, + data: { + lastOpenedTreeId: params.data.id, + lastOpenedAccessMode: 'share', + lastOpenedRole: role, + lastOpenedAt: now, + }, + }) + + return reply.send({ treeId: params.data.id, role }) + }) + + app.patch('/trees/:id/access/passwords', 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 = patchPasswordsSchema.safeParse(request.body) + if (!payload.success) { + return reply.code(400).send({ error: 'invalid_payload', details: payload.error.flatten() }) + } + + const membership = await getMembership(app, user.userId, params.data.id) + if (!membership || !hasAdminRight(membership.role)) { + return reply.code(403).send({ error: 'forbidden' }) + } + + const existing = await app.prisma.treeAccessPasswords.findUnique({ + where: { + treeId: params.data.id, + }, + }) + + if (!existing) { + return reply.code(404).send({ error: 'tree_not_found' }) + } + + const data: { visitorHash?: string; contributorHash?: string } = {} + if (payload.data.visitorPassword) { + data.visitorHash = await hashPassword(payload.data.visitorPassword) + } + + if (payload.data.contributorPassword) { + data.contributorHash = await hashPassword(payload.data.contributorPassword) + } + + await app.prisma.treeAccessPasswords.update({ + where: { + treeId: params.data.id, + }, + data, + }) + + await app.prisma.auditLog.create({ + data: { + treeId: params.data.id, + actorType: 'user', + actorId: user.userId, + action: 'access_passwords_rotated', + entityType: 'tree_access_passwords', + entityId: params.data.id, + payloadJson: { + rotatedVisitorPassword: Boolean(payload.data.visitorPassword), + rotatedContributorPassword: Boolean(payload.data.contributorPassword), + }, + }, + }) + + return reply.send({ ok: true }) + }) + + app.get('/trees/:id/stats', async (request, reply) => { + const user = requireUser(request, reply) + if (!user) { + return + } + + const params = treeIdParamSchema.safeParse(request.params) + if (!params.success) { + return reply.code(400).send({ error: 'invalid_tree_id' }) + } + + const membership = await getAdminMembership(app, user.userId, params.data.id) + if (!hasActiveAdminMembership(membership)) { + return reply.code(403).send({ error: 'forbidden' }) + } + + const [persons, unions, links, medias, pendingContributions, totalContributions] = await Promise.all([ + app.prisma.person.count({ + where: { + treeId: params.data.id, + deletedAt: null, + }, + }), + app.prisma.union.count({ + where: { + treeId: params.data.id, + deletedAt: null, + }, + }), + app.prisma.parentChildLink.count({ + where: { + treeId: params.data.id, + deletedAt: null, + }, + }), + app.prisma.mediaItem.count({ + where: { + treeId: params.data.id, + deletedAt: null, + }, + }), + app.prisma.contributionSession.count({ + where: { + treeId: params.data.id, + status: 'pending', + }, + }), + app.prisma.contributionSession.count({ + where: { + treeId: params.data.id, + }, + }), + ]) + + return reply.send({ + stats: { + persons, + unions, + filiations: links, + medias, + pendingContributions, + totalContributions, + }, + }) + }) + + app.get('/trees/:id/settings', async (request, reply) => { + const user = requireUser(request, reply) + if (!user) { + return + } + + const params = treeIdParamSchema.safeParse(request.params) + if (!params.success) { + return reply.code(400).send({ error: 'invalid_tree_id' }) + } + + const membership = await getAdminMembership(app, user.userId, params.data.id) + if (!hasActiveAdminMembership(membership)) { + return reply.code(403).send({ error: 'forbidden' }) + } + + const settings = await app.prisma.treeSettings.findUnique({ + where: { + treeId: params.data.id, + }, + }) + + if (!settings) { + return reply.code(404).send({ error: 'tree_not_found' }) + } + + return reply.send({ settings }) + }) + + app.patch('/trees/:id/settings', 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 = patchTreeSettingsSchema.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 settings = await app.prisma.treeSettings.upsert({ + where: { + treeId: params.data.id, + }, + create: { + treeId: params.data.id, + memberContributionPolicy: payload.data.memberContributionPolicy ?? 'pending', + visitorPolicy: 'read_only', + contributorPolicy: payload.data.contributorPolicy ?? 'pending', + }, + update: { + memberContributionPolicy: payload.data.memberContributionPolicy, + contributorPolicy: payload.data.contributorPolicy, + }, + }) + + await app.prisma.auditLog.create({ + data: { + treeId: params.data.id, + actorType: 'user', + actorId: user.userId, + action: 'tree_settings_updated', + entityType: 'tree_settings', + entityId: params.data.id, + payloadJson: payload.data, + }, + }) + + return reply.send({ settings }) + }) + + app.get('/trees/:id/audit', 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 query = listAuditQuerySchema.safeParse(request.query) + if (!query.success) { + return reply.code(400).send({ error: 'invalid_query', details: query.error.flatten() }) + } + + const membership = await getAdminMembership(app, user.userId, params.data.id) + if (!hasActiveAdminMembership(membership)) { + return reply.code(403).send({ error: 'forbidden' }) + } + + const logs = await app.prisma.auditLog.findMany({ + where: { + treeId: params.data.id, + action: query.data.action, + }, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + skip: query.data.offset, + take: query.data.limit + 1, + }) + + const hasMore = logs.length > query.data.limit + const items = hasMore ? logs.slice(0, query.data.limit) : logs + + return reply.send({ + logs: items, + nextOffset: hasMore ? query.data.offset + query.data.limit : null, + }) + }) + + app.post('/trees/:id/persons', 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 = createPersonSchema.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 (!membership) { + return reply.code(403).send({ error: 'forbidden' }) + } + + if (!hasAdminRight(membership.role)) { + return reply.code(403).send({ error: 'forbidden' }) + } + + if (membership.tree.deletedAt) { + return reply.code(404).send({ error: 'tree_not_found' }) + } + + const person = await app.prisma.person.create({ + data: { + treeId: params.data.id, + firstName: payload.data.firstName.trim(), + lastName: payload.data.lastName.trim(), + birthName: toTrimmedStringOrNull(payload.data.birthName), + birthDate: toDateOrNull(payload.data.birthDate), + deathDate: toDateOrNull(payload.data.deathDate), + birthPlace: toTrimmedStringOrNull(payload.data.birthPlace), + profession: toTrimmedStringOrNull(payload.data.profession), + region: toTrimmedStringOrNull(payload.data.region), + nationality: toTrimmedStringOrNull(payload.data.nationality), + sex: payload.data.sex ?? null, + notes: payload.data.notes ?? null, + createdBy: user.userId, + updatedBy: user.userId, + }, + }) + + await app.prisma.auditLog.create({ + data: { + treeId: params.data.id, + actorType: 'user', + actorId: user.userId, + action: 'person_created', + entityType: 'person', + entityId: person.id, + payloadJson: { + firstName: person.firstName, + lastName: person.lastName, + }, + }, + }) + + return reply.code(201).send({ person }) + }) + + app.patch('/trees/:id/persons/:personId', async (request, reply) => { + const user = requireUser(request, reply) + if (!user) { + return + } + + const params = personParamsSchema.safeParse(request.params) + if (!params.success) { + return reply.code(400).send({ error: 'invalid_params' }) + } + + const payload = patchPersonSchema.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: { + firstName?: string + lastName?: string + birthName?: string | null + birthDate?: Date | null + deathDate?: Date | null + birthPlace?: string | null + profession?: string | null + region?: string | null + nationality?: string | null + sex?: string | null + notes?: string | null + updatedBy?: string + } = {} + + if (payload.data.firstName !== undefined) { + data.firstName = payload.data.firstName.trim() + } + + if (payload.data.lastName !== undefined) { + data.lastName = payload.data.lastName.trim() + } + + if (payload.data.birthName !== undefined) { + data.birthName = toTrimmedStringOrNull(payload.data.birthName) + } + + if (payload.data.birthDate !== undefined) { + data.birthDate = toDateOrNull(payload.data.birthDate) + } + + if (payload.data.deathDate !== undefined) { + data.deathDate = toDateOrNull(payload.data.deathDate) + } + + if (payload.data.birthPlace !== undefined) { + data.birthPlace = toTrimmedStringOrNull(payload.data.birthPlace) + } + + if (payload.data.profession !== undefined) { + data.profession = toTrimmedStringOrNull(payload.data.profession) + } + + if (payload.data.region !== undefined) { + data.region = toTrimmedStringOrNull(payload.data.region) + } + + if (payload.data.nationality !== undefined) { + data.nationality = toTrimmedStringOrNull(payload.data.nationality) + } + + if (payload.data.sex !== undefined) { + data.sex = payload.data.sex + } + + if (payload.data.notes !== undefined) { + data.notes = payload.data.notes + } + + data.updatedBy = user.userId + + const updated = await app.prisma.person.updateMany({ + where: { + id: params.data.personId, + treeId: params.data.id, + deletedAt: null, + }, + data, + }) + + if (updated.count === 0) { + return reply.code(404).send({ error: 'person_not_found' }) + } + + const person = await app.prisma.person.findFirst({ + where: { + id: params.data.personId, + treeId: params.data.id, + deletedAt: null, + }, + }) + + await app.prisma.auditLog.create({ + data: { + treeId: params.data.id, + actorType: 'user', + actorId: user.userId, + action: 'person_updated', + entityType: 'person', + entityId: params.data.personId, + payloadJson: { + changedFields: Object.keys(payload.data), + }, + }, + }) + + return reply.send({ person }) + }) + + app.delete('/trees/:id/persons/:personId', async (request, reply) => { + const user = requireUser(request, reply) + if (!user) { + return + } + + const params = personParamsSchema.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 now = new Date() + const deletedPerson = await app.prisma.person.updateMany({ + where: { + id: params.data.personId, + treeId: params.data.id, + deletedAt: null, + }, + data: { + deletedAt: now, + updatedBy: user.userId, + }, + }) + + if (deletedPerson.count === 0) { + return reply.code(404).send({ error: 'person_not_found' }) + } + + const [deletedUnions, deletedLinks, deletedMedia] = await Promise.all([ + app.prisma.union.updateMany({ + where: { + treeId: params.data.id, + deletedAt: null, + OR: [{ partner1PersonId: params.data.personId }, { partner2PersonId: params.data.personId }], + }, + data: { + deletedAt: now, + }, + }), + app.prisma.parentChildLink.updateMany({ + where: { + treeId: params.data.id, + deletedAt: null, + OR: [{ parentPersonId: params.data.personId }, { childPersonId: params.data.personId }], + }, + data: { + deletedAt: now, + }, + }), + app.prisma.mediaItem.updateMany({ + where: { + treeId: params.data.id, + personId: params.data.personId, + deletedAt: null, + }, + data: { + deletedAt: now, + }, + }), + ]) + + await app.prisma.auditLog.create({ + data: { + treeId: params.data.id, + actorType: 'user', + actorId: user.userId, + action: 'person_deleted', + entityType: 'person', + entityId: params.data.personId, + payloadJson: { + deletedUnions: deletedUnions.count, + deletedLinks: deletedLinks.count, + deletedMedia: deletedMedia.count, + }, + }, + }) + + return reply.send({ + ok: true, + cascaded: { + unions: deletedUnions.count, + links: deletedLinks.count, + medias: deletedMedia.count, + }, + }) + }) + + app.post('/trees/:id/unions', 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 = createUnionSchema.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 (!membership) { + return reply.code(403).send({ error: 'forbidden' }) + } + + if (!hasAdminRight(membership.role)) { + return reply.code(403).send({ error: 'forbidden' }) + } + + if (membership.tree.deletedAt) { + return reply.code(404).send({ error: 'tree_not_found' }) + } + + const [partner1, partner2] = await Promise.all([ + app.prisma.person.findFirst({ + where: { + id: payload.data.partner1PersonId, + treeId: params.data.id, + deletedAt: null, + }, + select: { id: true }, + }), + payload.data.partner2PersonId + ? app.prisma.person.findFirst({ + where: { + id: payload.data.partner2PersonId, + treeId: params.data.id, + deletedAt: null, + }, + select: { id: true }, + }) + : Promise.resolve(null), + ]) + + if (!partner1 || (payload.data.partner2PersonId && !partner2)) { + return reply.code(400).send({ error: 'invalid_person_reference' }) + } + + const unionValidationError = await findUnionValidationError( + app.prisma, + params.data.id, + payload.data.partner1PersonId, + payload.data.partner2PersonId ?? null, + ) + if (unionValidationError) { + return reply.code(400).send({ error: unionValidationError }) + } + + const union = await app.prisma.union.create({ + data: { + treeId: params.data.id, + partner1PersonId: payload.data.partner1PersonId, + partner2PersonId: payload.data.partner2PersonId ?? null, + unionType: payload.data.unionType ?? null, + startDate: toDateOrNull(payload.data.startDate), + endDate: toDateOrNull(payload.data.endDate), + displayOrder: payload.data.displayOrder ?? 1, + }, + }) + + await app.prisma.auditLog.create({ + data: { + treeId: params.data.id, + actorType: 'user', + actorId: user.userId, + action: 'union_created', + entityType: 'union', + entityId: union.id, + payloadJson: { + partner1PersonId: union.partner1PersonId, + partner2PersonId: union.partner2PersonId, + }, + }, + }) + + return reply.code(201).send({ union }) + }) + + app.patch('/trees/:id/unions/:unionId', async (request, reply) => { + const user = requireUser(request, reply) + if (!user) { + return + } + + const params = unionParamsSchema.safeParse(request.params) + if (!params.success) { + return reply.code(400).send({ error: 'invalid_params' }) + } + + const payload = patchUnionSchema.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 existingUnion = await app.prisma.union.findFirst({ + where: { + id: params.data.unionId, + treeId: params.data.id, + deletedAt: null, + }, + select: { + partner1PersonId: true, + partner2PersonId: true, + }, + }) + + if (!existingUnion) { + return reply.code(404).send({ error: 'union_not_found' }) + } + + if (payload.data.partner1PersonId) { + const partner1 = await app.prisma.person.findFirst({ + where: { + id: payload.data.partner1PersonId, + treeId: params.data.id, + deletedAt: null, + }, + select: { id: true }, + }) + + if (!partner1) { + return reply.code(400).send({ error: 'invalid_person_reference' }) + } + } + + if (payload.data.partner2PersonId) { + const partner2 = await app.prisma.person.findFirst({ + where: { + id: payload.data.partner2PersonId, + treeId: params.data.id, + deletedAt: null, + }, + select: { id: true }, + }) + + if (!partner2) { + return reply.code(400).send({ error: 'invalid_person_reference' }) + } + } + + const hasPartner2Field = Object.prototype.hasOwnProperty.call(payload.data, 'partner2PersonId') + const finalPartner1PersonId = payload.data.partner1PersonId ?? existingUnion.partner1PersonId + const finalPartner2PersonId = hasPartner2Field ? payload.data.partner2PersonId ?? null : existingUnion.partner2PersonId + const unionValidationError = await findUnionValidationError( + app.prisma, + params.data.id, + finalPartner1PersonId, + finalPartner2PersonId, + ) + if (unionValidationError) { + return reply.code(400).send({ error: unionValidationError }) + } + + const updated = await app.prisma.union.updateMany({ + where: { + id: params.data.unionId, + treeId: params.data.id, + deletedAt: null, + }, + data: { + partner1PersonId: payload.data.partner1PersonId, + partner2PersonId: payload.data.partner2PersonId, + unionType: payload.data.unionType, + startDate: payload.data.startDate !== undefined ? toDateOrNull(payload.data.startDate) : undefined, + endDate: payload.data.endDate !== undefined ? toDateOrNull(payload.data.endDate) : undefined, + displayOrder: payload.data.displayOrder, + }, + }) + + if (updated.count === 0) { + return reply.code(404).send({ error: 'union_not_found' }) + } + + const union = await app.prisma.union.findFirst({ + where: { + id: params.data.unionId, + treeId: params.data.id, + deletedAt: null, + }, + }) + + await app.prisma.auditLog.create({ + data: { + treeId: params.data.id, + actorType: 'user', + actorId: user.userId, + action: 'union_updated', + entityType: 'union', + entityId: params.data.unionId, + payloadJson: { + changedFields: Object.keys(payload.data), + }, + }, + }) + + return reply.send({ union }) + }) + + app.delete('/trees/:id/unions/:unionId', async (request, reply) => { + const user = requireUser(request, reply) + if (!user) { + return + } + + const params = unionParamsSchema.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 now = new Date() + const deletedUnion = await app.prisma.union.updateMany({ + where: { + id: params.data.unionId, + treeId: params.data.id, + deletedAt: null, + }, + data: { + deletedAt: now, + }, + }) + + if (deletedUnion.count === 0) { + return reply.code(404).send({ error: 'union_not_found' }) + } + + const deletedLinks = await app.prisma.parentChildLink.updateMany({ + where: { + treeId: params.data.id, + viaUnionId: params.data.unionId, + deletedAt: null, + }, + data: { + deletedAt: now, + }, + }) + + await app.prisma.auditLog.create({ + data: { + treeId: params.data.id, + actorType: 'user', + actorId: user.userId, + action: 'union_deleted', + entityType: 'union', + entityId: params.data.unionId, + payloadJson: { + deletedLinks: deletedLinks.count, + }, + }, + }) + + return reply.send({ + ok: true, + cascaded: { + links: deletedLinks.count, + }, + }) + }) + + app.post('/trees/:id/links', 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 = createParentChildLinkSchema.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 (!membership) { + return reply.code(403).send({ error: 'forbidden' }) + } + + if (!hasAdminRight(membership.role)) { + return reply.code(403).send({ error: 'forbidden' }) + } + + if (membership.tree.deletedAt) { + return reply.code(404).send({ error: 'tree_not_found' }) + } + + const [parent, child, union] = await Promise.all([ + app.prisma.person.findFirst({ + where: { + id: payload.data.parentPersonId, + treeId: params.data.id, + deletedAt: null, + }, + select: { id: true }, + }), + app.prisma.person.findFirst({ + where: { + id: payload.data.childPersonId, + treeId: params.data.id, + deletedAt: null, + }, + select: { id: true }, + }), + payload.data.viaUnionId + ? app.prisma.union.findFirst({ + where: { + id: payload.data.viaUnionId, + treeId: params.data.id, + deletedAt: null, + }, + select: { + id: true, + partner1PersonId: true, + partner2PersonId: true, + }, + }) + : Promise.resolve(null), + ]) + + if (!parent || !child) { + return reply.code(400).send({ error: 'invalid_person_reference' }) + } + + if (payload.data.viaUnionId && !union) { + return reply.code(400).send({ error: 'invalid_union_reference' }) + } + + const linkValidationError = await findParentChildValidationError( + app.prisma, + params.data.id, + payload.data.parentPersonId, + payload.data.childPersonId, + { + viaUnionId: payload.data.viaUnionId ?? null, + resolvedViaUnion: union ?? null, + }, + ) + if (linkValidationError) { + return reply.code(400).send({ error: linkValidationError }) + } + + const link = await app.prisma.parentChildLink.create({ + data: { + treeId: params.data.id, + parentPersonId: payload.data.parentPersonId, + childPersonId: payload.data.childPersonId, + viaUnionId: payload.data.viaUnionId ?? null, + parentageType: payload.data.parentageType ?? 'biologique', + displayOrder: payload.data.displayOrder ?? 1, + }, + }) + + await app.prisma.auditLog.create({ + data: { + treeId: params.data.id, + actorType: 'user', + actorId: user.userId, + action: 'parent_child_link_created', + entityType: 'parent_child_link', + entityId: link.id, + payloadJson: { + parentPersonId: link.parentPersonId, + childPersonId: link.childPersonId, + viaUnionId: link.viaUnionId, + }, + }, + }) + + return reply.code(201).send({ link }) + }) + + app.patch('/trees/:id/links/:linkId', async (request, reply) => { + const user = requireUser(request, reply) + if (!user) { + return + } + + const params = linkParamsSchema.safeParse(request.params) + if (!params.success) { + return reply.code(400).send({ error: 'invalid_params' }) + } + + const payload = patchParentChildLinkSchema.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 existingLink = await app.prisma.parentChildLink.findFirst({ + where: { + id: params.data.linkId, + treeId: params.data.id, + deletedAt: null, + }, + select: { + parentPersonId: true, + childPersonId: true, + viaUnionId: true, + }, + }) + if (!existingLink) { + return reply.code(404).send({ error: 'link_not_found' }) + } + + if (payload.data.parentPersonId) { + const parent = await app.prisma.person.findFirst({ + where: { + id: payload.data.parentPersonId, + treeId: params.data.id, + deletedAt: null, + }, + select: { id: true }, + }) + + if (!parent) { + return reply.code(400).send({ error: 'invalid_person_reference' }) + } + } + + if (payload.data.childPersonId) { + const child = await app.prisma.person.findFirst({ + where: { + id: payload.data.childPersonId, + treeId: params.data.id, + deletedAt: null, + }, + select: { id: true }, + }) + + if (!child) { + return reply.code(400).send({ error: 'invalid_person_reference' }) + } + } + + let resolvedViaUnion: { id: string; partner1PersonId: string; partner2PersonId: string | null } | null = null + if (payload.data.viaUnionId) { + const union = await app.prisma.union.findFirst({ + where: { + id: payload.data.viaUnionId, + treeId: params.data.id, + deletedAt: null, + }, + select: { + id: true, + partner1PersonId: true, + partner2PersonId: true, + }, + }) + + if (!union) { + return reply.code(400).send({ error: 'invalid_union_reference' }) + } + + resolvedViaUnion = union + } + + const hasViaUnionIdField = Object.prototype.hasOwnProperty.call(payload.data, 'viaUnionId') + const finalParentPersonId = payload.data.parentPersonId ?? existingLink.parentPersonId + const finalChildPersonId = payload.data.childPersonId ?? existingLink.childPersonId + const finalViaUnionId = hasViaUnionIdField ? payload.data.viaUnionId ?? null : existingLink.viaUnionId + const linkValidationError = await findParentChildValidationError( + app.prisma, + params.data.id, + finalParentPersonId, + finalChildPersonId, + { + excludeLinkId: params.data.linkId, + viaUnionId: finalViaUnionId, + resolvedViaUnion: finalViaUnionId ? resolvedViaUnion : null, + }, + ) + if (linkValidationError) { + return reply.code(400).send({ error: linkValidationError }) + } + + const updated = await app.prisma.parentChildLink.updateMany({ + where: { + id: params.data.linkId, + treeId: params.data.id, + deletedAt: null, + }, + data: { + parentPersonId: payload.data.parentPersonId, + childPersonId: payload.data.childPersonId, + viaUnionId: payload.data.viaUnionId, + parentageType: payload.data.parentageType, + displayOrder: payload.data.displayOrder, + }, + }) + + if (updated.count === 0) { + return reply.code(404).send({ error: 'link_not_found' }) + } + + const link = await app.prisma.parentChildLink.findFirst({ + where: { + id: params.data.linkId, + treeId: params.data.id, + deletedAt: null, + }, + }) + + await app.prisma.auditLog.create({ + data: { + treeId: params.data.id, + actorType: 'user', + actorId: user.userId, + action: 'parent_child_link_updated', + entityType: 'parent_child_link', + entityId: params.data.linkId, + payloadJson: { + changedFields: Object.keys(payload.data), + }, + }, + }) + + return reply.send({ link }) + }) + + app.delete('/trees/:id/links/:linkId', async (request, reply) => { + const user = requireUser(request, reply) + if (!user) { + return + } + + const params = linkParamsSchema.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.parentChildLink.updateMany({ + where: { + id: params.data.linkId, + treeId: params.data.id, + deletedAt: null, + }, + data: { + deletedAt: new Date(), + }, + }) + + if (deleted.count === 0) { + return reply.code(404).send({ error: 'link_not_found' }) + } + + await app.prisma.auditLog.create({ + data: { + treeId: params.data.id, + actorType: 'user', + actorId: user.userId, + action: 'parent_child_link_deleted', + entityType: 'parent_child_link', + entityId: params.data.linkId, + }, + }) + + return reply.send({ ok: true }) + }) +} diff --git a/backend/src/routes/waitlist.ts b/backend/src/routes/waitlist.ts new file mode 100644 index 0000000..5aee545 --- /dev/null +++ b/backend/src/routes/waitlist.ts @@ -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 }) + }, + ) +} diff --git a/backend/src/server.ts b/backend/src/server.ts new file mode 100644 index 0000000..f9c48ac --- /dev/null +++ b/backend/src/server.ts @@ -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() \ No newline at end of file diff --git a/backend/src/types/auth.ts b/backend/src/types/auth.ts new file mode 100644 index 0000000..b0e0c00 --- /dev/null +++ b/backend/src/types/auth.ts @@ -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 diff --git a/backend/src/types/fastify.d.ts b/backend/src/types/fastify.d.ts new file mode 100644 index 0000000..752af28 --- /dev/null +++ b/backend/src/types/fastify.d.ts @@ -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 + } +} \ No newline at end of file diff --git a/backend/src/utils/graph-payload.ts b/backend/src/utils/graph-payload.ts new file mode 100644 index 0000000..7316904 --- /dev/null +++ b/backend/src/utils/graph-payload.ts @@ -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, + })), + } +} diff --git a/backend/src/utils/relationship-guards.ts b/backend/src/utils/relationship-guards.ts new file mode 100644 index 0000000..9cdc6e8 --- /dev/null +++ b/backend/src/utils/relationship-guards.ts @@ -0,0 +1,234 @@ +import type { Prisma, PrismaClient } from '@prisma/client' + +type RelationReader = Pick + +type UnionLite = { + id: string + partner1PersonId: string + partner2PersonId: string | null +} + +type LinkLite = { + id: string + parentPersonId: string + childPersonId: string + viaUnionId: string | null +} + +type RelationSnapshot = { + unionsById: Map + 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>, parentId: string, childId: string): void { + if (!adjacency.has(parentId)) { + adjacency.set(parentId, new Set()) + } + + adjacency.get(parentId)?.add(childId) +} + +function buildAdjacency(snapshot: RelationSnapshot): Map> { + const adjacency = new Map>() + + 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>, source: string, target: string): boolean { + if (source === target) { + return true + } + + const queue: string[] = [source] + const visited = new Set([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 { + 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() + 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 { + 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 { + 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 +} diff --git a/backend/test/api-routes.test.ts b/backend/test/api-routes.test.ts new file mode 100644 index 0000000..6f38d29 --- /dev/null +++ b/backend/test/api-routes.test.ts @@ -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 { + 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) => 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() + }) +}) diff --git a/backend/test/api-security.integration.test.ts b/backend/test/api-security.integration.test.ts new file mode 100644 index 0000000..d76618f --- /dev/null +++ b/backend/test/api-security.integration.test.ts @@ -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 { + 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) => 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 = '' + + 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() + }) +}) diff --git a/backend/test/auth.test.ts b/backend/test/auth.test.ts new file mode 100644 index 0000000..34be0fe --- /dev/null +++ b/backend/test/auth.test.ts @@ -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) +}) diff --git a/backend/test/e2e-journeys.test.ts b/backend/test/e2e-journeys.test.ts new file mode 100644 index 0000000..9089765 --- /dev/null +++ b/backend/test/e2e-journeys.test.ts @@ -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) => 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() + }) +}) diff --git a/backend/test/graph-payload.test.ts b/backend/test/graph-payload.test.ts new file mode 100644 index 0000000..2211b80 --- /dev/null +++ b/backend/test/graph-payload.test.ts @@ -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') + }) +}) diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..b326c87 --- /dev/null +++ b/backend/tsconfig.json @@ -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"] +} diff --git a/deploy/HOSTINGER_GITHUB_SETUP.md b/deploy/HOSTINGER_GITHUB_SETUP.md new file mode 100644 index 0000000..423707a --- /dev/null +++ b/deploy/HOSTINGER_GITHUB_SETUP.md @@ -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. diff --git a/deploy/backup.sh b/deploy/backup.sh new file mode 100644 index 0000000..94e2cc8 --- /dev/null +++ b/deploy/backup.sh @@ -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" diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100644 index 0000000..f167725 --- /dev/null +++ b/deploy/deploy.sh @@ -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" diff --git a/deploy/nginx.conf b/deploy/nginx.conf new file mode 100644 index 0000000..b96cfb6 --- /dev/null +++ b/deploy/nginx.conf @@ -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"; + } +} diff --git a/deploy/nginx.docker.conf b/deploy/nginx.docker.conf new file mode 100644 index 0000000..ae6eea2 --- /dev/null +++ b/deploy/nginx.docker.conf @@ -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; + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d6350fb --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs new file mode 100644 index 0000000..2ef9433 --- /dev/null +++ b/ecosystem.config.cjs @@ -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, + }, + ], +} diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..5ba2327 --- /dev/null +++ b/frontend/.env.example @@ -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 diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -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? diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..18bc70e --- /dev/null +++ b/frontend/README.md @@ -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. diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..fe4222e --- /dev/null +++ b/frontend/eslint.config.js @@ -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', + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..563b119 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,19 @@ + + + + + + + Parenthèse + + + + + + + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..9611987 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,4334 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "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" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.208.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.208.0.tgz", + "integrity": "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", + "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-http": { + "version": "0.208.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.208.0.tgz", + "integrity": "sha512-jOv40Bs9jy9bZVLo/i8FwUiuCvbjWDI+ZW13wimJm4LjnlwJxGgB+N/VWOZUTpM+ah/awXeQqKdNlpLf2EjvYg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.208.0", + "@opentelemetry/core": "2.2.0", + "@opentelemetry/otlp-exporter-base": "0.208.0", + "@opentelemetry/otlp-transformer": "0.208.0", + "@opentelemetry/sdk-logs": "0.208.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.208.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.208.0.tgz", + "integrity": "sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/otlp-transformer": "0.208.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer": { + "version": "0.208.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.208.0.tgz", + "integrity": "sha512-DCFPY8C6lAQHUNkzcNT9R+qYExvsk6C5Bto2pbNxgicpcSWbe2WHShLxkOxIdNcBiYPdVHv/e7vH7K6TI+C+fQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.208.0", + "@opentelemetry/core": "2.2.0", + "@opentelemetry/resources": "2.2.0", + "@opentelemetry/sdk-logs": "0.208.0", + "@opentelemetry/sdk-metrics": "2.2.0", + "@opentelemetry/sdk-trace-base": "2.2.0", + "protobufjs": "^7.3.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/resources": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", + "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.1.tgz", + "integrity": "sha512-BViBCdE/GuXRlp9k7nS1w6wJvY5fnFX5XvuEtWsTAOQFIO89Eru7lGW3WbfbxtCuZ/GbrJfAziXG0w0dpxL7eQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.5.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/resources/node_modules/@opentelemetry/core": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.1.tgz", + "integrity": "sha512-Dwlc+3HAZqpgTYq0MUyZABjFkcrKTePwuiFVLjahGD8cx3enqihmpAmdgNFO1R4m/sIe5afjJrA25Prqy4NXlA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs": { + "version": "0.208.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.208.0.tgz", + "integrity": "sha512-QlAyL1jRpOeaqx7/leG1vJMp84g0xKP6gJmfELBpnI4O/9xPX+Hu5m1POk9Kl+veNkyth5t19hRlN6tNY1sjbA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.208.0", + "@opentelemetry/core": "2.2.0", + "@opentelemetry/resources": "2.2.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/resources": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", + "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.2.0.tgz", + "integrity": "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/resources": "2.2.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/resources": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", + "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz", + "integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/resources": "2.2.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/resources": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", + "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.39.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.39.0.tgz", + "integrity": "sha512-R5R9tb2AXs2IRLNKLBJDynhkfmx7mX0vi8NkhZb3gUkPWHn6HXk5J8iQ/dql0U3ApfWym4kXXmBDRGO+oeOfjg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@posthog/core": { + "version": "1.23.1", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.23.1.tgz", + "integrity": "sha512-GViD5mOv/mcbZcyzz3z9CS0R79JzxVaqEz4sP5Dsea178M/j3ZWe6gaHDZB9yuyGfcmIMQ/8K14yv+7QrK4sQQ==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.6" + } + }, + "node_modules/@posthog/types": { + "version": "1.353.0", + "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.353.0.tgz", + "integrity": "sha512-wG5zMdr80QNV+0XkJFCX5ZxcpdER1TtYe21YlLAuI4i0G1wjQDi/FDCjh4b0QGiV8KljQMHfC2xPZ/SXnPBlBQ==", + "license": "MIT" + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", + "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==", + "dev": true + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, + "node_modules/@types/node": { + "version": "25.3.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz", + "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.13", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.13.tgz", + "integrity": "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==", + "dev": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.3.tgz", + "integrity": "sha512-NVUnA6gQCl8jfoYqKqQU5Clv0aPw14KkZYCsX6T9Lfu9slI0LOU10OTwFHS/WmptsMMpshNd/1tuWsHQ2Uk+cg==", + "dev": true, + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.2", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.24", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz", + "integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001766", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "dev": true, + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001769", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz", + "integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/core-js": { + "version": "3.48.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.48.0.tgz", + "integrity": "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true + }, + "node_modules/dompurify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", + "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.286", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", + "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "dev": true + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.4.8", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz", + "integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==", + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.564.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.564.0.tgz", + "integrity": "sha512-JJ8GVTQqFwuliifD48U6+h7DXEHdkhJ/E87kksGByII3qHxtPciVb8T8woQONHBQgHVOl7rSMrrip3SeVNy7Fg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true + }, + "node_modules/posthog-js": { + "version": "1.353.0", + "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.353.0.tgz", + "integrity": "sha512-1ifN9CDMavZwRvm+6UYAaEbvzJPvC6q0warmNGyqUcmtDe3SZh4PfvftSJrucH5Oeh6r+ihWtLS7ESIUS0pYuw==", + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/api-logs": "^0.208.0", + "@opentelemetry/exporter-logs-otlp-http": "^0.208.0", + "@opentelemetry/resources": "^2.2.0", + "@opentelemetry/sdk-logs": "^0.208.0", + "@posthog/core": "1.23.1", + "@posthog/types": "1.353.0", + "core-js": "^3.38.1", + "dompurify": "^3.3.1", + "fflate": "^0.4.8", + "preact": "^10.28.2", + "query-selector-shadow-dom": "^1.0.1", + "web-vitals": "^5.1.0" + } + }, + "node_modules/preact": { + "version": "10.28.4", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.28.4.tgz", + "integrity": "sha512-uKFfOHWuSNpRFVTnljsCluEFq57OKT+0QdOiQo8XWnQ/pSvg7OpX5eNOejELXJMWy+BwM2nobz0FkvzmnpCNsQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/query-selector-shadow-dom": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz", + "integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==", + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/web-vitals": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.1.0.tgz", + "integrity": "sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==", + "license": "Apache-2.0" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..5de3475 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/public/robots.txt b/frontend/public/robots.txt new file mode 100644 index 0000000..1f53798 --- /dev/null +++ b/frontend/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: / diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..84001a9 --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,2903 @@ +.app { + width: 100vw; + height: 100vh; + position: relative; + overflow: hidden; + background: #FDF8EC; +} + +.app::before { + content: ''; + position: absolute; + inset: 0; + background: transparent; + pointer-events: none; +} + +.app-loading { + display: grid; + place-items: center; +} + +.loading-card { + background: rgba(255, 255, 255, 0.75); + border: 1px solid #e7dcc8; + border-radius: 14px; + padding: 18px 22px; + color: #5d524b; + font-size: 14px; + letter-spacing: 0.01em; + backdrop-filter: blur(8px); +} + +.access-gate { + position: absolute; + inset: 0; + display: grid; + place-items: center; + padding: 20px; + overflow: hidden; + background: radial-gradient(circle at 50% 0%, rgba(255, 245, 227, 0.78), rgba(253, 248, 236, 0.9)); +} + +.access-gate-mock-blur { + position: absolute; + inset: -40px; + pointer-events: none; + filter: blur(8px); + opacity: 0.55; +} + +.access-gate-mock-card { + position: absolute; + border-radius: 22px; + background: linear-gradient(135deg, rgba(251, 231, 197, 0.7), rgba(239, 216, 176, 0.6)); + border: 1px solid rgba(155, 126, 89, 0.35); + box-shadow: 0 20px 60px rgba(104, 83, 58, 0.2); +} + +.access-gate-mock-card-main { + width: min(780px, 92vw); + height: min(440px, 60vh); + top: 16%; + left: 12%; +} + +.access-gate-mock-card-side { + width: min(260px, 40vw); + height: min(340px, 48vh); + top: 26%; + right: 8%; +} + +.access-gate-mock-dot { + position: absolute; + width: 180px; + height: 180px; + border-radius: 50%; + background: radial-gradient(circle, rgba(247, 205, 130, 0.7), rgba(247, 205, 130, 0)); + left: 8%; + bottom: 6%; +} + +.access-gate-card { + width: min(460px, 100%); + display: grid; + gap: 10px; + padding: 24px; + border-radius: 18px; + border: 1px solid #e2d5bf; + background: rgba(255, 255, 255, 0.78); + box-shadow: 0 14px 36px rgba(93, 82, 75, 0.12); + backdrop-filter: blur(8px); + position: relative; + z-index: 2; +} + +.access-gate-card h1 { + font-size: 28px; + line-height: 1.15; + color: #453d37; +} + +.access-gate-card p { + color: #6e6258; + margin-bottom: 4px; +} + +.access-gate-tree-info { + display: grid; + gap: 6px; + border: 1px solid #e7d8bf; + border-radius: 10px; + background: rgba(255, 250, 241, 0.85); + padding: 10px 12px; +} + +.access-gate-tree-name { + color: #4f443d; + font-weight: 700; +} + +.access-gate-tree-meta { + color: #6d5f52; + font-size: 14px; +} + +.access-gate-tree-desc { + color: #6f655d; + font-size: 14px; +} + +.access-gate-card label { + font-size: 14px; + color: #6e6258; + margin-top: 4px; +} + +.access-gate-card input { + width: 100%; + border-radius: 10px; + border: 1px solid #d9cdb8; + background: #fffdf8; + color: #453d37; + padding: 10px 12px; + outline: none; +} + +.access-gate-card input:focus { + border-color: #b99762; + box-shadow: 0 0 0 2px rgba(185, 151, 98, 0.15); +} + +.access-gate-card button { + border: 0; + border-radius: 10px; + padding: 11px 14px; + font-weight: 450; + color: #fef9ed; + background: linear-gradient(135deg, #8b6a43, #6a4f32); + cursor: pointer; + margin-top: 6px; +} + +.access-gate-card button:disabled { + opacity: 0.65; + cursor: not-allowed; +} + +.access-gate-card .ghost { + background: transparent; + color: #6a4f32; + border: 1px solid #cdb89a; +} + +.access-gate-error { + background: rgba(171, 49, 49, 0.1); + border: 1px solid rgba(171, 49, 49, 0.32); + color: #862f2f; + border-radius: 10px; + padding: 10px 12px; + font-size: 14px; + margin-top: 6px; +} + +.account-dashboard { + position: absolute; + inset: 0; + display: grid; + place-items: center; + padding: 20px; + background: radial-gradient(circle at 20% 0%, rgba(255, 242, 221, 0.75), rgba(253, 248, 236, 0.95)); +} + +.account-dashboard-card { + width: min(720px, 100%); + max-height: calc(100vh - 40px); + overflow: auto; + display: grid; + gap: 12px; + padding: 24px; + border-radius: 18px; + border: 1px solid #e2d5bf; + background: rgba(255, 255, 255, 0.82); + box-shadow: 0 16px 38px rgba(93, 82, 75, 0.14); +} + +.account-dashboard-card h1 { + font-size: 28px; + color: #453d37; +} + +.account-dashboard-card h2 { + font-size: 20px; + color: #564a42; +} + +.account-dashboard-card p { + color: #6e6258; +} + +.account-toggle { + display: flex; + gap: 8px; +} + +.account-toggle button { + border: 1px solid #d6c5aa; + border-radius: 8px; + background: #fffdf8; + color: #6a4f32; + padding: 8px 12px; + font-weight: 450; + cursor: pointer; +} + +.account-toggle button.active { + color: #fef9ed; + border-color: #7a5b39; + background: linear-gradient(135deg, #8b6a43, #6a4f32); +} + +.account-form, +.create-tree-form { + display: grid; + gap: 8px; + background: rgba(255, 255, 255, 0.65); + border: 1px solid #e3d7c2; + border-radius: 12px; + padding: 14px; +} + +.account-form label, +.create-tree-form label { + font-size: 14px; + color: #6e6258; +} + +.account-form input, +.create-tree-form input { + width: 100%; + border-radius: 10px; + border: 1px solid #d9cdb8; + background: #fffdf8; + color: #453d37; + padding: 10px 12px; + outline: none; +} + +.account-form input:focus, +.create-tree-form input:focus { + border-color: #b99762; + box-shadow: 0 0 0 2px rgba(185, 151, 98, 0.15); +} + +.account-form button, +.create-tree-form button { + border: 0; + border-radius: 10px; + padding: 11px 14px; + font-weight: 450; + color: #fef9ed; + background: linear-gradient(135deg, #8b6a43, #6a4f32); + cursor: pointer; + margin-top: 4px; +} + +.account-form button:disabled, +.create-tree-form button:disabled { + opacity: 0.65; + cursor: not-allowed; +} + +.account-error { + background: rgba(171, 49, 49, 0.1); + border: 1px solid rgba(171, 49, 49, 0.32); + color: #862f2f; + border-radius: 10px; + padding: 10px 12px; + font-size: 14px; +} + +.account-help { + color: #6e6258; + font-size: 14px; +} + +.account-help code { + border: 1px solid #e3d7c2; + background: #fffaf2; + border-radius: 6px; + padding: 2px 6px; + color: #554940; +} + +.tree-list { + display: grid; + gap: 8px; +} + +.tree-item { + display: grid; + justify-items: start; + gap: 2px; + border: 1px solid #d8c7ac; + background: #fffdf8; + color: #453d37; + border-radius: 10px; + padding: 10px 12px; + cursor: pointer; +} + +.tree-item:hover { + border-color: #b99762; +} + +.tree-item.active { + border-color: #8b6a43; + box-shadow: 0 0 0 2px rgba(139, 106, 67, 0.18); +} + +.tree-name { + font-weight: 700; +} + +.tree-meta { + font-size: 14px; + color: #6e6258; +} + +.tree-empty { + color: #6e6258; + background: rgba(255, 255, 255, 0.72); + border: 1px dashed #d8c7ac; + border-radius: 10px; + padding: 12px; +} + +.account-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.account-actions .ghost { + border: 1px solid #cdb89a; + border-radius: 10px; + padding: 10px 12px; + background: transparent; + color: #6a4f32; + font-weight: 450; + cursor: pointer; +} + +.wizard-overlay { + position: absolute; + inset: 0; + z-index: 30; + display: grid; + place-items: center; + padding: 20px; + background: rgba(44, 36, 30, 0.34); +} + +.wizard-card { + width: min(460px, 100%); + max-height: calc(100vh - 40px); + overflow: auto; + display: grid; + gap: 10px; + border-radius: 18px; + border: 1px solid #d8c7ac; + background: #fffdf8; + padding: 22px 20px 18px; + box-shadow: 0 24px 45px rgba(50, 38, 25, 0.34); +} + +/* ── Sections personnes ── */ +.wizard-persons { + display: grid; + gap: 8px; +} + +.wizard-person { + display: grid; + gap: 7px; + border: 1px solid #e4d8c4; + border-radius: 12px; + background: rgba(255, 252, 245, 0.9); + padding: 11px 12px; +} + +.wizard-person-label { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #9a8370; +} + +.wizard-person-label--self { + color: #7a5c2e; +} + +.wizard-person-fields { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 7px; +} + +.wizard-card input { + width: 100%; + border-radius: 9px; + border: 1px solid #d9cdb8; + background: #fffdf8; + color: #453d37; + padding: 9px 11px; + font-size: 14px; + outline: none; + box-sizing: border-box; +} + +.wizard-card input::placeholder { + color: #b5a48e; +} + +.wizard-card input:focus { + border-color: #b99762; + box-shadow: 0 0 0 2px rgba(185, 151, 98, 0.15); +} + +.wizard-year { + grid-column: 1 / -1; + font-size: 13px !important; +} + +.wizard-card button { + border: 0; + border-radius: 10px; + padding: 11px 14px; + font-size: 14px; + font-weight: 500; + color: #fef9ed; + background: linear-gradient(135deg, #8b6a43, #6a4f32); + cursor: pointer; + margin-top: 4px; + transition: filter 0.15s; +} + +.wizard-card button:not(:disabled):hover { + filter: brightness(1.08); +} + +.wizard-card button:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.wizard-card .ghost { + margin-top: 0; + background: transparent; + color: #7a6252; + border: 1px solid #cdb89a; + font-weight: 450; +} + +.wizard-error { + background: rgba(171, 49, 49, 0.1); + border: 1px solid rgba(171, 49, 49, 0.32); + color: #862f2f; + border-radius: 10px; + padding: 10px 12px; + font-size: 14px; +} + +.admin-overlay { + position: absolute; + inset: 0; + z-index: 34; + display: grid; + place-items: center; + padding: 20px; + background: rgba(44, 36, 30, 0.32); +} + +.admin-card { + width: min(860px, 100%); + max-height: calc(100vh - 40px); + overflow: auto; + display: grid; + gap: 12px; + border-radius: 16px; + border: 1px solid #d8c7ac; + background: #fffdf8; + padding: 20px; + box-shadow: 0 24px 45px rgba(50, 38, 25, 0.34); +} + +.admin-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.admin-header h1 { + font-size: 24px; + color: #453d37; +} + +.admin-tabs { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.admin-tabs button { + border: 1px solid #cdb89a; + border-radius: 10px; + padding: 9px 12px; + background: transparent; + color: #6a4f32; + font-weight: 700; + cursor: pointer; +} + +.admin-tabs button.active { + color: #fef9ed; + border-color: #7a5b39; + background: linear-gradient(135deg, #8b6a43, #6a4f32); +} + +.admin-section { + display: grid; + gap: 10px; +} + +.admin-stats-grid { + display: grid; + gap: 8px; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); +} + +.admin-stat-card { + border: 1px solid #d8c7ac; + border-radius: 10px; + background: #fffdf8; + padding: 12px; + display: grid; + gap: 2px; +} + +.admin-stat-card strong { + font-size: 24px; + color: #4e4138; +} + +.admin-stat-card span { + color: #6e6258; + font-size: 14px; +} + +.admin-share-line { + display: grid; + gap: 4px; +} + +.admin-share-line span { + color: #6e6258; + font-size: 14px; +} + +.admin-share-line code { + border: 1px solid #e3d7c2; + background: #fffaf2; + border-radius: 8px; + padding: 8px 10px; + color: #554940; + font-size: 14px; + overflow: auto; +} + +.admin-form { + display: grid; + gap: 8px; + border: 1px solid #e3d7c2; + border-radius: 12px; + padding: 14px; + background: rgba(255, 255, 255, 0.65); +} + +.admin-form h2 { + font-size: 18px; + color: #564a42; +} + +.admin-form label { + font-size: 14px; + color: #6e6258; +} + +.admin-form input, +.admin-form select { + width: 100%; + border-radius: 10px; + border: 1px solid #d9cdb8; + background: #fffdf8; + color: #453d37; + padding: 10px 12px; + outline: none; +} + +.admin-form button, +.admin-audit-header button, +.admin-section .ghost { + border: 0; + border-radius: 10px; + padding: 10px 12px; + font-weight: 450; + color: #fef9ed; + background: linear-gradient(135deg, #8b6a43, #6a4f32); + cursor: pointer; +} + +.admin-card .ghost { + border: 1px solid #cdb89a; + border-radius: 10px; + padding: 10px 12px; + background: transparent; + color: #6a4f32; + font-weight: 450; + cursor: pointer; +} + +.admin-meta { + color: #6e6258; + font-size: 14px; +} + +.admin-audit-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.admin-audit-header input { + border-radius: 10px; + border: 1px solid #d9cdb8; + background: #fffdf8; + color: #453d37; + padding: 9px 11px; + outline: none; + width: min(280px, 100%); +} + +.admin-audit-header h2 { + font-size: 18px; + color: #564a42; +} + +.admin-audit-list { + display: grid; + gap: 8px; +} + +.admin-audit-item { + border: 1px solid #d8c7ac; + border-radius: 10px; + background: #fffdf8; + padding: 10px 12px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.admin-audit-main { + display: grid; + gap: 2px; + color: #5a4d44; +} + +.admin-audit-main span { + color: #6e6258; + font-size: 14px; +} + +.admin-audit-meta { + display: grid; + gap: 2px; + text-align: right; + color: #6e6258; + font-size: 14px; +} + +.admin-person-list { + display: grid; + gap: 8px; +} + +.admin-person-item { + border: 1px solid #d8c7ac; + border-radius: 10px; + background: #fffdf8; + padding: 10px 12px; + display: grid; + gap: 3px; +} + +.admin-person-item strong { + color: #4e4138; +} + +.admin-person-item span { + color: #6e6258; + font-size: 14px; +} + +.admin-person-actions { + display: flex; + gap: 8px; +} + +.admin-empty { + color: #6e6258; + background: rgba(255, 255, 255, 0.72); + border: 1px dashed #d8c7ac; + border-radius: 10px; + padding: 12px; +} + +.admin-error { + background: rgba(171, 49, 49, 0.1); + border: 1px solid rgba(171, 49, 49, 0.32); + color: #862f2f; + border-radius: 10px; + padding: 10px 12px; + font-size: 14px; +} + +/* ============================================================ + Bannière de bienvenue — onboarding premier utilisateur + ============================================================ */ +.demo-welcome { + position: fixed; + top: 20px; + left: 20px; + z-index: 800; + max-width: 260px; + padding: 14px 16px; + background: rgba(247, 236, 217, 0.93); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border-radius: 16px; + border: 1px solid rgba(93, 82, 75, 0.1); + box-shadow: + 0 4px 24px rgba(93, 82, 75, 0.14), + 0 1px 4px rgba(93, 82, 75, 0.06); + font-family: 'Red Hat Mono', ui-monospace, monospace; + font-size: 13px; + color: #5D524B; + line-height: 1.55; + pointer-events: none; + animation: demoWelcomeIn 0.6s cubic-bezier(0.22, 1, 0.36, 1) 0.3s both; +} + +@keyframes demoWelcomeIn { + from { + opacity: 0; + transform: translateY(-8px) scale(0.96); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.demo-welcome strong { + color: #453d37; + display: block; + margin-bottom: 5px; + font-size: 14px; +} + +/* ============================================================ + Nœud vide arbre — premier arbre sans personnes + ============================================================ */ +.empty-tree-add { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 100; + + display: flex; + align-items: center; + gap: 12px; + + border: 1.5px solid rgba(166, 124, 82, 0.38); + background: rgba(255, 251, 244, 0.92); + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + border-radius: 14px; + padding: 13px 16px 13px 14px; + cursor: pointer; + + box-shadow: 0 4px 20px rgba(70, 55, 40, 0.14), 0 1px 4px rgba(70, 55, 40, 0.08); + transition: transform 0.22s cubic-bezier(0.22, 1, 0.36, 1), box-shadow 0.22s ease, border-color 0.18s; + animation: emptyAddIn 0.45s cubic-bezier(0.22, 1, 0.36, 1) 0.2s both; +} + +@keyframes emptyAddIn { + from { opacity: 0; transform: translate(-50%, -48%) scale(0.9); } + to { opacity: 1; transform: translate(-50%, -50%) scale(1); } +} + +.empty-tree-add:hover { + transform: translate(-50%, -51%); + box-shadow: 0 8px 28px rgba(70, 55, 40, 0.2), 0 2px 6px rgba(70, 55, 40, 0.1); + border-color: rgba(166, 124, 82, 0.65); +} + +.empty-tree-add:active { + transform: translate(-50%, -50%) scale(0.97); +} + +.empty-tree-add-icon { + width: 26px; + height: 26px; + color: #a07840; + flex-shrink: 0; +} + +.empty-tree-add-body { + display: grid; + gap: 2px; + text-align: left; +} + +.empty-tree-add-title { + font-size: 14px; + font-weight: 600; + color: #3a2f25; + line-height: 1.2; +} + +.empty-tree-add-hint { + font-size: 12px; + color: #9a8370; + line-height: 1.2; +} + +.empty-tree-add-chevron { + width: 14px; + height: 14px; + color: #b8996e; + flex-shrink: 0; + margin-left: 2px; +} + +.admin-ok { + background: rgba(52, 130, 70, 0.1); + border: 1px solid rgba(52, 130, 70, 0.3); + color: #2f6e3a; + border-radius: 10px; + padding: 10px 12px; + font-size: 14px; +} + +.media-manager-overlay { + position: absolute; + inset: 0; + z-index: 32; + display: grid; + place-items: center; + padding: 20px; + background: rgba(44, 36, 30, 0.3); +} + +.media-manager-card { + width: min(760px, 100%); + max-height: calc(100vh - 40px); + overflow: auto; + display: grid; + gap: 10px; + border-radius: 16px; + border: 1px solid #d8c7ac; + background: #fffdf8; + padding: 20px; + box-shadow: 0 24px 45px rgba(50, 38, 25, 0.34); +} + +.media-manager-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; +} + +.media-manager-header h1 { + font-size: 24px; + color: #453d37; +} + +.media-manager-form { + display: grid; + gap: 8px; + border: 1px solid #e3d7c2; + border-radius: 12px; + padding: 12px; + background: rgba(255, 255, 255, 0.7); +} + +.media-manager-form label { + font-size: 14px; + color: #6e6258; +} + +.media-manager-form input, +.media-manager-form select { + width: 100%; + border-radius: 10px; + border: 1px solid #d9cdb8; + background: #fffdf8; + color: #453d37; + padding: 10px 12px; + outline: none; +} + +.media-manager-form button, +.media-manager-actions button { + border: 0; + border-radius: 10px; + padding: 10px 12px; + font-weight: 450; + color: #fef9ed; + background: linear-gradient(135deg, #8b6a43, #6a4f32); + cursor: pointer; +} + +.media-manager-card .ghost { + border: 1px solid #cdb89a; + border-radius: 10px; + padding: 10px 12px; + background: transparent; + color: #6a4f32; + font-weight: 450; + cursor: pointer; +} + +.media-manager-list { + display: grid; + gap: 8px; +} + +.media-manager-item { + display: grid; + grid-template-columns: 74px 1fr auto; + align-items: center; + gap: 10px; + border: 1px solid #d8c7ac; + background: #fffdf8; + border-radius: 10px; + padding: 8px; +} + +.media-manager-preview { + width: 74px; + height: 74px; + border-radius: 8px; + background: #f3ecdf; + overflow: hidden; + display: grid; + place-items: center; + color: #6e6258; + font-size: 14px; +} + +.media-manager-preview img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.media-manager-meta { + display: grid; + gap: 2px; + color: #5a4d44; +} + +.media-manager-meta span { + color: #7a6f65; + font-size: 14px; +} + +.media-manager-actions { + display: flex; + gap: 6px; +} + +.media-manager-empty { + color: #6e6258; + background: rgba(255, 255, 255, 0.72); + border: 1px dashed #d8c7ac; + border-radius: 10px; + padding: 12px; +} + +.media-manager-error { + background: rgba(171, 49, 49, 0.1); + border: 1px solid rgba(171, 49, 49, 0.32); + color: #862f2f; + border-radius: 10px; + padding: 10px 12px; + font-size: 14px; +} + +.contrib-overlay { + position: fixed; + inset: 0; + z-index: 2200; + visibility: hidden; + pointer-events: none; + opacity: 0; + transition: opacity 0.24s ease; + background: rgba(33, 25, 19, 0.42); +} + +.contrib-overlay--open { + visibility: visible; + pointer-events: auto; + opacity: 1; +} + +.contrib-card { + position: absolute; + top: 0; + left: 0; + bottom: 0; + width: 48vw; + min-width: 480px; + max-width: 960px; + max-height: 100vh; + overflow: auto; + display: flex; + flex-direction: column; + gap: 14px; + border-right: 1px solid #cbb391; + background: linear-gradient(180deg, #fffaf1 0%, #f8ede0 100%); + padding: 20px 18px 22px; + box-shadow: 14px 0 44px rgba(29, 22, 16, 0.36); + transform: translateX(-100%); + transition: transform 0.32s cubic-bezier(0.22, 1, 0.36, 1); +} + +@media (max-width: 640px) { + .contrib-card { + width: 100%; + max-width: 100%; + min-width: 0; + padding: 18px 14px 20px; + } +} + +.contrib-overlay--open .contrib-card { + transform: translateX(0); +} + +.contrib-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + border-bottom: 1px solid #e2cfb3; + padding-bottom: 12px; +} + +.contrib-header-text { + display: grid; + gap: 4px; + text-align: left; +} + +.contrib-header h1 { + font-size: 28px; + line-height: 1.05; + color: #2e2824; + margin: 0; +} + +.contrib-header p { + margin: 0; + color: #675848; + font-size: 14px; + letter-spacing: 0.01em; +} + +.contrib-form { + display: grid; + gap: 8px; + border: 1px solid #e3d7c2; + border-radius: 12px; + padding: 14px; + background: rgba(255, 255, 255, 0.65); +} + +.contrib-form h2, +.contrib-list-section h2 { + font-size: 18px; + color: #564a42; +} + +.contrib-form label { + font-size: 14px; + color: #6e6258; +} + +.contrib-form input { + width: 100%; + border-radius: 10px; + border: 1px solid #d9cdb8; + background: #fffdf8; + color: #453d37; + padding: 10px 12px; + outline: none; +} + +.contrib-form input:focus { + border-color: #b99762; + box-shadow: 0 0 0 2px rgba(185, 151, 98, 0.15); +} + +.contrib-form button, +.contrib-item-actions button { + border: 0; + border-radius: 10px; + padding: 10px 12px; + font-weight: 450; + color: #fef9ed; + background: linear-gradient(135deg, #8b6a43, #6a4f32); + cursor: pointer; +} + +.contrib-form button:disabled, +.contrib-item-actions button:disabled { + opacity: 0.65; + cursor: not-allowed; +} + +.contrib-card .ghost { + border: 1px solid #cdb89a; + border-radius: 10px; + padding: 10px 12px; + background: transparent; + color: #6a4f32; + font-weight: 450; + cursor: pointer; +} + +.contrib-list-section { + display: grid; + gap: 10px; +} + +.contrib-invite { + display: grid; + gap: 8px; + border: 1px solid #dbc39f; + border-radius: 10px; + background: rgba(255, 252, 247, 0.9); + padding: 10px; +} + +.contrib-invite-top { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; +} + +.contrib-invite-title { + color: #2e2824; + font-size: 14px; + line-height: 1.3; +} + +.contrib-invite-link { + display: flex; + align-items: center; + gap: 8px; +} + +.contrib-link-url { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + border: 1px solid #dec5a7; + background: #fbf3e8; + color: #5e4d3f; + border-radius: 7px; + padding: 7px 10px; + font-size: 12px; +} + +.contrib-invite-passwords { + display: grid; + gap: 8px; + padding-top: 6px; + border-top: 1px solid #ead9c3; +} + +.contrib-role-row { + display: flex; + align-items: center; + gap: 8px; +} + +.contrib-role-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 108px; + flex-shrink: 0; + border-radius: 999px; + border: 1px solid #d8bc95; + background: #f4e5d0; + color: #5f4d3d; + padding: 4px 10px; + font-size: 12px; + font-weight: 450; +} + +.contrib-role-badge--contrib { + border-color: #c8b48f; + background: #ede2d1; +} + +.contrib-pass-reveal { + flex: 1; + min-width: 0; + text-align: left; + font-family: monospace; + font-size: 14px; + background: rgba(255, 249, 239, 0.85); + border: 1px dashed #d6bd9d; + border-radius: 7px; + padding: 6px 8px; + color: #5f4d3f; + cursor: pointer; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + transition: filter 0.15s ease, border-color 0.15s ease; +} + +.contrib-pass-reveal--blurred { + filter: blur(5px); + user-select: none; +} + +.contrib-pass-reveal:not(:disabled):hover { + border-color: #b99762; + border-style: solid; +} + +.contrib-pass-reveal:disabled { + cursor: default; + color: #9e8e7e; +} + +.contrib-pass-edit-inline { + display: flex; + align-items: center; + gap: 6px; + flex: 1; + min-width: 0; +} + +.contrib-pass-edit-input { + flex: 1; + min-width: 0; + border-radius: 7px; + border: 1px solid #b99762; + background: #fffdf8; + color: #453d37; + padding: 5px 8px; + font-size: 14px; + outline: none; +} + +.contrib-pass-edit-input:focus { + border-color: #8b6a43; + box-shadow: 0 0 0 2px rgba(139, 106, 67, 0.12); +} + +.contrib-pill-btn { + flex-shrink: 0; + border-radius: 999px; + padding: 5px 12px; + font-size: 14px; + font-weight: 450; + cursor: pointer; + line-height: 1; + white-space: nowrap; + transition: background 0.15s ease, border-color 0.15s ease, opacity 0.15s ease; +} + +.contrib-pill-btn--ghost { + border: 1px solid rgba(196, 163, 120, 0.75); + background: rgba(255, 248, 238, 0.85); + color: #6a4f32; +} + +.contrib-pill-btn--ghost:not(:disabled):hover { + background: rgba(229, 195, 152, 0.4); + border-color: #b99762; +} + +.contrib-pill-btn--edit { + border: 1px solid rgba(185, 151, 98, 0.55); + background: rgba(185, 151, 98, 0.12); + color: #7a5c2e; +} + +.contrib-pill-btn--edit:not(:disabled):hover { + background: rgba(185, 151, 98, 0.26); + border-color: #8b6a43; +} + +.contrib-pill-btn--save { + border: none; + background: linear-gradient(135deg, #8b6a43, #6a4f32); + color: #fef9ed; +} + +.contrib-pill-btn--save:not(:disabled):hover { + background: linear-gradient(135deg, #9a7550, #7a5c3a); +} + +.contrib-pill-btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.contrib-ok { + border: 1px solid rgba(50, 133, 73, 0.34); + background: rgba(50, 133, 73, 0.1); + color: #2e6f40; + border-radius: 8px; + padding: 7px 9px; + font-size: 14px; +} + +.contrib-copy-feedback { + color: #6a4f32; + font-size: 14px; + font-weight: 450; +} + +.contrib-list-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 12px; +} + +.contrib-list-heading { + display: grid; + gap: 3px; +} + +.contrib-list-heading h2 { + margin: 0; + font-size: 18px; + color: #3a2f27; +} + +.contrib-list-heading p { + margin: 0; + color: #726355; + font-size: 14px; +} + +.contrib-list-header-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.contrib-count { + min-width: 28px; + height: 28px; + border-radius: 999px; + display: inline-grid; + place-items: center; + padding: 0 7px; + background: #6a4f32; + color: #fef9ed; + font-size: 14px; + font-weight: 700; +} + +.contrib-list { + display: grid; + gap: 10px; +} + +.contrib-item { + border: 1px solid #d4b994; + border-radius: 14px; + background: rgba(255, 252, 247, 0.95); + padding: 12px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.contrib-item-stack { + display: grid; + align-items: stretch; + gap: 10px; +} + +.contrib-item-main { + display: grid; + gap: 6px; + color: #43372e; +} + +.contrib-item-main--clickable { + cursor: pointer; + user-select: none; + border-radius: 8px; + padding: 4px; + margin: -4px; + transition: background 0.12s ease; +} + +.contrib-item-main--clickable:hover { + background: rgba(166, 124, 82, 0.06); +} + +.contrib-item-top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; +} + +.contrib-session-top-right { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.contrib-session-title { + font-size: 16px; + line-height: 1.2; + color: #2f2722; +} + +.contrib-session-date { + font-size: 14px; + color: #6a5a4a; + background: #f4e7d5; + border: 1px solid #e4cfb2; + border-radius: 999px; + padding: 3px 8px; + white-space: nowrap; +} + +.contrib-collapse-icon { + color: #a08060; + transition: transform 0.22s cubic-bezier(0.22, 1, 0.36, 1); + flex-shrink: 0; +} + +.contrib-collapse-icon--up { + transform: rotate(180deg); +} + +.contrib-session-comment { + margin: 0; + font-size: 13px; + font-style: italic; + color: #7a6a58; + padding: 2px 0; +} + +.contrib-item-meta { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.contrib-meta-pill { + font-size: 14px; + color: #655647; + background: #f0e1cd; + border: 1px solid #e0c7a5; + border-radius: 999px; + padding: 3px 8px; +} + + +.contrib-selected-person { + color: #5a4d44; + font-size: 14px; + border: 1px dashed #d8c7ac; + border-radius: 8px; + padding: 8px 10px; + background: rgba(255, 255, 255, 0.7); +} + +/* ── Contribution change cards ── */ +.contrib-change-list { + display: grid; + gap: 8px; +} + +.contrib-change-card { + display: grid; + gap: 0; + border: 1.5px solid #d9c5a8; + border-radius: 10px; + overflow: hidden; + background: #fffcf7; + transition: border-color 0.15s, background 0.15s, box-shadow 0.15s; +} + +.contrib-change-card:hover { + box-shadow: 0 2px 10px rgba(100, 75, 50, 0.12); + border-color: #c4a87e; +} + +.contrib-change-card--approved { + border-color: #5a9e6a; + background: #f4fbf5; +} + +.contrib-change-card--approved .contrib-change-card-head { + background: rgba(46, 125, 72, 0.06); +} + +.contrib-change-card--rejected { + border-color: #c0726a; + background: #fdf4f3; +} + +.contrib-change-card--rejected .contrib-change-card-head { + background: rgba(146, 46, 39, 0.05); +} + +.contrib-change-card--rejected .contrib-change-fields, +.contrib-change-card--rejected .contrib-annotation-preview { + opacity: 0.55; +} + +.contrib-change-card-head { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + padding: 9px 12px; + border-bottom: 1px solid rgba(0,0,0,0.06); +} + +.contrib-action-badge { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + padding: 2px 7px; + border-radius: 4px; + flex-shrink: 0; +} + +.contrib-action-badge--create { + background: #d6f0dc; + color: #276138; +} + +.contrib-action-badge--update { + background: #dce8f8; + color: #1e4f8a; +} + +.contrib-action-badge--delete { + background: #fde5e3; + color: #8c2e27; +} + +.contrib-action-badge--reorder { + background: #e8ddd0; + color: #5a3e2b; +} + +.contrib-change-entity-name { + font-size: 14px; + font-weight: 600; + color: #2e2118; + flex: 1; +} + +.contrib-conflict-badge { + display: inline-flex; + align-items: center; + border: 1px solid #d4a074; + background: #f9e7d4; + color: #8a5424; + border-radius: 999px; + padding: 2px 8px; + font-size: 12px; + font-weight: 600; +} + +/* champs modifiés */ +.contrib-change-fields { + display: grid; + gap: 0; + padding: 4px 0; +} + +.contrib-field-row { + display: flex; + align-items: baseline; + gap: 6px; + padding: 5px 12px; + font-size: 13px; + border-bottom: 1px solid rgba(0,0,0,0.04); +} + +.contrib-field-row:last-child { + border-bottom: none; +} + +.contrib-field-label { + color: #7a6a58; + white-space: nowrap; + flex-shrink: 0; + min-width: 110px; + font-size: 12px; +} + +.contrib-field-diff { + display: flex; + align-items: center; + gap: 5px; + flex-wrap: wrap; +} + +.contrib-field-old { + color: #8c3e39; + text-decoration: line-through; + font-size: 13px; +} + +.contrib-field-arrow { + color: #aaa; + font-size: 12px; +} + +.contrib-field-new { + color: #2a5e3a; + font-weight: 500; + font-size: 13px; +} + +/* Boutons décision dans le header de carte */ +.contrib-card-decisions { + display: flex; + gap: 5px; + margin-left: auto; + flex-shrink: 0; +} + +.contrib-decision-icon { + width: 28px; + height: 28px; + border-radius: 50%; + border: 1.5px solid; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background 0.14s ease, border-color 0.14s ease, color 0.14s ease, transform 0.1s ease; + background: transparent; + flex-shrink: 0; +} + +.contrib-decision-icon:active:not(:disabled) { + transform: scale(0.9); +} + +.contrib-decision-icon--approve { + border-color: rgba(46, 125, 72, 0.35); + color: #2e7d48; +} + +.contrib-decision-icon--approve:hover:not(:disabled) { + background: rgba(46, 125, 72, 0.1); + border-color: rgba(46, 125, 72, 0.6); +} + +.contrib-decision-icon--approve.active { + background: #2e7d48; + color: #fff; + border-color: #2e7d48; +} + +.contrib-decision-icon--reject { + border-color: rgba(146, 46, 39, 0.35); + color: #922e27; +} + +.contrib-decision-icon--reject:hover:not(:disabled) { + background: rgba(146, 46, 39, 0.1); + border-color: rgba(146, 46, 39, 0.6); +} + +.contrib-decision-icon--reject.active { + background: #922e27; + color: #fff; + border-color: #922e27; +} + +.contrib-decision-icon:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* Aperçu annotations */ +.contrib-annotation-preview { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + border-top: 1px solid rgba(0,0,0,0.05); + background: rgba(255,252,247,0.6); +} + +.contrib-ann-emoji { + font-size: 30px; + line-height: 1; + flex-shrink: 0; +} + +.contrib-ann-text { + font-style: italic; + color: #5a4d44; + font-size: 13px; + line-height: 1.4; +} + +.contrib-ann-drawing { + display: inline-block; + padding-left: 10px; + border-left: 4px solid #5D524B; + color: #5a4d44; + font-size: 13px; + font-style: italic; +} + +.contrib-ann-photo { + max-width: 120px; + max-height: 80px; + border-radius: 6px; + object-fit: cover; + border: 1px solid #dbc9ab; +} + +/* résumé décisions dans la meta */ +.contrib-meta-sep { + color: #b0a090; +} + +.contrib-decision-summary { + font-size: 13px; + font-weight: 600; + padding: 1px 6px; + border-radius: 4px; +} + +.contrib-decision-summary--ok { + background: #d6f0dc; + color: #2a5e38; +} + +.contrib-decision-summary--ko { + background: #fde5e3; + color: #8c2e27; +} + +.contrib-decision-summary--pending { + color: #8a7868; + font-weight: 400; +} + +.contrib-all-decided-badge { + font-size: 12px; + font-weight: 600; + letter-spacing: 0.02em; + color: #2a6b3e; + background: #d6f0dc; + border: 1px solid #a8d9b4; + border-radius: 999px; + padding: 2px 8px; +} + +/* footer session */ +.contrib-session-footer { + padding: 10px 12px; + display: grid; + gap: 8px; + border-top: 1px solid #e4ceb0; + background: rgba(255,250,242,0.7); +} + +.contrib-apply-btn { + border: none; + border-radius: 10px; + padding: 10px 16px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + background: linear-gradient(135deg, #3a6e4a, #2b5439); + color: #fff; + text-align: left; + line-height: 1.3; +} + +.contrib-apply-btn--approve { + background: linear-gradient(135deg, #3a7a50, #2a5c3c); +} + +.contrib-apply-btn:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.contrib-apply-btn:not(:disabled):hover { + filter: brightness(1.08); +} + +.contrib-session-quick { + display: flex; + gap: 8px; +} + +.contrib-session-quick--full { + grid-column: 1 / -1; +} + +.contrib-quick-btn { + border: 1px solid #cdb89a; + border-radius: 8px; + padding: 7px 12px; + font-size: 13px; + background: rgba(255,255,255,0.7); + color: #5f4a35; + cursor: pointer; +} + +.contrib-quick-btn:not(:disabled):hover { + background: rgba(229, 195, 152, 0.3); + border-color: #b99762; +} + +.contrib-quick-btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +/* Groupement par type d'entité */ +.contrib-change-group + .contrib-change-group { + margin-top: 4px; +} + +.contrib-group-header { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.6px; + color: #9a8878; + padding: 6px 10px 2px; +} + +/* Confirmation "Tout approuver" inline */ +.contrib-confirm-approve { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: #5f4a35; + flex-wrap: wrap; +} + +.contrib-confirm-yes { + border: none; + border-radius: 8px; + padding: 5px 12px; + font-size: 13px; + background: linear-gradient(135deg, #3a7a50, #2a5c3c); + color: #fff; + cursor: pointer; + font-weight: 600; + transition: filter 0.15s; +} + +.contrib-confirm-yes:not(:disabled):hover { + filter: brightness(1.1); +} + +.contrib-confirm-yes:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.contrib-confirm-no { + border: 1px solid #cdb89a; + border-radius: 8px; + padding: 5px 10px; + font-size: 13px; + background: transparent; + color: #5f4a35; + cursor: pointer; +} + +.contrib-confirm-no:hover { + background: rgba(229, 195, 152, 0.3); +} + +/* Toast succès contribution envoyée */ +.contrib-success-toast { + position: fixed; + bottom: 96px; + left: 50%; + transform: translateX(-50%); + z-index: 3000; + background: linear-gradient(135deg, #3a7a50, #2a5c3c); + color: #fff; + font-family: 'Red Hat Mono', ui-monospace, monospace; + font-size: 14px; + font-weight: 600; + padding: 10px 24px; + border-radius: 24px; + box-shadow: 0 4px 20px rgba(42, 92, 60, 0.35); + pointer-events: none; + animation: toastIn 0.3s cubic-bezier(0.22, 1, 0.36, 1); +} + +@keyframes toastIn { + from { opacity: 0; transform: translateX(-50%) translateY(12px); } + to { opacity: 1; transform: translateX(-50%) translateY(0); } +} + +@media (max-width: 768px) { + .contrib-success-toast { + bottom: 80px; + font-size: 13px; + padding: 9px 20px; + } +} + +.contrib-empty, +.contrib-readonly { + color: #6e6258; + background: rgba(255, 255, 255, 0.72); + border: 1px dashed #d8c7ac; + border-radius: 10px; + padding: 12px; +} + +.contrib-error { + background: rgba(171, 49, 49, 0.1); + border: 1px solid rgba(171, 49, 49, 0.32); + color: #862f2f; + border-radius: 10px; + padding: 10px 12px; + font-size: 14px; +} + +@media (max-width: 700px) { + .contrib-card { + width: 90vw; + max-width: none; + } + + .contrib-list-header { + align-items: flex-start; + flex-direction: column; + } + + .contrib-list-header-actions { + width: 100%; + justify-content: space-between; + } + + .contrib-item-top { + flex-direction: column; + gap: 5px; + } + + .contrib-role-row { + flex-wrap: wrap; + } + + .contrib-pass-reveal { + min-width: calc(100% - 70px); + } + + .contrib-pass-edit-inline { + flex-wrap: wrap; + } + + .media-manager-item { + grid-template-columns: 1fr; + } + + .media-manager-actions { + width: 100%; + } + + .media-manager-actions button { + flex: 1; + } + + .contrib-field-label { + min-width: 80px; + } + + .contrib-session-quick { + flex-direction: column; + } + + .contrib-quick-btn { + width: 100%; + } + + .admin-audit-item { + flex-direction: column; + align-items: flex-start; + } + + .admin-audit-meta { + text-align: left; + } + + .admin-audit-header { + flex-direction: column; + align-items: flex-start; + } +} + +.account-dashboard-card.auth-only { + width: min(480px, 100%); + text-align: center; +} + +.account-subtitle { + margin-top: -2px; +} + +.account-inline-link { + border: none; + background: transparent; + color: #6a4f32; + cursor: pointer; + font-size: 14px; +} + +.account-inline-link:hover { + text-decoration: underline; +} + +.account-divider { + color: #8f806f; + text-align: center; + font-size: 13px; +} + +.account-secondary-btn { + border: 1px solid #cdb89a; + border-radius: 10px; + padding: 11px 14px; + background: #fff8ef; + color: #6a4f32; + font-weight: 500; + cursor: pointer; +} + +.account-secondary-btn:hover:not(:disabled) { + background: #fff2de; + border-color: #b99762; +} + +.account-secondary-btn:disabled { + opacity: 0.65; + cursor: not-allowed; +} + +.account-shared-link { + width: 100%; +} + +.account-secondary-actions { + display: grid; + gap: 8px; +} + +.tree-created-prompt { + position: fixed; + top: 20px; + left: 20px; + z-index: 1200; + max-width: 340px; + padding: 12px 14px; + display: grid; + gap: 8px; + border-radius: 14px; + border: 1px solid rgba(93, 82, 75, 0.14); + background: rgba(247, 236, 217, 0.95); + box-shadow: 0 6px 24px rgba(70, 55, 40, 0.18); + color: #5d524b; + font-size: 13px; + line-height: 1.45; +} + +.tree-created-prompt strong { + color: #433931; + font-size: 14px; +} + +.tree-created-prompt button { + justify-self: start; + border: 1px solid #cdb89a; + border-radius: 8px; + background: #fff8ef; + color: #6a4f32; + padding: 6px 10px; + cursor: pointer; +} + +.settings-card { + width: min(900px, 100%); +} + +.settings-tabs { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.settings-tabs button { + border: 1px solid #cdb89a; + border-radius: 999px; + padding: 8px 12px; + background: transparent; + color: #6a4f32; + cursor: pointer; +} + +.settings-tabs button.active { + color: #fef9ed; + border-color: #7a5b39; + background: linear-gradient(135deg, #8b6a43, #6a4f32); +} + +.settings-share-card { + display: grid; + gap: 8px; + border: 1px solid #e3d7c2; + border-radius: 12px; + padding: 12px; + background: rgba(255, 255, 255, 0.65); +} + +.settings-share-card h3 { + color: #4f443d; + font-size: 16px; +} + +.settings-share-link-row { + display: flex; + gap: 8px; + align-items: center; +} + +.settings-share-link-row input { + flex: 1; + min-width: 0; +} + +.settings-password-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.settings-password-row code { + border: 1px dashed #d8c7ac; + border-radius: 8px; + background: #fffdf8; + padding: 6px 10px; + color: #5a4d44; +} + +.settings-hint { + color: #6e6258; + font-size: 14px; + border-left: 3px solid #d8c7ac; + padding-left: 10px; +} + +.settings-data-row { + display: flex; + justify-content: space-between; + gap: 12px; + border: 1px solid #e8dcc8; + border-radius: 10px; + padding: 12px; + align-items: center; +} + +.settings-data-row p { + color: #726458; + font-size: 14px; + margin-top: 4px; +} + +.settings-data-row--muted { + opacity: 0.72; +} + +.settings-advanced-toggle { + justify-self: start; +} + +.settings-danger-box { + display: grid; + gap: 8px; + border: 1px solid rgba(171, 49, 49, 0.34); + border-radius: 12px; + background: rgba(171, 49, 49, 0.06); + padding: 12px; +} + +.danger-action { + border: 1px solid rgba(171, 49, 49, 0.45); + border-radius: 10px; + padding: 10px 12px; + background: rgba(171, 49, 49, 0.1); + color: #862f2f; + cursor: pointer; + font-weight: 600; +} + +.danger-action:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.settings-delete-modal-overlay { + position: fixed; + inset: 0; + z-index: 2300; + display: grid; + place-items: center; + padding: 20px; + background: rgba(33, 25, 19, 0.55); +} + +.settings-delete-modal { + width: min(460px, 100%); + display: grid; + gap: 10px; + border-radius: 16px; + border: 1px solid #d8c7ac; + background: #fffdf8; + padding: 18px; + box-shadow: 0 24px 45px rgba(50, 38, 25, 0.34); +} + +.settings-delete-modal h3 { + color: #433931; + font-size: 22px; +} + +.settings-delete-modal p { + color: #6e6258; +} + +.settings-delete-modal input { + border-radius: 10px; + border: 1px solid #d9cdb8; + background: #fffdf8; + color: #453d37; + padding: 10px 12px; +} + +.settings-delete-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +@media (max-width: 700px) { + .settings-share-link-row { + flex-direction: column; + align-items: stretch; + } + + .settings-data-row { + flex-direction: column; + align-items: stretch; + } + + .settings-delete-actions { + flex-direction: column; + } +} + +.account-overlay { + position: fixed; + inset: 0; + z-index: 2300; + visibility: hidden; + pointer-events: none; + opacity: 0; + transition: opacity 0.24s ease; + background: rgba(33, 25, 19, 0.42); +} + +.account-overlay--open { + visibility: visible; + pointer-events: auto; + opacity: 1; +} + +.account-card { + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: min(520px, 100vw); + max-height: 100vh; + overflow: auto; + display: grid; + gap: 12px; + padding: 18px; + background: linear-gradient(180deg, #fffaf1 0%, #f8ede0 100%); + border-left: 1px solid #cbb391; + box-shadow: -14px 0 44px rgba(29, 22, 16, 0.36); + transform: translateX(100%); + transition: transform 0.32s cubic-bezier(0.22, 1, 0.36, 1); + z-index: 2; +} + +.account-overlay--open .account-card { + transform: translateX(0); +} + +.account-backdrop { + position: absolute; + inset: 0; + background: transparent; + border: none; + cursor: default; + z-index: 1; +} + +.account-card > * { + position: relative; + z-index: 2; +} + +.account-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + border-bottom: 1px solid #e2cfb3; + padding-bottom: 10px; +} + +.account-header h1 { + color: #2e2824; + font-size: 30px; +} + +.account-header p { + color: #675848; + font-size: 14px; +} + +.account-linked, +.account-pick { + display: grid; + gap: 10px; + border: 1px solid #e3d7c2; + border-radius: 12px; + padding: 14px; + background: rgba(255, 255, 255, 0.65); +} + +.account-linked h2, +.account-pick h2 { + color: #564a42; + font-size: 18px; +} + +.account-linked-person { + display: flex; + align-items: center; + gap: 10px; +} + +.account-linked-avatar, +.account-person-avatar { + width: 44px; + height: 44px; + border-radius: 50%; + overflow: hidden; + background: linear-gradient(145deg, #a67c52, #8b6843); + color: #fef9ed; + display: grid; + place-items: center; + font-weight: 700; +} + +.account-linked-avatar img, +.account-person-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.account-linked-meta { + display: grid; + gap: 2px; +} + +.account-linked-meta strong { + color: #453d37; +} + +.account-linked-meta span { + color: #6e6258; + font-size: 14px; +} + +.account-pick input { + width: 100%; + border-radius: 10px; + border: 1px solid #d9cdb8; + background: #fffdf8; + color: #453d37; + padding: 10px 12px; +} + +.account-person-list { + display: grid; + gap: 8px; + max-height: min(52vh, 420px); + overflow: auto; +} + +.account-person-item { + display: flex; + align-items: center; + gap: 10px; + border: 1px solid #d8c7ac; + border-radius: 10px; + background: #fffdf8; + color: #453d37; + padding: 8px 10px; + cursor: pointer; + text-align: left; +} + +.account-person-item.active { + border-color: #8b6a43; + box-shadow: 0 0 0 2px rgba(139, 106, 67, 0.12); +} + +.account-person-name { + flex: 1; +} + +.account-person-tag { + font-size: 12px; + color: #2f6e3a; + border: 1px solid rgba(52, 130, 70, 0.3); + background: rgba(52, 130, 70, 0.1); + border-radius: 999px; + padding: 3px 8px; +} + +.tree-settings-overlay { + z-index: 2250; +} + +.tree-settings-overlay .tree-settings-card { + top: 0; + left: 0; + right: auto; + bottom: 0; + width: 50vw; + min-width: 500px; + max-width: 980px; + border-right: 1px solid #cbb391; + border-left: 0; + box-shadow: 14px 0 44px rgba(29, 22, 16, 0.36); + transform: translateX(-100%); +} + +.tree-settings-overlay--open .tree-settings-card, +.contrib-overlay--open .tree-settings-card { + transform: translateX(0); +} + +.tree-settings-content { + display: grid; + gap: 14px; +} + +.tree-settings-tabs { + display: flex; + align-items: center; + gap: 8px; + position: sticky; + top: 0; + z-index: 4; + padding: 8px 0 10px; + background: linear-gradient(180deg, rgba(255, 250, 241, 0.96) 0%, rgba(255, 250, 241, 0.84) 75%, rgba(255, 250, 241, 0) 100%); + backdrop-filter: blur(6px); +} + +.tree-settings-tab { + border: 1px solid #cdb89a; + border-radius: 999px; + padding: 8px 13px; + background: transparent; + color: #5f4a32; + font-size: 14px; + font-weight: 500; + letter-spacing: 0.01em; + cursor: pointer; + transition: all 0.16s ease; +} + +.tree-settings-tab:hover { + background: rgba(255, 248, 237, 0.9); + border-color: #b99762; + color: #4f3d2a; +} + +.tree-settings-tab.active { + color: #fef9ed; + border-color: #7a5b39; + background: linear-gradient(135deg, #8b6a43, #6a4f32); + box-shadow: 0 8px 18px rgba(106, 79, 50, 0.24); +} + +.tree-settings-tab--danger { + border-color: rgba(171, 49, 49, 0.36); + color: #7f3e3e; +} + +.tree-settings-tab--danger:hover { + border-color: rgba(171, 49, 49, 0.56); + background: rgba(171, 49, 49, 0.08); + color: #742f2f; +} + +.tree-settings-tab--danger.active { + border-color: rgba(150, 40, 40, 0.85); + background: linear-gradient(135deg, #a94949, #873838); + color: #fff5f5; + box-shadow: 0 8px 18px rgba(129, 49, 49, 0.28); +} + +.tree-settings-panel { + display: grid; + gap: 11px; + border: 1px solid #e3d7c2; + border-radius: 14px; + padding: 15px; + background: rgba(255, 255, 255, 0.7); + box-shadow: 0 6px 18px rgba(85, 66, 44, 0.08); +} + +.tree-settings-panel h2 { + margin: 0; + font-size: 18px; + color: #564a42; +} + +.tree-settings-help { + margin: 0; + color: #6e6258; + font-size: 14px; + line-height: 1.45; +} + +.tree-settings-panel label { + font-size: 12px; + color: #7a6956; + text-transform: uppercase; + letter-spacing: 0.06em; + font-weight: 600; +} + +.tree-settings-panel input { + width: 100%; + border-radius: 10px; + border: 1px solid #d9cdb8; + background: #fffdf8; + color: #453d37; + padding: 10px 12px; + outline: none; +} + +.tree-settings-panel input:focus { + border-color: #b99762; + box-shadow: 0 0 0 2px rgba(185, 151, 98, 0.15); +} + +.tree-settings-actions { + display: flex; + align-items: center; + justify-content: flex-start; +} + +.tree-settings-primary { + border: 0; + border-radius: 11px; + padding: 10px 12px; + font-weight: 600; + color: #fef9ed; + background: linear-gradient(135deg, #8b6a43, #6a4f32); + cursor: pointer; + width: auto; + justify-self: start; + transition: transform 0.15s ease, filter 0.15s ease, box-shadow 0.15s ease; + box-shadow: 0 6px 14px rgba(89, 65, 42, 0.22); +} + +.tree-settings-primary:hover:not(:disabled) { + transform: translateY(-1px); + filter: brightness(1.05); + box-shadow: 0 10px 16px rgba(89, 65, 42, 0.26); +} + +.tree-settings-primary:disabled { + opacity: 0.65; + cursor: not-allowed; + box-shadow: none; +} + +.tree-settings-secondary { + border: 1px solid #cdb89a; + border-radius: 11px; + padding: 9px 12px; + background: rgba(255, 250, 242, 0.95); + color: #5d4933; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; +} + +.tree-settings-secondary:hover:not(:disabled) { + border-color: #b99762; + background: #fff2de; + color: #4f3d2a; +} + +.tree-settings-secondary:disabled { + opacity: 0.62; + cursor: not-allowed; +} + +.tree-settings-row { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + border: 1px solid #e8dcc8; + border-radius: 12px; + padding: 12px; + background: rgba(255, 255, 255, 0.82); +} + +.tree-settings-row-text { + display: grid; + gap: 3px; +} + +.tree-settings-row-text strong { + color: #3e352f; + font-size: 15px; +} + +.tree-settings-row-text p { + margin: 0; + color: #726458; + font-size: 14px; + line-height: 1.4; +} + +.tree-settings-row-actions { + display: grid; + justify-items: end; + align-content: start; + gap: 8px; + min-width: 220px; +} + +.tree-settings-row-actions--inline { + min-width: 0; +} + +.tree-settings-file-input { + display: none; +} + +.tree-settings-file-name { + max-width: 280px; + font-size: 12px; + color: #645544; + background: #fbf3e8; + border: 1px solid #e2cfb5; + border-radius: 8px; + padding: 6px 9px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tree-settings-row--muted { + opacity: 0.72; +} + +.tree-settings-link-btn { + justify-self: start; + border: 1px solid rgba(171, 49, 49, 0.42); + border-radius: 11px; + padding: 9px 12px; + background: rgba(171, 49, 49, 0.08); + color: #7a3131; + font-weight: 550; + cursor: pointer; + transition: background-color 0.16s ease, border-color 0.16s ease, color 0.16s ease; +} + +.tree-settings-link-btn:hover { + background: rgba(171, 49, 49, 0.14); + border-color: rgba(171, 49, 49, 0.64); + color: #682323; +} + +.tree-settings-panel--danger { + border-color: rgba(171, 49, 49, 0.35); + background: rgba(171, 49, 49, 0.03); +} + +.tree-settings-panel--danger .tree-settings-help { + color: #7a4040; +} + +.tree-settings-danger-box { + display: grid; + gap: 10px; + border: 1px solid rgba(171, 49, 49, 0.34); + border-radius: 12px; + background: rgba(171, 49, 49, 0.06); + padding: 13px; +} + +.tree-settings-danger-box .danger-action { + justify-self: start; +} + +.tree-settings-danger-box p { + margin: 0; + color: #7a3e3e; + font-size: 14px; + line-height: 1.45; +} + +@media (max-width: 700px) { + .tree-settings-overlay .tree-settings-card { + width: 100%; + max-width: 100%; + min-width: 0; + padding: 18px 14px 20px; + } + + .tree-settings-tabs { + position: static; + padding-top: 0; + background: transparent; + backdrop-filter: none; + } + + .tree-settings-row { + flex-direction: column; + align-items: stretch; + } + + .tree-settings-row-actions { + justify-items: stretch; + min-width: 0; + } + + .tree-settings-file-name { + max-width: 100%; + } + + .tree-settings-actions { + width: 100%; + } + + .tree-settings-primary { + width: 100%; + } +} + +/* Écran compte : contexte de retour sur un arbre partagé + aide */ +.account-pending-tree { + margin: -6px 0 14px; + font-size: 13px; + color: var(--ink-2, #8B7355); +} + +.account-hint { + margin: -6px 0 10px; + font-size: 12px; + color: var(--ink-2, #8B7355); +} + +/* Paramètres : contributions de la famille */ +.tree-settings-policy { + margin: 22px 0 0; + padding: 0; + border: 0; +} + +.tree-settings-policy legend { + font-weight: 600; + margin-bottom: 4px; +} + +.tree-settings-radio { + display: flex; + gap: 10px; + align-items: flex-start; + padding: 10px 12px; + margin-top: 8px; + border: 1px solid rgba(93, 82, 75, 0.14); + border-radius: 10px; + cursor: pointer; +} + +.tree-settings-radio input { + margin-top: 3px; +} + +.tree-settings-radio span { + display: flex; + flex-direction: column; + gap: 2px; +} + +.tree-settings-radio small { + color: rgba(93, 82, 75, 0.7); +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..76d1f76 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,2552 @@ +import { useEffect, useState, useCallback, useRef, useMemo } from 'react' +import Galaxy from './components/Galaxy' +import PersonCard from './components/PersonCard.jsx' +import MediaViewer from './components/MediaViewer' +import MediaManagerPanel from './components/MediaManagerPanel' +import AdminPanel from './components/AdminPanel' +import AccessGate from './components/AccessGate' +import AccountDashboard from './components/AccountDashboard' +import AccountPanel from './components/AccountPanel' +import TreeCreationWizard from './components/TreeCreationWizard' +import ContributionPanel from './components/ContributionPanel' +import EditModeOverlay from './components/EditSessionBar.jsx' +import EditConfirmModal from './components/EditConfirmModal' +import DraftRestoreBanner from './components/DraftRestoreBanner' +import AddPersonPanel from './components/AddPersonPanel' +import ContributionSessionModal from './components/ContributionSessionModal' +import { ContextualNavbar } from './components/navbar' +import ZoomControl from './components/ZoomControl' +import { ExpandableSearch } from './components/search' +import { + createParentChildLink, + deleteParentChildLink, + deletePerson, + deleteUnion, + createPerson, + createTree, + createUnion, + batchAnnotations, + deletePersonAvatar, + getTree, + linkTreeAccess, + updateTree, + rotateTreePasswords, + resolveTreeBySlug, + uploadPersonAvatar, + updatePerson, + uploadAnnotationPhoto, +} from './api/treeApi' +import { useAuth } from './hooks/useAuth' +import { useTreeAccess, getTreeTokenStorageKey, getSlugFromPathname, readTreeAccessRole, TREE_ID_FROM_ENV } from './hooks/useTreeAccess' +import { useMediaManager } from './hooks/useMediaManager' +import { useContributions } from './hooks/useContributions' +import { useUserRole } from './hooks/useUserRole' +import { usePersonSearch } from './hooks/usePersonSearch' +import { useEditMode } from './hooks/useEditMode' +import { useAnnotations } from './hooks/useAnnotations' +import AnnotationToolbar from './components/annotations/AnnotationToolbar' +import TextInputOverlay from './components/annotations/TextInputOverlay' +import { filiations, medias, persons, unions } from './data/mockData' +import { fileToBase64, getAccountErrorMessage } from './utils/errorMessages' +import { getUploadMimeType, validateMediaFile } from './utils/mediaUpload' +import { + identifyUser, + resetUser, + toInteractionMediaType, + toJourneyRole, + toOpaqueTreeId, + trackAppEvent, +} from './utils/analytics' +import './App.css' + +function yearToIsoDate(value) { + if (value === null || value === undefined || value === '') { + return null + } + + const raw = String(value).trim() + + // If user provided a full date YYYY-MM-DD, keep it. + if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) { + return raw + } + + const year = Number.parseInt(raw.slice(0, 4), 10) + if (!Number.isFinite(year)) { + return null + } + + return `${String(year).padStart(4, '0')}-01-01` +} + +function extractYearFromIsoDate(value) { + if (!value || typeof value !== 'string') return null + const year = Number.parseInt(value.slice(0, 4), 10) + return Number.isFinite(year) ? year : null +} + +function normalizeSlug(value) { + return String(value || '') + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') +} + +function generateSecurePassword() { + return crypto.randomUUID().replace(/-/g, '') +} + +function getAccountSelfPersonStorageKey(userEmail, treeId) { + return `account_self_person_${userEmail || 'unknown'}_${treeId || 'none'}` +} + +function buildContributionChangesFromDraft(draft = {}) { + const changes = [] + const relationTypeToCanonical = (relation) => { + if (!relation || !relation.sourcePersonId || !relation.targetPersonId) { + return null + } + + const sourcePersonId = String(relation.sourcePersonId) + const targetPersonId = String(relation.targetPersonId) + const relationType = String(relation.relationType || '') + + if (relationType === 'spouse') { + const ordered = [sourcePersonId, targetPersonId].sort() + return { + relationType: 'spouse', + sourcePersonId, + targetPersonId, + key: `spouse:${ordered[0]}:${ordered[1]}`, + } + } + + if (relationType === 'parent' || relationType === 'child') { + const parentPersonId = relationType === 'parent' ? targetPersonId : sourcePersonId + const childPersonId = relationType === 'parent' ? sourcePersonId : targetPersonId + return { + relationType: 'parent_child_link', + parentPersonId, + childPersonId, + key: `parent_child:${parentPersonId}:${childPersonId}`, + } + } + + return null + } + + const resolveRemovedRelationEntity = (canonicalRelation) => { + if (!canonicalRelation) { + return null + } + + if (canonicalRelation.relationType === 'spouse') { + const union = unions.find((candidate) => ( + (String(candidate.partner1Id) === canonicalRelation.sourcePersonId + && String(candidate.partner2Id) === canonicalRelation.targetPersonId) + || (String(candidate.partner1Id) === canonicalRelation.targetPersonId + && String(candidate.partner2Id) === canonicalRelation.sourcePersonId) + )) + + if (!union) { + return null + } + + return { + entityType: 'union', + action: 'delete', + entityId: String(union.id), + } + } + + let link = filiations.find((candidate) => ( + String(candidate.childId) === canonicalRelation.childPersonId + && String(candidate.parentId) === canonicalRelation.parentPersonId + )) + + if (!link) { + const relatedUnionIds = new Set( + unions + .filter((candidate) => ( + String(candidate.partner1Id) === canonicalRelation.parentPersonId + || String(candidate.partner2Id) === canonicalRelation.parentPersonId + )) + .map((candidate) => String(candidate.id)), + ) + + link = filiations.find((candidate) => ( + String(candidate.childId) === canonicalRelation.childPersonId + && candidate.unionId + && relatedUnionIds.has(String(candidate.unionId)) + )) + } + + if (!link) { + return null + } + + return { + entityType: 'parent_child_link', + action: 'delete', + entityId: String(link.id), + } + } + + const addedPersons = Array.isArray(draft.addedPersons) ? draft.addedPersons : [] + for (const person of addedPersons) { + if (!person || typeof person !== 'object') continue + if (!person.firstName || !person.lastName) continue + + changes.push({ + entityType: 'person', + action: 'create', + after: { + firstName: String(person.firstName).trim(), + lastName: String(person.lastName).trim(), + birthName: person.birthName ? String(person.birthName).trim() : null, + birthDate: person.birthDate || null, + deathDate: person.deathDate || null, + }, + }) + } + + const modifiedPersons = draft.modifiedPersons && typeof draft.modifiedPersons === 'object' + ? draft.modifiedPersons + : {} + + for (const [personId, rawChanges] of Object.entries(modifiedPersons)) { + const source = rawChanges && typeof rawChanges === 'object' ? rawChanges : {} + const after = {} + + if (Object.prototype.hasOwnProperty.call(source, 'firstName')) after.firstName = source.firstName || '' + if (Object.prototype.hasOwnProperty.call(source, 'lastName')) after.lastName = source.lastName || '' + if (Object.prototype.hasOwnProperty.call(source, 'birthName')) after.birthName = (source.birthName || '').trim() || null + if (Object.prototype.hasOwnProperty.call(source, 'birthYear')) after.birthDate = yearToIsoDate(source.birthYear) + if (Object.prototype.hasOwnProperty.call(source, 'deathYear')) after.deathDate = yearToIsoDate(source.deathYear) + if (Object.prototype.hasOwnProperty.call(source, 'birthPlace')) after.birthPlace = (source.birthPlace || '').trim() || null + if (Object.prototype.hasOwnProperty.call(source, 'profession')) after.profession = (source.profession || '').trim() || null + if (Object.prototype.hasOwnProperty.call(source, 'region')) after.region = (source.region || '').trim() || null + if (Object.prototype.hasOwnProperty.call(source, 'nationality')) after.nationality = (source.nationality || '').trim() || null + if (Object.prototype.hasOwnProperty.call(source, 'note')) after.notes = source.note || null + if (Object.prototype.hasOwnProperty.call(source, 'isAlive') && source.isAlive === true) after.deathDate = null + + if (Object.keys(after).length > 0) { + changes.push({ + entityType: 'person', + action: 'update', + entityId: personId, + after, + }) + } + } + + const deletedPersons = Array.isArray(draft.deletedPersons) ? draft.deletedPersons : [] + for (const personId of deletedPersons) { + if (!personId) continue + changes.push({ + entityType: 'person', + action: 'delete', + entityId: String(personId), + }) + } + + const addedRelations = Array.isArray(draft.addedRelations) ? draft.addedRelations : [] + const removedRelations = Array.isArray(draft.removedRelations) ? draft.removedRelations : [] + const canonicalAddedRelations = [] + const canonicalRemovedRelations = [] + const addedRelationKeys = new Set() + const removedRelationKeys = new Set() + + for (const relation of addedRelations) { + const canonicalRelation = relationTypeToCanonical(relation) + if (!canonicalRelation || addedRelationKeys.has(canonicalRelation.key)) continue + canonicalAddedRelations.push(canonicalRelation) + addedRelationKeys.add(canonicalRelation.key) + } + + for (const relation of removedRelations) { + const canonicalRelation = relationTypeToCanonical(relation) + if (!canonicalRelation || removedRelationKeys.has(canonicalRelation.key)) continue + canonicalRemovedRelations.push(canonicalRelation) + removedRelationKeys.add(canonicalRelation.key) + } + + const canceledRelationKeys = new Set( + [...addedRelationKeys].filter((key) => removedRelationKeys.has(key)), + ) + + for (const canonicalRelation of canonicalAddedRelations) { + if (canceledRelationKeys.has(canonicalRelation.key)) continue + + if (canonicalRelation.relationType === 'spouse') { + changes.push({ + entityType: 'union', + action: 'create', + after: { + partner1PersonId: canonicalRelation.sourcePersonId, + partner2PersonId: canonicalRelation.targetPersonId, + unionType: 'mariage', + }, + }) + } else if (canonicalRelation.relationType === 'parent_child_link') { + changes.push({ + entityType: 'parent_child_link', + action: 'create', + after: { + parentPersonId: canonicalRelation.parentPersonId, + childPersonId: canonicalRelation.childPersonId, + parentageType: 'biologique', + }, + }) + } + } + + for (const relation of canonicalRemovedRelations) { + if (canceledRelationKeys.has(relation.key)) continue + const change = resolveRemovedRelationEntity(relation) + if (!change) continue + changes.push(change) + } + + // Annotations (stickers, dessins, textes, photos canvas) + const annData = Array.isArray(draft.annotationsData) ? draft.annotationsData : [] + const addedAnnIds = new Set(Array.isArray(draft.addedAnnotations) ? draft.addedAnnotations : []) + const modifiedAnnIds = new Set(Array.isArray(draft.modifiedAnnotations) ? draft.modifiedAnnotations : []) + const deletedAnnIds = Array.isArray(draft.deletedAnnotations) ? draft.deletedAnnotations : [] + + for (const ann of annData) { + const isAdded = addedAnnIds.has(ann.id) + const isModified = modifiedAnnIds.has(ann.id) + if (!isAdded && !isModified) continue + const action = isAdded ? 'create' : 'update' + const change = { + entityType: 'annotation', + action, + after: { + type: ann.type, + x: ann.x, + y: ann.y, + content: ann.content, + style: ann.style || {}, + zIndex: ann.zIndex || 0, + }, + } + if (action === 'update') change.entityId = String(ann.id) + changes.push(change) + } + + for (const annId of deletedAnnIds) { + changes.push({ entityType: 'annotation', action: 'delete', entityId: String(annId) }) + } + + return changes +} + +function buildContribChangesList(draft = {}) { + const items = [] + const addedPersonIds = (Array.isArray(draft.addedPersons) ? draft.addedPersons : []).map(String) + + for (const id of addedPersonIds) { + const d = draft.modifiedPersons?.[id] || {} + const name = `${d.firstName || ''} ${d.lastName || ''}`.trim() || 'Nouvelle personne' + items.push({ entityType: 'person', action: 'create', label: name }) + } + + for (const [id, d] of Object.entries(draft.modifiedPersons || {})) { + if (addedPersonIds.includes(String(id))) continue + const name = `${d.firstName || ''} ${d.lastName || ''}`.trim() || 'Personne' + items.push({ entityType: 'person', action: 'update', label: name }) + } + + for (let i = 0; i < (draft.deletedPersons || []).length; i++) { + items.push({ entityType: 'person', action: 'delete', label: 'Personne' }) + } + + const annData = Array.isArray(draft.annotationsData) ? draft.annotationsData : [] + const addedAnnIds = new Set(Array.isArray(draft.addedAnnotations) ? draft.addedAnnotations : []) + const modifiedAnnIds = new Set(Array.isArray(draft.modifiedAnnotations) ? draft.modifiedAnnotations : []) + const deletedAnnCount = (Array.isArray(draft.deletedAnnotations) ? draft.deletedAnnotations : []).length + const annTypeLabel = (t) => ({ sticker: 'Sticker', drawing: 'Dessin', text: 'Texte', photo: 'Photo' }[t] || 'Annotation') + + for (const ann of annData) { + if (addedAnnIds.has(ann.id)) items.push({ entityType: 'annotation', action: 'create', label: annTypeLabel(ann.type) }) + else if (modifiedAnnIds.has(ann.id)) items.push({ entityType: 'annotation', action: 'update', label: annTypeLabel(ann.type) }) + } + for (let i = 0; i < deletedAnnCount; i++) { + items.push({ entityType: 'annotation', action: 'delete', label: 'Annotation' }) + } + + for (let i = 0; i < (draft.addedRelations || []).length; i++) { + items.push({ entityType: 'relation', action: 'create', label: 'Relation' }) + } + for (let i = 0; i < (draft.removedRelations || []).length; i++) { + items.push({ entityType: 'relation', action: 'delete', label: 'Relation' }) + } + + return items +} + +function getInvitePasswordStorageKey(treeId, role) { + return `invite_password_${treeId}_${role}` +} + +function readInvitePasswordsFromStorage(treeId) { + if (!treeId) { + return { visitor: '', contributor: '' } + } + + try { + return { + visitor: localStorage.getItem(getInvitePasswordStorageKey(treeId, 'visitor')) || '', + contributor: localStorage.getItem(getInvitePasswordStorageKey(treeId, 'contributor')) || '', + } + } catch { + return { visitor: '', contributor: '' } + } +} + +function writeInvitePasswordsToStorage(treeId, passwords) { + if (!treeId) { + return + } + + try { + if (passwords.visitor) { + localStorage.setItem(getInvitePasswordStorageKey(treeId, 'visitor'), passwords.visitor) + } + if (passwords.contributor) { + localStorage.setItem(getInvitePasswordStorageKey(treeId, 'contributor'), passwords.contributor) + } + } catch { + // Ignore localStorage failures + } +} + +function App() { + const auth = useAuth() + const tree = useTreeAccess() + // Arbre partagé d'où l'on vient quand on ouvre l'écran compte : on y revient après connexion/création + const pendingSharedTreeRef = useRef(null) + const refreshTreeAndKeepSelection = useCallback(async (personId, options = {}) => { + await tree.refreshCurrentTreeGraph({ + relayout: options.relayout !== false, + avatarCacheBust: options.avatarCacheBust, + }) + if (personId !== null && personId !== undefined) { + const refreshedPerson = persons.find((candidate) => String(candidate.id) === String(personId)) || null + tree.setSelectedPerson(refreshedPerson) + } + }, [tree.refreshCurrentTreeGraph, tree.setSelectedPerson]) // eslint-disable-line react-hooks/exhaustive-deps + + const media = useMediaManager({ + canEditCurrentTree: tree.canEditCurrentTree, + canSubmitContribution: tree.canSubmitContribution, + selectedPerson: tree.selectedPerson, + treeContext: tree.treeContext, + refreshCurrentTreeGraph: tree.refreshCurrentTreeGraph, + refreshTreeAndKeepSelection, + }) + const contrib = useContributions({ + canEditCurrentTree: tree.canEditCurrentTree, + canSubmitContribution: tree.canSubmitContribution, + treeContext: tree.treeContext, + refreshCurrentTreeGraph: tree.refreshCurrentTreeGraph, + }) + + const [adminPanelVisible, setAdminPanelVisible] = useState(false) + const [showConfirmModal, setShowConfirmModal] = useState(false) + const [showDraftBanner, setShowDraftBanner] = useState(false) + const [accountPanelVisible, setAccountPanelVisible] = useState(false) + const [linkedSelfPersonId, setLinkedSelfPersonId] = useState('') + const [relationFeedback, setRelationFeedback] = useState({ + loading: false, + success: '', + error: '', + }) + const [avatarActionState, setAvatarActionState] = useState({ + loading: false, + error: '', + }) + const [treeNotFound, setTreeNotFound] = useState(false) + const [accountEntryMode, setAccountEntryMode] = useState('login') + const [treeWizardVisible, setTreeWizardVisible] = useState(false) + const [treeWizardLoading, setTreeWizardLoading] = useState(false) + const [treeWelcomeVisible, setTreeWelcomeVisible] = useState(false) + const [contribInviteState, setContribInviteState] = useState({ + shareUrl: '', + knownPasswords: { + visitor: '', + contributor: '', + }, + loading: false, + error: '', + message: '', + }) + + const buildEmptyTreeContext = useCallback((overrides = {}) => ({ + treeId: '', + treeName: '', + treeDescription: '', + treeOwnerName: '', + treeOwnerEmail: '', + role: '', + accessMode: '', + rootPersonId: null, + accessToken: '', + ...overrides, + }), []) + + // Déterminer le rôle et permissions de l'utilisateur + const userRole = useUserRole({ + treeContext: tree.treeContext, + authenticated: auth.authenticated, + }) + + const accountUser = auth.userAuth.user || null + const linkedSelfPerson = useMemo(() => ( + persons.find((candidate) => String(candidate.id) === String(linkedSelfPersonId)) || null + ), [linkedSelfPersonId, tree.graphRevision, tree.treeContext.treeId]) + + const accountDisplayName = useMemo(() => { + if (linkedSelfPerson) { + const fullName = `${linkedSelfPerson.firstName || ''} ${linkedSelfPerson.lastName || ''}`.trim() + if (fullName) { + return fullName + } + } + + const firstName = accountUser?.firstName || accountUser?.name || '' + if (firstName && String(firstName).trim()) { + return String(firstName).trim() + } + + const email = accountUser?.email || '' + if (!email) { + return 'Compte' + } + + return String(email).split('@')[0] || 'Compte' + }, [accountUser?.email, accountUser?.firstName, accountUser?.name, linkedSelfPerson]) + + const accountEmail = accountUser?.email || '' + const accountAvatarPhoto = linkedSelfPerson?.photo || '' + const activeAccountTreeName = useMemo(() => { + const activeTree = auth.accountTrees.find( + (candidate) => String(candidate.id) === String(tree.treeContext.treeId), + ) + return activeTree?.name || tree.treeContext.treeName || '' + }, [auth.accountTrees, tree.treeContext.treeId, tree.treeContext.treeName]) + + const isDemoMode = tree.treeContext.accessMode === 'demo' + const canEditInCurrentMode = tree.canSubmitContribution || isDemoMode + + // Recherche de personnes avec scoring + const search = usePersonSearch(persons) + + // Mode édition avec auto-save + const editMode = useEditMode({ + treeId: tree.treeContext.treeId, + canEdit: canEditInCurrentMode, + }) + + const localEntityCounterRef = useRef(0) + const makeLocalId = useCallback((prefix) => { + localEntityCounterRef.current += 1 + return `${prefix}-${Date.now().toString(36)}-${localEntityCounterRef.current}` + }, []) + const shareSessionStartedAtRef = useRef(0) + const shareSessionTimeSentRef = useRef(false) + + const journeyRole = useMemo( + () => toJourneyRole(tree.treeContext.role), + [tree.treeContext.role], + ) + const opaqueTreeId = useMemo( + () => toOpaqueTreeId(tree.treeContext.treeId), + [tree.treeContext.treeId], + ) + const isTrackableShareSession = Boolean( + tree.bootState === 'ready' + && tree.treeContext.accessMode === 'share' + && tree.treeContext.treeId + && journeyRole, + ) + + const captureTreeTimeSpent = useCallback(() => { + if (!isTrackableShareSession || shareSessionTimeSentRef.current) return + if (!shareSessionStartedAtRef.current) return + + const elapsedSeconds = Math.max(1, Math.round((Date.now() - shareSessionStartedAtRef.current) / 1000)) + trackAppEvent('tree_time_spent', { + seconds: elapsedSeconds, + role: journeyRole, + tree_id: opaqueTreeId, + }) + shareSessionTimeSentRef.current = true + }, [isTrackableShareSession, journeyRole, opaqueTreeId]) + + useEffect(() => { + if (!isTrackableShareSession) { + shareSessionStartedAtRef.current = 0 + shareSessionTimeSentRef.current = false + return + } + + shareSessionStartedAtRef.current = Date.now() + shareSessionTimeSentRef.current = false + trackAppEvent('tree_viewed', { + role: journeyRole, + tree_id: opaqueTreeId, + }) + + const fiveMinutesTimeout = window.setTimeout(() => { + captureTreeTimeSpent() + }, 5 * 60 * 1000) + + const handleVisibilityChange = () => { + if (document.visibilityState === 'hidden') { + captureTreeTimeSpent() + } + } + const handlePageHide = () => { + captureTreeTimeSpent() + } + + document.addEventListener('visibilitychange', handleVisibilityChange) + window.addEventListener('pagehide', handlePageHide) + + return () => { + window.clearTimeout(fiveMinutesTimeout) + document.removeEventListener('visibilitychange', handleVisibilityChange) + window.removeEventListener('pagehide', handlePageHide) + captureTreeTimeSpent() + } + }, [captureTreeTimeSpent, isTrackableShareSession, journeyRole, opaqueTreeId]) + + const trackTreeInteraction = useCallback((mediaType) => { + if (!isTrackableShareSession) return + trackAppEvent('tree_interaction', { + role: journeyRole, + media_type: mediaType, + tree_id: opaqueTreeId, + }) + }, [isTrackableShareSession, journeyRole, opaqueTreeId]) + + // Identifiant technique du compte, jamais l'adresse email (engagement de la page Données et vie privée) + useEffect(() => { + if (auth.authenticated && auth.userAuth.user?.id) { + identifyUser(auth.userAuth.user.id) + return + } + + resetUser() + }, [auth.authenticated, auth.userAuth.user?.id]) + + useEffect(() => { + const treeId = tree.treeContext.treeId + if (!treeId || !accountEmail) { + setLinkedSelfPersonId('') + return + } + + try { + const saved = localStorage.getItem(getAccountSelfPersonStorageKey(accountEmail, treeId)) || '' + setLinkedSelfPersonId(saved) + } catch { + setLinkedSelfPersonId('') + } + }, [accountEmail, tree.treeContext.treeId]) + + useEffect(() => { + if (!linkedSelfPersonId || !tree.treeContext.treeId || !accountEmail) { + return + } + + const exists = persons.some((candidate) => String(candidate.id) === String(linkedSelfPersonId)) + if (!exists) { + setLinkedSelfPersonId('') + try { + localStorage.removeItem(getAccountSelfPersonStorageKey(accountEmail, tree.treeContext.treeId)) + } catch { + // Ignore storage failures + } + } + }, [accountEmail, linkedSelfPersonId, tree.graphRevision, tree.treeContext.treeId]) + + // Annotations canvas (dessin, stickers, texte) + const annot = useAnnotations({ + editModeActive: editMode.isActive, + updateDraft: editMode.updateDraft, + }) + + const handlePersonSelect = useCallback((personLike) => { + const personId = personLike && typeof personLike === 'object' ? personLike.id : personLike + if (personId === null || personId === undefined) { + tree.setSelectedPerson(null) + return + } + + const freshPerson = persons.find((candidate) => String(candidate.id) === String(personId)) || personLike || null + tree.setSelectedPerson(freshPerson) + trackTreeInteraction('text') + }, [tree.setSelectedPerson, trackTreeInteraction]) // eslint-disable-line react-hooks/exhaustive-deps + + const handleMediaSelectFresh = useCallback((media, personLike) => { + const personId = personLike && typeof personLike === 'object' ? personLike.id : personLike + const freshPerson = personId !== null && personId !== undefined + ? persons.find((candidate) => String(candidate.id) === String(personId)) || personLike || null + : personLike || null + + tree.handleMediaSelect(media, freshPerson) + trackTreeInteraction(toInteractionMediaType(media?.type)) + }, [tree.handleMediaSelect, trackTreeInteraction]) // eslint-disable-line react-hooks/exhaustive-deps + + // Ref that Galaxy fills with a pointer to its transformRef (for computing view center) + const galaxyTransformReadRef = useRef(null) + // Ref exposant l'API zoom de Galaxy au ZoomControl + const galaxyZoomApiRef = useRef(null) + + // Called by AnnotationToolbar when user picks a photo file for canvas annotation + const handleAnnotationPhotoFile = useCallback(async (file) => { + const treeId = tree.treeContext.treeId + const token = tree.treeContext.accessToken || auth.userAuth.token + if (!treeId || !token || !file) return + + try { + const dataBase64 = await fileToBase64(file) + const result = await uploadAnnotationPhoto( + treeId, + { + fileName: file.name || 'photo.jpg', + mimeType: file.type || 'image/jpeg', + sizeBytes: file.size, + dataBase64, + }, + token, + ) + + // Compute world-space view center so the photo appears in the middle of the screen + const t = galaxyTransformReadRef.current?.current || { x: 0, y: 0, scale: 1 } + const worldX = (window.innerWidth / 2 - t.x) / t.scale + const worldY = (window.innerHeight / 2 - t.y) / t.scale + + // Get natural dimensions from the file + const img = new Image() + img.onload = () => { + const apiBase = import.meta.env.VITE_API_BASE_URL || '/api' + const fullUrl = token + ? `${apiBase}${result.photoPath}?token=${encodeURIComponent(token)}` + : `${apiBase}${result.photoPath}` + annot.placePhoto(worldX, worldY, result.photoPath, fullUrl, img.naturalWidth, img.naturalHeight) + URL.revokeObjectURL(img.src) + } + img.onerror = () => URL.revokeObjectURL(img.src) + img.src = URL.createObjectURL(file) + } catch { + // silent — user can retry + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tree.treeContext.treeId, tree.treeContext.accessToken, auth.userAuth.token]) + + // Afficher le banner de restauration si un draft existe au mount + useEffect(() => { + if (editMode.hasDraft && !editMode.isActive) { + setShowDraftBanner(true) + } + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + // Toggle édition avec confirmation si draft en cours + const handleEditToggle = useCallback(() => { + if (editMode.isActive && editMode.hasDraft) { + setShowConfirmModal(true) + } else { + editMode.toggle() + } + }, [editMode.isActive, editMode.hasDraft, editMode.toggle]) // eslint-disable-line react-hooks/exhaustive-deps + + // Actions de la modal de confirmation + const handleConfirmContinue = useCallback(() => { + setShowConfirmModal(false) + }, []) + + const handleConfirmSaveAndQuit = useCallback(() => { + editMode.saveDraft() + editMode.deactivate() + setShowConfirmModal(false) + }, [editMode.saveDraft, editMode.deactivate]) // eslint-disable-line react-hooks/exhaustive-deps + + const handleConfirmDiscard = useCallback(() => { + editMode.clearDraft() + editMode.deactivate() + setShowConfirmModal(false) + }, [editMode.clearDraft, editMode.deactivate]) // eslint-disable-line react-hooks/exhaustive-deps + + // Actions du banner de restauration + const handleDraftRestore = useCallback(() => { + editMode.activate() + setShowDraftBanner(false) + }, [editMode.activate]) // eslint-disable-line react-hooks/exhaustive-deps + + const handleDraftDiscard = useCallback(() => { + editMode.clearDraft() + setShowDraftBanner(false) + }, [editMode.clearDraft]) // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + if (!relationFeedback.success) return undefined + + const timeout = setTimeout(() => { + setRelationFeedback((current) => ({ ...current, success: '' })) + }, 2500) + + return () => clearTimeout(timeout) + }, [relationFeedback.success]) + + useEffect(() => { + setAvatarActionState({ loading: false, error: '' }) + }, [tree.selectedPerson?.id]) + + // Panneau ajout personne + const [addPersonPanelVisible, setAddPersonPanelVisible] = useState(false) + const [addPersonLoading, setAddPersonLoading] = useState(false) + const [addPersonError, setAddPersonError] = useState('') + + const handleOpenAddPerson = useCallback(() => { + setAddPersonError('') + setAddPersonPanelVisible(true) + }, []) + + const handleCloseAddPerson = useCallback(() => { + setAddPersonPanelVisible(false) + setAddPersonError('') + }, []) + + const handleAddPersonSubmit = useCallback(async (formData) => { + if (!tree.treeContext.treeId && !isDemoMode) return + + setAddPersonLoading(true) + setAddPersonError('') + + try { + if (!tree.canEditCurrentTree) { + if (isDemoMode) { + const personId = makeLocalId('local-person') + const birthYear = extractYearFromIsoDate(formData.birthDate) + const deathYear = extractYearFromIsoDate(formData.deathDate) + const newPerson = { + id: personId, + firstName: formData.firstName, + lastName: formData.lastName || '', + birthName: formData.birthName || null, + birthDate: formData.birthDate || null, + deathDate: formData.deathDate || null, + birthYear, + deathYear, + isAlive: !deathYear, + photo: null, + frameType: 'round', + } + + persons.push(newPerson) + + for (const rel of formData.relations) { + if (rel.type === 'spouse') { + const existsUnion = unions.some((union) => ( + (String(union.partner1Id) === String(rel.personId) && String(union.partner2Id) === String(personId)) + || (String(union.partner1Id) === String(personId) && String(union.partner2Id) === String(rel.personId)) + )) + if (!existsUnion) { + unions.push({ + id: makeLocalId('local-union'), + partner1Id: rel.personId, + partner2Id: personId, + unionType: 'mariage', + startYear: null, + endYear: null, + displayOrder: unions.length + 1, + }) + } + } else if (rel.type === 'parent') { + filiations.push({ + id: makeLocalId('local-filiation'), + childId: personId, + parentId: rel.personId, + unionId: null, + parentageType: 'biologique', + displayOrder: filiations.length + 1, + }) + } else if (rel.type === 'child') { + filiations.push({ + id: makeLocalId('local-filiation'), + childId: rel.personId, + parentId: personId, + unionId: null, + parentageType: 'biologique', + displayOrder: filiations.length + 1, + }) + } + } + + tree.bumpGraphRevision() + tree.setSelectedPerson({ ...newPerson }) + } + + const draft = editMode.restoreDraft() + const addedPersons = [ + ...(draft.addedPersons || []), + { + firstName: formData.firstName, + lastName: formData.lastName, + birthName: formData.birthName || null, + birthDate: formData.birthDate || null, + deathDate: formData.deathDate || null, + }, + ] + editMode.updateDraft({ addedPersons }) + return + } + + if (!auth.userAuth.token) return + + const newPerson = await createPerson( + tree.treeContext.treeId, + { + firstName: formData.firstName, + lastName: formData.lastName, + birthName: formData.birthName, + birthDate: formData.birthDate, + deathDate: formData.deathDate, + }, + auth.userAuth.token + ) + + // Créer les liens de relation + for (const rel of formData.relations) { + if (rel.type === 'child') { + await createParentChildLink( + tree.treeContext.treeId, + { parentPersonId: rel.personId, childPersonId: newPerson.id, parentageType: 'biologique', displayOrder: 1 }, + auth.userAuth.token + ) + } else if (rel.type === 'parent') { + await createParentChildLink( + tree.treeContext.treeId, + { parentPersonId: newPerson.id, childPersonId: rel.personId, parentageType: 'biologique', displayOrder: 1 }, + auth.userAuth.token + ) + } else if (rel.type === 'spouse') { + await createUnion( + tree.treeContext.treeId, + { partner1PersonId: rel.personId, partner2PersonId: newPerson.id, unionType: 'mariage', displayOrder: 1 }, + auth.userAuth.token + ) + } + // TODO: sibling (nécessite de trouver les parents communs) + } + + // Mettre à jour le draft avec la nouvelle personne + editMode.updateDraft({ + addedPersons: [...(editMode.restoreDraft().addedPersons || []), newPerson.id], + }) + + await tree.refreshCurrentTreeGraph() + } catch (err) { + setAddPersonError(err.message || "Erreur lors de l'ajout de la personne") + } finally { + setAddPersonLoading(false) + } + }, [auth.userAuth.token, tree.canEditCurrentTree, tree.treeContext.treeId, isDemoMode, makeLocalId]) // eslint-disable-line react-hooks/exhaustive-deps + + // Modal session contribution (contributeurs) + const [showContribModal, setShowContribModal] = useState(false) + const [contribChanges, setContribChanges] = useState([]) + const [contribSubmitLoading, setContribSubmitLoading] = useState(false) + const [contribSubmitError, setContribSubmitError] = useState('') + const [contribSuccess, setContribSuccess] = useState(false) + + // Soumission des modifications + const handleEditSubmit = useCallback(async () => { + if (isDemoMode) { + // Démo: édition locale uniquement, jamais de persistance serveur. + editMode.saveDraft() + editMode.deactivate() + setContribSubmitError('') + setShowContribModal(false) + return + } + + if (userRole.isAdmin) { + const token = auth.userAuth.token + const treeId = tree.treeContext.treeId + const draft = editMode.restoreDraft() + + if (token && treeId) { + // Appliquer les modifications de personnes depuis le draft + const modifiedPersons = draft.modifiedPersons || {} + for (const [personId, rawChanges] of Object.entries(modifiedPersons)) { + const payload = {} + if (Object.prototype.hasOwnProperty.call(rawChanges, 'firstName')) payload.firstName = rawChanges.firstName || '' + if (Object.prototype.hasOwnProperty.call(rawChanges, 'lastName')) payload.lastName = rawChanges.lastName || '' + if (Object.prototype.hasOwnProperty.call(rawChanges, 'birthName')) payload.birthName = (rawChanges.birthName || '').trim() || null + if (Object.prototype.hasOwnProperty.call(rawChanges, 'birthYear')) payload.birthDate = yearToIsoDate(rawChanges.birthYear) + if (Object.prototype.hasOwnProperty.call(rawChanges, 'deathYear')) payload.deathDate = yearToIsoDate(rawChanges.deathYear) + if (Object.prototype.hasOwnProperty.call(rawChanges, 'birthPlace')) payload.birthPlace = (rawChanges.birthPlace || '').trim() || null + if (Object.prototype.hasOwnProperty.call(rawChanges, 'profession')) payload.profession = (rawChanges.profession || '').trim() || null + if (Object.prototype.hasOwnProperty.call(rawChanges, 'region')) payload.region = (rawChanges.region || '').trim() || null + if (Object.prototype.hasOwnProperty.call(rawChanges, 'nationality')) payload.nationality = (rawChanges.nationality || '').trim() || null + if (Object.prototype.hasOwnProperty.call(rawChanges, 'isAlive') && rawChanges.isAlive === true) payload.deathDate = null + if (Object.prototype.hasOwnProperty.call(rawChanges, 'note')) payload.notes = rawChanges.note || null + + if (Object.keys(payload).length > 0) { + try { + await updatePerson(treeId, personId, payload, token) + } catch (err) { + console.error('Failed to update person:', personId, err) + } + } + } + + // Appliquer les annotations + const addedAnnIds = new Set(draft.addedAnnotations || []) + const modifiedAnnIds = new Set(draft.modifiedAnnotations || []) + const deletedAnnIds = draft.deletedAnnotations || [] + const annData = draft.annotationsData || [] + + const batchPayload = { + create: annData + .filter((a) => addedAnnIds.has(a.id)) + .map((a) => ({ + type: a.type, + x: a.x, + y: a.y, + content: a.content, + style: a.style || {}, + zIndex: a.zIndex || 0, + })), + update: annData + .filter((a) => modifiedAnnIds.has(a.id)) + .map((a) => ({ + id: a.id, + x: a.x, + y: a.y, + content: a.content, + style: a.style || {}, + zIndex: a.zIndex || 0, + })), + delete: deletedAnnIds, + } + + const hasAnnotationChanges = batchPayload.create.length > 0 || batchPayload.update.length > 0 || batchPayload.delete.length > 0 + + if (hasAnnotationChanges) { + try { + await batchAnnotations(treeId, batchPayload, token) + } catch (err) { + console.error('Failed to submit annotations:', err) + } + } + } + + await tree.refreshCurrentTreeGraph({ relayout: false }) + editMode.clearDraft() + editMode.deactivate() + } else { + // Contributeur → ouvrir modal session nommée + setContribSubmitError('') + setContribChanges(buildContribChangesList(editMode.restoreDraft())) + setShowContribModal(true) + } + }, [userRole.isAdmin, auth.userAuth.token, tree.treeContext.treeId, isDemoMode]) // eslint-disable-line react-hooks/exhaustive-deps + + const handleContribSessionSubmit = useCallback(async ({ comment }) => { + setContribSubmitLoading(true) + setContribSubmitError('') + + try { + const draft = editMode.restoreDraft() + const changes = buildContributionChangesFromDraft(draft) + + if (changes.length === 0) { + setContribSubmitError('Aucune modification à soumettre.') + return + } + + const now = new Date() + const dateStr = now.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' }) + const userName = auth.userAuth.user?.name || 'Anonyme' + const title = `Contribution de ${userName} · ${dateStr}` + + const result = await contrib.handleSubmitContributionSession({ title, comment, changes }) + + if (!result?.ok) { + setContribSubmitError(result?.error || "Erreur lors de l'envoi des contributions") + return + } + + editMode.clearDraft() + editMode.deactivate() + setShowContribModal(false) + setContribSubmitError('') + setContribSuccess(true) + setTimeout(() => setContribSuccess(false), 3500) + } catch (err) { + setContribSubmitError(err.message || "Erreur lors de l'envoi des contributions") + } finally { + setContribSubmitLoading(false) + } + }, [contrib, editMode, auth.userAuth.user]) // eslint-disable-line react-hooks/exhaustive-deps + + const resolveContributionShareUrl = useCallback(async () => { + const slugFromPath = getSlugFromPathname() + if (slugFromPath) { + setContribInviteState((current) => ({ + ...current, + shareUrl: `${window.location.origin}/arbre/${encodeURIComponent(slugFromPath)}`, + })) + return + } + + if (!tree.treeContext.treeId) { + setContribInviteState((current) => ({ ...current, shareUrl: '' })) + return + } + + const accessToken = tree.treeContext.accessToken || auth.userAuth.token + if (!accessToken) { + setContribInviteState((current) => ({ ...current, shareUrl: '' })) + return + } + + try { + const result = await getTree(tree.treeContext.treeId, accessToken) + const slug = result?.tree?.slug || '' + setContribInviteState((current) => ({ + ...current, + shareUrl: slug ? `${window.location.origin}/arbre/${encodeURIComponent(slug)}` : '', + })) + } catch { + setContribInviteState((current) => ({ ...current, shareUrl: '' })) + } + }, [auth.userAuth.token, tree.treeContext.accessToken, tree.treeContext.treeId]) + + const handleOpenContributionPanel = useCallback(async () => { + const knownPasswords = readInvitePasswordsFromStorage(tree.treeContext.treeId) + await resolveContributionShareUrl() + setContribInviteState((current) => ({ + ...current, + knownPasswords, + error: '', + message: '', + })) + await contrib.handleOpenContributionPanel() + }, [contrib, resolveContributionShareUrl, tree.treeContext.treeId]) // eslint-disable-line react-hooks/exhaustive-deps + + const handleContributionPasswordRotate = useCallback(async ({ visitorPassword, contributorPassword }) => { + if (!tree.treeContext.treeId) { + return { ok: false, error: 'Arbre introuvable.' } + } + + const accessToken = tree.treeContext.accessToken || auth.userAuth.token + if (!accessToken) { + return { ok: false, error: 'Session invalide.' } + } + + const payload = {} + if (visitorPassword && visitorPassword.trim().length >= 8) { + payload.visitorPassword = visitorPassword.trim() + } + + if (contributorPassword && contributorPassword.trim().length >= 8) { + payload.contributorPassword = contributorPassword.trim() + } + + if (!payload.visitorPassword && !payload.contributorPassword) { + return { ok: false, error: 'Renseignez au moins un mot de passe valide.' } + } + + const sameVisitor = payload.visitorPassword && payload.visitorPassword === contribInviteState.knownPasswords.visitor + const sameContributor = payload.contributorPassword && payload.contributorPassword === contribInviteState.knownPasswords.contributor + const nothingChanged = (!payload.visitorPassword || sameVisitor) && (!payload.contributorPassword || sameContributor) + if (nothingChanged) { + setContribInviteState((current) => ({ + ...current, + loading: false, + error: '', + message: 'Aucun changement détecté sur les mots de passe.', + })) + return { ok: true } + } + + setContribInviteState((current) => ({ + ...current, + loading: true, + error: '', + message: '', + })) + + try { + await rotateTreePasswords(tree.treeContext.treeId, payload, accessToken) + const storedPasswords = { + visitor: payload.visitorPassword || contribInviteState.knownPasswords.visitor || '', + contributor: payload.contributorPassword || contribInviteState.knownPasswords.contributor || '', + } + writeInvitePasswordsToStorage(tree.treeContext.treeId, storedPasswords) + + setContribInviteState((current) => ({ + ...current, + knownPasswords: storedPasswords, + loading: false, + error: '', + message: 'Mots de passe mis à jour.', + })) + return { ok: true } + } catch (error) { + const message = getAccountErrorMessage(error) + setContribInviteState((current) => ({ + ...current, + loading: false, + error: message, + message: '', + })) + return { ok: false, error: message } + } + }, [auth.userAuth.token, contribInviteState.knownPasswords.contributor, contribInviteState.knownPasswords.visitor, tree.treeContext.accessToken, tree.treeContext.treeId]) + + // Sauvegarde modification personne (mode édition) + // Admin → appel API direct et immédiat. + // Contributeur/membre → mise dans le draft local, soumis ensuite pour approbation. + const handlePersonSave = useCallback(async (personId, changes) => { + if (!tree.canEditCurrentTree) { + // Mode contribution : accumuler dans le draft local + const draft = editMode.restoreDraft() + const modifiedPersons = draft.modifiedPersons || {} + modifiedPersons[personId] = { ...modifiedPersons[personId], ...changes } + editMode.updateDraft({ modifiedPersons }) + + const localPerson = persons.find((candidate) => String(candidate.id) === String(personId)) + if (localPerson) { + if (Object.prototype.hasOwnProperty.call(changes, 'firstName')) localPerson.firstName = changes.firstName || '' + if (Object.prototype.hasOwnProperty.call(changes, 'lastName')) localPerson.lastName = changes.lastName || '' + if (Object.prototype.hasOwnProperty.call(changes, 'birthName')) localPerson.birthName = changes.birthName || null + if (Object.prototype.hasOwnProperty.call(changes, 'birthYear')) { + const iso = yearToIsoDate(changes.birthYear) + localPerson.birthDate = iso + localPerson.birthYear = iso ? Number(iso.slice(0, 4)) : null + } + if (Object.prototype.hasOwnProperty.call(changes, 'deathYear')) { + const iso = yearToIsoDate(changes.deathYear) + localPerson.deathDate = iso + localPerson.deathYear = iso ? Number(iso.slice(0, 4)) : null + } + if (Object.prototype.hasOwnProperty.call(changes, 'birthPlace')) localPerson.birthPlace = changes.birthPlace || null + if (Object.prototype.hasOwnProperty.call(changes, 'profession')) localPerson.profession = changes.profession || null + if (Object.prototype.hasOwnProperty.call(changes, 'region')) localPerson.region = changes.region || null + if (Object.prototype.hasOwnProperty.call(changes, 'nationality')) localPerson.nationality = changes.nationality || null + if (Object.prototype.hasOwnProperty.call(changes, 'note')) localPerson.note = changes.note || null + if (Object.prototype.hasOwnProperty.call(changes, 'isAlive') && changes.isAlive === true) { + localPerson.deathYear = null + } + tree.setSelectedPerson({ ...localPerson }) + } + return + } + + // Admin : appliquer directement via l'API + const accessToken = tree.treeContext.accessToken || auth.userAuth.token + if (!accessToken || !tree.treeContext.treeId) return + + const payload = {} + if (Object.prototype.hasOwnProperty.call(changes, 'firstName')) payload.firstName = changes.firstName || '' + if (Object.prototype.hasOwnProperty.call(changes, 'lastName')) payload.lastName = changes.lastName || '' + if (Object.prototype.hasOwnProperty.call(changes, 'birthName')) payload.birthName = (changes.birthName || '').trim() || null + if (Object.prototype.hasOwnProperty.call(changes, 'birthYear')) payload.birthDate = yearToIsoDate(changes.birthYear) + if (Object.prototype.hasOwnProperty.call(changes, 'deathYear')) payload.deathDate = yearToIsoDate(changes.deathYear) + if (Object.prototype.hasOwnProperty.call(changes, 'birthPlace')) payload.birthPlace = (changes.birthPlace || '').trim() || null + if (Object.prototype.hasOwnProperty.call(changes, 'profession')) payload.profession = (changes.profession || '').trim() || null + if (Object.prototype.hasOwnProperty.call(changes, 'region')) payload.region = (changes.region || '').trim() || null + if (Object.prototype.hasOwnProperty.call(changes, 'nationality')) payload.nationality = (changes.nationality || '').trim() || null + if (Object.prototype.hasOwnProperty.call(changes, 'isAlive') && changes.isAlive === true) payload.deathDate = null + if (Object.prototype.hasOwnProperty.call(changes, 'note')) payload.notes = changes.note || null + + if (Object.keys(payload).length > 0) { + await updatePerson(tree.treeContext.treeId, personId, payload, accessToken) + } + + await refreshTreeAndKeepSelection(personId, { relayout: false, avatarCacheBust: Date.now() }) + }, [auth.userAuth.token, tree.canEditCurrentTree, tree.treeContext.accessToken, tree.treeContext.treeId, refreshTreeAndKeepSelection]) // eslint-disable-line react-hooks/exhaustive-deps + + const handlePersonAvatarUpload = useCallback(async (personId, file) => { + const accessToken = tree.treeContext.accessToken || auth.userAuth.token + if (!accessToken || !tree.treeContext.treeId) return + + const validationError = validateMediaFile(file, 'photo') + if (validationError) { + setAvatarActionState({ loading: false, error: validationError }) + return + } + + setAvatarActionState({ loading: true, error: '' }) + + try { + const dataBase64 = await fileToBase64(file) + await uploadPersonAvatar( + tree.treeContext.treeId, + personId, + { + fileName: file.name || 'avatar.jpg', + mimeType: getUploadMimeType(file, 'photo'), + sizeBytes: file.size, + dataBase64, + }, + accessToken, + ) + await refreshTreeAndKeepSelection(personId, { relayout: true }) + setAvatarActionState({ loading: false, error: '' }) + } catch (error) { + setAvatarActionState({ loading: false, error: getAccountErrorMessage(error) }) + } + }, [auth.userAuth.token, tree.treeContext.accessToken, tree.treeContext.treeId, refreshTreeAndKeepSelection]) + + const handlePersonAvatarDelete = useCallback(async (personId) => { + const accessToken = tree.treeContext.accessToken || auth.userAuth.token + if (!accessToken || !tree.treeContext.treeId) return + + setAvatarActionState({ loading: true, error: '' }) + + try { + await deletePersonAvatar(tree.treeContext.treeId, personId, accessToken) + await refreshTreeAndKeepSelection(personId, { relayout: true }) + setAvatarActionState({ loading: false, error: '' }) + } catch (error) { + setAvatarActionState({ loading: false, error: getAccountErrorMessage(error) }) + } + }, [auth.userAuth.token, tree.treeContext.accessToken, tree.treeContext.treeId, refreshTreeAndKeepSelection]) + + // Suppression personne (mode édition, admin) + const handlePersonDelete = useCallback(async (personId) => { + if (!tree.treeContext.treeId && !isDemoMode) return + + if (isDemoMode) { + for (let idx = persons.length - 1; idx >= 0; idx -= 1) { + if (String(persons[idx].id) === String(personId)) { + persons.splice(idx, 1) + } + } + + const removedUnionIds = new Set() + for (let idx = unions.length - 1; idx >= 0; idx -= 1) { + const union = unions[idx] + if ( + String(union.partner1Id) === String(personId) + || String(union.partner2Id) === String(personId) + ) { + removedUnionIds.add(union.id) + unions.splice(idx, 1) + } + } + + for (let idx = filiations.length - 1; idx >= 0; idx -= 1) { + const filiation = filiations[idx] + const touchesPerson = ( + String(filiation.childId) === String(personId) + || String(filiation.parentId) === String(personId) + ) + const touchesRemovedUnion = filiation.unionId && removedUnionIds.has(filiation.unionId) + if (touchesPerson || touchesRemovedUnion) { + filiations.splice(idx, 1) + } + } + + for (let idx = medias.length - 1; idx >= 0; idx -= 1) { + if (String(medias[idx].personId) === String(personId)) { + medias.splice(idx, 1) + } + } + + tree.setSelectedPerson(null) + tree.bumpGraphRevision() + return + } + + if (tree.canEditCurrentTree) { + const accessToken = tree.treeContext.accessToken || auth.userAuth.token + if (!accessToken) return + + await deletePerson(tree.treeContext.treeId, personId, accessToken) + await refreshTreeAndKeepSelection(null, { relayout: true }) + tree.setSelectedPerson(null) + return + } + + // Mettre a jour le draft + const draft = editMode.restoreDraft() + const deletedPersons = [...(draft.deletedPersons || []), personId] + editMode.updateDraft({ deletedPersons }) + + tree.setSelectedPerson(null) + }, [auth.userAuth.token, refreshTreeAndKeepSelection, tree.canEditCurrentTree, tree.treeContext.accessToken, tree.treeContext.treeId, isDemoMode]) // eslint-disable-line react-hooks/exhaustive-deps + + // Ajout d'une relation (mode édition) + const handleAddRelation = useCallback(async (sourcePersonId, targetPersonId, relationType) => { + if (isDemoMode) { + let relationAdded = false + + if (relationType === 'spouse') { + const existsUnion = unions.some((candidate) => ( + (String(candidate.partner1Id) === String(sourcePersonId) && String(candidate.partner2Id) === String(targetPersonId)) + || (String(candidate.partner1Id) === String(targetPersonId) && String(candidate.partner2Id) === String(sourcePersonId)) + )) + + if (!existsUnion) { + unions.push({ + id: makeLocalId('local-union'), + partner1Id: sourcePersonId, + partner2Id: targetPersonId, + unionType: 'mariage', + startYear: null, + endYear: null, + displayOrder: unions.length + 1, + }) + relationAdded = true + } + } else { + const childId = relationType === 'parent' ? sourcePersonId : targetPersonId + const parentId = relationType === 'parent' ? targetPersonId : sourcePersonId + const existsLink = filiations.some((candidate) => ( + String(candidate.childId) === String(childId) + && String(candidate.parentId) === String(parentId) + )) + + if (!existsLink) { + filiations.push({ + id: makeLocalId('local-filiation'), + childId, + parentId, + unionId: null, + parentageType: 'biologique', + displayOrder: filiations.length + 1, + }) + relationAdded = true + } + } + + if (!relationAdded) { + setRelationFeedback({ loading: false, success: '', error: 'Lien déjà présent' }) + return + } + + const draft = editMode.restoreDraft() + const addedRelations = [...(draft.addedRelations || []), { sourcePersonId, targetPersonId, relationType }] + editMode.updateDraft({ addedRelations }) + tree.bumpGraphRevision() + setRelationFeedback({ loading: false, success: 'Lien familial ajouté (démo locale)', error: '' }) + return + } + + if (!tree.canEditCurrentTree) { + const draft = editMode.restoreDraft() + const addedRelations = [...(draft.addedRelations || []), { sourcePersonId, targetPersonId, relationType }] + editMode.updateDraft({ addedRelations }) + setRelationFeedback({ loading: false, success: 'Lien ajouté à la contribution', error: '' }) + return + } + + const accessToken = tree.treeContext.accessToken || auth.userAuth.token + if (!accessToken || !tree.treeContext.treeId) return + + setRelationFeedback({ loading: true, success: '', error: '' }) + + try { + if (relationType === 'parent') { + // sourcePersonId est l'enfant, targetPersonId est le parent + await createParentChildLink( + tree.treeContext.treeId, + { parentPersonId: targetPersonId, childPersonId: sourcePersonId, parentageType: 'biologique', displayOrder: 1 }, + accessToken + ) + } else if (relationType === 'child') { + // sourcePersonId est le parent, targetPersonId est l'enfant + await createParentChildLink( + tree.treeContext.treeId, + { parentPersonId: sourcePersonId, childPersonId: targetPersonId, parentageType: 'biologique', displayOrder: 1 }, + accessToken + ) + } else if (relationType === 'spouse') { + await createUnion( + tree.treeContext.treeId, + { partner1PersonId: sourcePersonId, partner2PersonId: targetPersonId, unionType: 'mariage', displayOrder: 1 }, + accessToken + ) + } + + // Mettre à jour le draft + const draft = editMode.restoreDraft() + const addedRelations = [...(draft.addedRelations || []), { sourcePersonId, targetPersonId, relationType }] + editMode.updateDraft({ addedRelations }) + + await refreshTreeAndKeepSelection(sourcePersonId, { relayout: true }) + setRelationFeedback({ loading: false, success: 'Lien familial ajouté', error: '' }) + } catch (err) { + setRelationFeedback({ loading: false, success: '', error: getAccountErrorMessage(err) }) + console.error('Failed to add relation:', err) + } + }, [auth.userAuth.token, tree.canEditCurrentTree, tree.treeContext.accessToken, tree.treeContext.treeId, refreshTreeAndKeepSelection, isDemoMode, makeLocalId]) // eslint-disable-line react-hooks/exhaustive-deps + + // Suppression d'une relation (mode édition) + const handleRemoveRelation = useCallback(async (sourcePersonId, targetPersonId, relationType) => { + if (isDemoMode) { + let relationRemoved = false + + if (relationType === 'spouse') { + for (let idx = unions.length - 1; idx >= 0; idx -= 1) { + const union = unions[idx] + if ( + (String(union.partner1Id) === String(sourcePersonId) && String(union.partner2Id) === String(targetPersonId)) + || (String(union.partner1Id) === String(targetPersonId) && String(union.partner2Id) === String(sourcePersonId)) + ) { + const removedUnionId = union.id + unions.splice(idx, 1) + for (let fIdx = filiations.length - 1; fIdx >= 0; fIdx -= 1) { + if (filiations[fIdx].unionId && String(filiations[fIdx].unionId) === String(removedUnionId)) { + filiations.splice(fIdx, 1) + } + } + relationRemoved = true + break + } + } + } else { + const childId = relationType === 'parent' ? sourcePersonId : targetPersonId + const parentId = relationType === 'parent' ? targetPersonId : sourcePersonId + for (let idx = filiations.length - 1; idx >= 0; idx -= 1) { + const filiation = filiations[idx] + if ( + String(filiation.childId) === String(childId) + && String(filiation.parentId) === String(parentId) + ) { + filiations.splice(idx, 1) + relationRemoved = true + } + } + } + + if (!relationRemoved) { + setRelationFeedback({ loading: false, success: '', error: 'Relation introuvable pour suppression' }) + return + } + + const draft = editMode.restoreDraft() + const removedRelations = [...(draft.removedRelations || []), { sourcePersonId, targetPersonId, relationType }] + editMode.updateDraft({ removedRelations }) + tree.bumpGraphRevision() + setRelationFeedback({ loading: false, success: 'Lien familial supprimé (démo locale)', error: '' }) + return + } + + if (!tree.canEditCurrentTree) { + const draft = editMode.restoreDraft() + const removedRelations = [...(draft.removedRelations || []), { sourcePersonId, targetPersonId, relationType }] + editMode.updateDraft({ removedRelations }) + setRelationFeedback({ loading: false, success: 'Suppression de lien ajoutée à la contribution', error: '' }) + return + } + + const accessToken = tree.treeContext.accessToken || auth.userAuth.token + if (!accessToken || !tree.treeContext.treeId) return + + setRelationFeedback({ loading: true, success: '', error: '' }) + + try { + let relationRemoved = false + + if (relationType === 'spouse') { + const union = unions.find((candidate) => ( + (String(candidate.partner1Id) === String(sourcePersonId) && String(candidate.partner2Id) === String(targetPersonId)) || + (String(candidate.partner1Id) === String(targetPersonId) && String(candidate.partner2Id) === String(sourcePersonId)) + )) + + if (union) { + await deleteUnion(tree.treeContext.treeId, union.id, accessToken) + relationRemoved = true + } + } else { + const childId = relationType === 'parent' ? sourcePersonId : targetPersonId + const parentId = relationType === 'parent' ? targetPersonId : sourcePersonId + + let link = filiations.find((candidate) => ( + String(candidate.childId) === String(childId) && + String(candidate.parentId) === String(parentId) + )) + + if (!link) { + const relatedUnionIds = new Set( + unions + .filter((candidate) => ( + String(candidate.partner1Id) === String(parentId) || + String(candidate.partner2Id) === String(parentId) + )) + .map((candidate) => candidate.id), + ) + + link = filiations.find((candidate) => ( + String(candidate.childId) === String(childId) && + candidate.unionId && + relatedUnionIds.has(candidate.unionId) + )) + } + + if (link) { + await deleteParentChildLink(tree.treeContext.treeId, link.id, accessToken) + relationRemoved = true + } + } + + if (!relationRemoved) { + setRelationFeedback({ loading: false, success: '', error: 'Relation introuvable pour suppression' }) + return + } + + const draft = editMode.restoreDraft() + const removedRelations = [...(draft.removedRelations || []), { sourcePersonId, targetPersonId, relationType }] + editMode.updateDraft({ removedRelations }) + + await refreshTreeAndKeepSelection(sourcePersonId, { relayout: true }) + setRelationFeedback({ loading: false, success: 'Lien familial supprimé', error: '' }) + } catch (err) { + setRelationFeedback({ loading: false, success: '', error: getAccountErrorMessage(err) }) + console.error('Failed to remove relation:', err) + } + }, [auth.userAuth.token, tree.canEditCurrentTree, tree.treeContext.accessToken, tree.treeContext.treeId, refreshTreeAndKeepSelection, isDemoMode]) // eslint-disable-line react-hooks/exhaustive-deps + + // Raccourcis clavier : Ctrl+E (toggle), Ctrl+S (save), ESC (quit) + useEffect(() => { + function handleKeyDown(e) { + // Ignorer si focus dans un input/textarea + const tag = document.activeElement?.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return + + if (e.ctrlKey && e.key === 'e') { + e.preventDefault() + handleEditToggle() + } else if (e.ctrlKey && e.key === 's') { + if (editMode.isActive) { + e.preventDefault() + editMode.saveDraft() + } + } else if (e.key === 'Escape') { + if (annot.selectedAnnotationId) { + annot.selectAnnotation(null) + } else if (editMode.isActive) { + handleEditToggle() + } + } else if (e.key === 'Delete' || e.key === 'Backspace') { + if (editMode.isActive && annot.selectedAnnotationId) { + e.preventDefault() + annot.deleteSelectedAnnotation() + } + } + } + + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }) // re-bind chaque render pour capter isActive/hasDraft à jour + + useEffect(() => { + if (!tree.canEditCurrentTree) { + setAdminPanelVisible(false) + } + if (!canEditInCurrentMode) { + editMode.deactivate() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tree.canEditCurrentTree, canEditInCurrentMode]) + + const openAccountTree = useCallback(async (entry, token, options = {}) => { + const accessMode = entry?.accessMode || 'member' + const role = entry?.role || (accessMode === 'share' ? 'visitor' : 'member') + + await tree.openTreeGraph(entry.id, token, { + treeName: entry?.name || '', + treeDescription: entry?.description || '', + treeOwnerName: entry?.ownerName || '', + treeOwnerEmail: entry?.ownerEmail || '', + role, + accessMode, + promptWizard: Boolean(options.promptWizard), + }) + + auth.persistLastOpenedTree({ + treeId: entry.id, + accessMode, + }) + }, [auth.persistLastOpenedTree, tree.openTreeGraph]) + + // Bootstrap + useEffect(() => { + let active = true + + const bootstrap = async () => { + const query = new URLSearchParams(window.location.search) + + // Explicit demo modes (landing iframe / forced demo) + if (query.has('embed') || query.has('demo')) { + tree.handleUseDemo() + return + } + + const slug = getSlugFromPathname() + let resolvedTreeId = TREE_ID_FROM_ENV + let resolvedTreeName = '' + let resolvedTreeDescription = '' + let resolvedTreeOwnerName = '' + let resolvedTreeOwnerEmail = '' + + // Root app: + // - not connected => demo by default + // - connected => continue bootstrap to open the user's tree + if (!slug && !TREE_ID_FROM_ENV && !query.has('account')) { + const userToken = auth.restoreUserToken() + if (!userToken) { + tree.handleUseDemo() + return + } + } + + setTreeNotFound(false) + + if (slug) { + try { + const treeInfo = await resolveTreeBySlug(slug) + resolvedTreeId = treeInfo.id + resolvedTreeName = treeInfo.name || '' + resolvedTreeDescription = treeInfo.description || '' + resolvedTreeOwnerName = treeInfo.ownerName || '' + resolvedTreeOwnerEmail = treeInfo.ownerEmail || '' + } catch (error) { + if (!active) return + tree.setBootState('need-access') + tree.setTreeContext(buildEmptyTreeContext()) + const notFound = error?.status === 404 || error?.payload?.error === 'tree_not_found' + setTreeNotFound(notFound) + tree.setGateError(notFound ? "Cet arbre n'existe pas." : 'Impossible de résoudre ce lien de partage.') + return + } + } + + const userToken = auth.restoreUserToken() + if (userToken) { + try { + const { trees, lastOpenedTree } = await auth.loadAccountTrees(userToken) + if (!active) return + + auth.setUserAuth({ + token: userToken, + user: auth.restoreUserProfile?.() || null, + }) + + if (resolvedTreeId) { + const resolvedTree = trees.find((entry) => String(entry.id) === String(resolvedTreeId)) + if (resolvedTree) { + try { + await openAccountTree(resolvedTree, userToken, { promptWizard: true }) + return + } catch { + // Fallback vers ecran partage + } + } + + tree.setTreeContext(buildEmptyTreeContext({ + treeId: resolvedTreeId, + treeName: resolvedTreeName, + treeDescription: resolvedTreeDescription, + treeOwnerName: resolvedTreeOwnerName, + treeOwnerEmail: resolvedTreeOwnerEmail, + })) + tree.setGateError('') + tree.setBootState('need-access') + return + } + + const preferredTree = lastOpenedTree + ? trees.find((entry) => + String(entry.id) === String(lastOpenedTree.treeId) + && (!lastOpenedTree.accessMode || entry.accessMode === lastOpenedTree.accessMode)) + : null + + if (preferredTree) { + try { + await openAccountTree(preferredTree, userToken, { promptWizard: true }) + return + } catch { + // Fallback sur arbre suivant + } + } + + if (trees.length > 0) { + try { + await openAccountTree(trees[0], userToken, { promptWizard: true }) + return + } catch { + auth.setAccountError("Aucun arbre accessible n'a pu être chargé automatiquement.") + } + tree.setBootState('account') + return + } + + // Premier utilisateur : aucun arbre → galaxie démo + bouton onboarding + tree.handleUseDemo() + setAccountEntryMode('register') + setTreeWizardVisible(true) + return + } catch { + auth.clearStoredUserToken() + } + } + + if (resolvedTreeId) { + const shareToken = localStorage.getItem(getTreeTokenStorageKey(resolvedTreeId)) + if (shareToken) { + try { + await tree.openTreeGraph(resolvedTreeId, shareToken, { + treeName: resolvedTreeName, + treeDescription: resolvedTreeDescription, + treeOwnerName: resolvedTreeOwnerName, + treeOwnerEmail: resolvedTreeOwnerEmail, + // Rôle lu dans le jeton : un contributeur qui recharge la page reste contributeur + role: readTreeAccessRole(shareToken), + accessMode: 'share', + promptWizard: false, + }) + return + } catch { + localStorage.removeItem(getTreeTokenStorageKey(resolvedTreeId)) + } + } + } + + if (!active) return + + if (resolvedTreeId) { + tree.setTreeContext(buildEmptyTreeContext({ + treeId: resolvedTreeId, + treeName: resolvedTreeName, + treeDescription: resolvedTreeDescription, + treeOwnerName: resolvedTreeOwnerName, + treeOwnerEmail: resolvedTreeOwnerEmail, + })) + tree.setGateError('') + tree.setBootState('need-access') + return + } + + // ?account=register (CTA de la landing) : écran compte directement en création + if (query.get('account') === 'register') { + setAccountEntryMode('register') + trackAppEvent('account_screen_viewed', { mode: 'register', from_shared_tree: false, source: query.get('source') || 'direct' }) + } + tree.setBootState('account') + } + + bootstrap() + + return () => { + active = false + } + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + const handleUseDemo = () => { + setTreeNotFound(false) + tree.handleUseDemo() + auth.setAccountError('') + setAccountPanelVisible(false) + setTreeWelcomeVisible(false) + setTreeWizardVisible(false) + media.resetMediaManager() + setAdminPanelVisible(false) + contrib.resetContribState() + } + + // Fallback auto: si l'utilisateur est connecté mais n'a aucun arbre, + // on bascule automatiquement sur la démo (utile en production quand la création d'account laisse l'UI vide). + useEffect(() => { + if ( + tree.bootState === 'account' && + auth.authenticated && + !auth.accountLoading && + auth.accountTrees.length === 0 && + !pendingSharedTreeRef.current // retour sur un arbre partagé en cours : pas d'assistant "votre arbre" + ) { + handleUseDemo() + setAccountEntryMode('register') + setTreeWizardVisible(true) + } + }, [tree.bootState, auth.authenticated, auth.accountLoading, auth.accountTrees.length]) // eslint-disable-line react-hooks/exhaustive-deps + + const handleUseAccount = (mode = 'login') => { + const ctx = tree.treeContext + pendingSharedTreeRef.current = ctx.treeId && ctx.accessMode !== 'demo' && ctx.accessMode !== 'member' + ? { + treeId: ctx.treeId, + treeName: ctx.treeName, + treeDescription: ctx.treeDescription, + treeOwnerName: ctx.treeOwnerName, + treeOwnerEmail: ctx.treeOwnerEmail, + } + : null + setTreeNotFound(false) + tree.setGateError('') + auth.setAccountError('') + setAccountPanelVisible(false) + setAccountEntryMode(mode === 'register' ? 'register' : 'login') + trackAppEvent('account_screen_viewed', { mode: mode === 'register' ? 'register' : 'login', from_shared_tree: Boolean(pendingSharedTreeRef.current?.treeId) }) + media.resetMediaManager() + setAdminPanelVisible(false) + contrib.resetContribState() + tree.setBootState('account') + } + + const handleBackToAccess = () => { + setTreeNotFound(false) + auth.setAccountError('') + setAccountPanelVisible(false) + setAccountEntryMode('login') + tree.setGateError('') + tree.setWizardVisibility(false) + setTreeWizardVisible(false) + media.resetMediaManager() + setAdminPanelVisible(false) + contrib.resetContribState() + tree.setBootState('need-access') + } + + // Après connexion ou création de compte depuis un arbre partagé : rattacher l'accès au compte + // (jeton d'arbre déjà en poche) et rouvrir l'arbre ; sans jeton, retour à la grille d'accès de + // cet arbre, où le mot de passe fera le rattachement. Renvoie true si le retour a été géré. + const resumePendingSharedTree = async (token) => { + const pending = pendingSharedTreeRef.current + if (!pending?.treeId) { + return false + } + + // Le ref reste posé jusqu'à la fin : il bloque l'assistant "votre arbre" pendant le rattachement + try { + const shareToken = localStorage.getItem(getTreeTokenStorageKey(pending.treeId)) + if (shareToken) { + try { + await linkTreeAccess(pending.treeId, shareToken, token) + const { trees } = await auth.loadAccountTrees(token) + const entry = trees.find((candidate) => String(candidate.id) === String(pending.treeId)) + if (entry) { + await openAccountTree(entry, token, { promptWizard: false }) + return true + } + } catch { + // Jeton expiré ou refusé : on repasse par le mot de passe + } + } + + tree.setTreeContext(buildEmptyTreeContext(pending)) + tree.setGateError('') + tree.setBootState('need-access') + return true + } finally { + pendingSharedTreeRef.current = null + } + } + + const handleLogin = async (email, password) => { + const success = await auth.handleLogin(email, password) + if (!success) { + return + } + + const token = auth.restoreUserToken() + if (!token) { + tree.setBootState('account') + return + } + + if (await resumePendingSharedTree(token)) { + return + } + + const { trees, lastOpenedTree } = await auth.loadAccountTrees(token) + const preferredTree = lastOpenedTree + ? trees.find((entry) => + String(entry.id) === String(lastOpenedTree.treeId) + && (!lastOpenedTree.accessMode || entry.accessMode === lastOpenedTree.accessMode)) + : null + const target = preferredTree || trees[0] || null + if (!target) { + handleUseDemo() + setAccountEntryMode('register') + setTreeWizardVisible(true) + return + } + + try { + await openAccountTree(target, token, { promptWizard: true }) + } catch { + tree.setBootState('account') + } + } + + const handleRegister = async (firstName, email, password) => { + const success = await auth.handleRegister(firstName, email, password) + if (!success) { + return + } + trackAppEvent('account_registered', { from_shared_tree: Boolean(pendingSharedTreeRef.current?.treeId) }) + + const token = auth.restoreUserToken() + if (token && await resumePendingSharedTree(token)) { + return + } + + handleUseDemo() + setAccountEntryMode('register') + setTreeWizardVisible(true) + } + + const handleOpenTreeFromAccount = async (treeId) => { + if (!auth.userAuth.token) { + return + } + + auth.setAccountLoading(true) + auth.setAccountError('') + + try { + const treeInfo = auth.accountTrees.find((candidate) => String(candidate.id) === String(treeId)) + if (!treeInfo) { + return + } + await openAccountTree(treeInfo, auth.userAuth.token, { promptWizard: true }) + } catch (error) { + auth.setAccountError(getAccountErrorMessage(error)) + } finally { + auth.setAccountLoading(false) + } + } + + const handleOpenTreeWizard = () => { + auth.setAccountError('') + setTreeWelcomeVisible(false) + setTreeWizardVisible(true) + } + + const handleCreateTreeFromWizard = async (payload) => { + const token = auth.userAuth.token + if (!token) { + return 'Session invalide ou expirée. Reconnectez-vous.' + } + + setTreeWizardLoading(true) + auth.setAccountLoading(true) + auth.setAccountError('') + + try { + const normalizedSlug = normalizeSlug(payload.treeName) + const safeSlug = normalizedSlug.length >= 3 + ? normalizedSlug + : `famille-${Date.now().toString(36)}` + + const createdTree = await createTree( + { + name: payload.treeName, + slug: safeSlug, + description: null, + visitorPassword: generateSecurePassword(), + contributorPassword: generateSecurePassword(), + }, + token, + ) + trackAppEvent('tree_created', { tree_id: toOpaqueTreeId(createdTree.id) }) + + const createdSelf = await createPerson( + createdTree.id, + { + firstName: payload.selfFirstName, + lastName: payload.selfLastName, + birthDate: yearToIsoDate(payload.selfBirthYear), + }, + token, + ) + + if (createdSelf?.id) { + await updateTree( + createdTree.id, + { rootPersonId: createdSelf.id }, + token, + ) + + if (payload.photoFile) { + const validationError = validateMediaFile(payload.photoFile, 'photo') + if (!validationError) { + try { + const dataBase64 = await fileToBase64(payload.photoFile) + await uploadPersonAvatar( + createdTree.id, + createdSelf.id, + { + fileName: payload.photoFile.name || 'avatar.jpg', + mimeType: getUploadMimeType(payload.photoFile, 'photo'), + sizeBytes: payload.photoFile.size, + dataBase64, + }, + token, + ) + } catch { + // La photo est optionnelle: ignorer l'echec sans bloquer la creation. + } + } + } + } + + await auth.loadAccountTrees(token) + await tree.openTreeGraph(createdTree.id, token, { + treeName: createdTree.name || payload.treeName, + treeDescription: createdTree.description || '', + role: 'owner', + accessMode: 'member', + rootPersonId: createdSelf?.id || null, + promptWizard: false, + }) + await auth.persistLastOpenedTree({ + treeId: createdTree.id, + accessMode: 'member', + }) + + setTreeWizardVisible(false) + setTreeWelcomeVisible(true) + return null + } catch (error) { + const message = getAccountErrorMessage(error) + auth.setAccountError(message) + return message + } finally { + auth.setAccountLoading(false) + setTreeWizardLoading(false) + } + } + + const handleLogout = async () => { + setTreeNotFound(false) + await auth.handleLogout() + tree.setWizardState({ visible: false, loading: false, error: '' }) + setAccountPanelVisible(false) + setTreeWizardVisible(false) + setTreeWelcomeVisible(false) + setAccountEntryMode('login') + media.resetMediaManager() + setAdminPanelVisible(false) + contrib.resetContribState() + tree.setBootState('account') + } + + const handleGateUnlock = async (treeId, password) => { + const access = await tree.handleUnlock(treeId, password, { + userToken: auth.userAuth.token || '', + treeName: tree.treeContext.treeName, + treeDescription: tree.treeContext.treeDescription, + treeOwnerName: tree.treeContext.treeOwnerName, + treeOwnerEmail: tree.treeContext.treeOwnerEmail, + }) + + if (!access) { + return + } + + setTreeNotFound(false) + if (auth.userAuth.token) { + await auth.loadAccountTrees(auth.userAuth.token) + auth.persistLastOpenedTree({ + treeId, + accessMode: 'share', + }) + } + } + + const handleTreeDeleted = async (deletedTreeId) => { + if (!auth.userAuth.token) { + handleUseAccount('login') + return + } + + try { + const { trees, lastOpenedTree } = await auth.loadAccountTrees(auth.userAuth.token) + + const preferredTree = lastOpenedTree + ? trees.find((entry) => + String(entry.id) === String(lastOpenedTree.treeId) + && (!lastOpenedTree.accessMode || entry.accessMode === lastOpenedTree.accessMode)) + : null + + const nextTree = trees.find((entry) => String(entry.id) !== String(deletedTreeId)) || preferredTree || trees[0] || null + if (nextTree) { + await openAccountTree(nextTree, auth.userAuth.token, { promptWizard: true }) + return + } + + handleUseDemo() + setAccountEntryMode('register') + setTreeWizardVisible(true) + } catch { + tree.setBootState('account') + } + } + + const handleGedcomImported = useCallback(async () => { + await refreshTreeAndKeepSelection(null, { relayout: true }) + }, [refreshTreeAndKeepSelection]) + + const handleSelectSelfPerson = (personId) => { + const treeId = tree.treeContext.treeId + if (!treeId || !accountEmail) { + return + } + + const nextValue = personId ? String(personId) : '' + setLinkedSelfPersonId(nextValue) + + try { + const storageKey = getAccountSelfPersonStorageKey(accountEmail, treeId) + if (nextValue) { + localStorage.setItem(storageKey, nextValue) + } else { + localStorage.removeItem(storageKey) + } + } catch { + // Ignore storage failures + } + } + + + if (tree.bootState === 'loading') { + return ( +
+
Chargement de l'arbre généalogique...
+
+ ) + } + + if (tree.bootState === 'need-access') { + return ( +
+ +
+ ) + } + + if (tree.bootState === 'account') { + return ( +
+ +
+ ) + } + + const isEmptyTree = persons.length === 0 + && !!tree.treeContext.treeId + && tree.canEditCurrentTree + && tree.treeContext.accessMode !== 'demo' + + return ( +
+ {/* Bannière de bienvenue (premier utilisateur, mode démo) */} + {treeWelcomeVisible && ( +
+ Votre arbre est cree. + Ajoutez un proche pour commencer - votre mere, votre pere, un frere. Il suffit d un prenom. + +
+ )} + {/* Bouton premier ajout (arbre vide) */} + {isEmptyTree && ( + + )} + {/* Recherche Expandable */} + { + const fullPerson = persons.find(p => p.id === person.id) + if (fullPerson) tree.setSelectedPerson(fullPerson) + }} + onResultHover={() => { + // TODO: Highlight dans galaxie + }} + onResultHoverEnd={() => { + // TODO: Clear highlight galaxie + }} + /> + {/* Draft Restore Banner (au reload si draft trouvé) */} + + {/* Edit Mode Overlay */} + + {/* Confirmation Modal (quitter mode édition avec draft) */} + + {/* Slide-in Ajouter Personne */} + + {/* Modal Session Contribution (contributeurs) */} + { + const d = editMode.restoreDraft() + return { + added: (d.addedPersons || []).length + (d.addedAnnotations || []).length, + modified: Object.keys(d.modifiedPersons || {}).length + (d.modifiedAnnotations || []).length, + deleted: (d.deletedPersons || []).length + (d.deletedAnnotations || []).length, + } + })()} + loading={contribSubmitLoading} + error={contribSubmitError} + onSubmit={handleContribSessionSubmit} + onCancel={() => setShowContribModal(false)} + /> + {/* Toast succès contribution */} + {contribSuccess && ( +
+ Contribution envoyée — merci ! +
+ )} + {/* Navbar contextuelle */} + setAccountPanelVisible(true)} + onSettingsClick={() => setAdminPanelVisible(true)} + onLogout={handleLogout} + userDisplayName={accountDisplayName} + userEmail={accountEmail} + userAvatarPhoto={accountAvatarPhoto} + linkedPersonName={linkedSelfPerson ? `${linkedSelfPerson.firstName || ''} ${linkedSelfPerson.lastName || ''}`.trim() : ''} + activeTreeName={activeAccountTreeName} + annotationTools={editMode.isActive ? { + activeTool: annot.activeTool, + onToolChange: annot.setActiveTool, + currentStyle: annot.currentStyle, + onStyleChange: annot.setCurrentStyle, + selectedAnnotationId: annot.selectedAnnotationId, + selectedSticker: annot.selectedSticker, + onStickerSelect: annot.setSelectedSticker, + onDelete: annot.deleteSelectedAnnotation, + isDragging: annot.isDragging, + isOverTrash: annot.isOverTrash, + onPhotoFileSelected: handleAnnotationPhotoFile, + } : null} + /> + + + {tree.selectedPerson && ( + tree.setSelectedPerson(null)} + onMediaSelect={handleMediaSelectFresh} + editMode={editMode.isActive} + canManageMedia={editMode.isActive && tree.canSubmitContribution} + onManageMedia={media.handleOpenMediaManager} + onAvatarUpload={handlePersonAvatarUpload} + onAvatarDelete={handlePersonAvatarDelete} + avatarActionLoading={avatarActionState.loading} + avatarActionError={avatarActionState.error} + isAdmin={userRole.isAdmin} + onSave={handlePersonSave} + onDelete={handlePersonDelete} + onAddRelation={handleAddRelation} + onRemoveRelation={handleRemoveRelation} + relationActionLoading={relationFeedback.loading} + relationActionSuccess={relationFeedback.success} + relationActionError={relationFeedback.error} + /> + )} + {tree.selectedMedia && ( + tree.setSelectedMedia(null)} + /> + )} + + + setAccountPanelVisible(false)} + /> + setAdminPanelVisible(false)} + /> + {/* Annotation Toolbar (visible uniquement en mode édition) */} + {editMode.isActive && ( + + )} + {/* Text Input Overlay (saisie texte sur canvas) */} + + {/* Onboarding premier arbre */} + setTreeWizardVisible(false)} + onSubmit={handleCreateTreeFromWizard} + /> +
+ ) +} + +export default App + + + + diff --git a/frontend/src/api/authApi.js b/frontend/src/api/authApi.js new file mode 100644 index 0000000..a50a0dd --- /dev/null +++ b/frontend/src/api/authApi.js @@ -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 +} diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js new file mode 100644 index 0000000..090df6a --- /dev/null +++ b/frontend/src/api/client.js @@ -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 } diff --git a/frontend/src/api/treeApi.js b/frontend/src/api/treeApi.js new file mode 100644 index 0000000..22e04a1 --- /dev/null +++ b/frontend/src/api/treeApi.js @@ -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 +} diff --git a/frontend/src/assets/Parenthese logo.svg b/frontend/src/assets/Parenthese logo.svg new file mode 100644 index 0000000..9cb9f07 --- /dev/null +++ b/frontend/src/assets/Parenthese logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/AccessGate.jsx b/frontend/src/components/AccessGate.jsx new file mode 100644 index 0000000..9a909ce --- /dev/null +++ b/frontend/src/components/AccessGate.jsx @@ -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 ( +
+