mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-06 23:32:08 +00:00
Suppression des données par les utilisateurs, CI et auto-hébergement vérifié
Utilisateurs - Suppression de compte depuis l'écran « Votre compte », confirmée par mot de passe : arbres possédés et médias effacés, email retiré des contributions, sessions fermées partout. - Suppression d'arbre définitive, en base et sur le disque. - Ajout d'un frère ou d'une sœur relié aux parents existants ; relations parent et enfant corrigées en mode démo. - Liste d'attente retirée (route, envoi d'emails, dépendance Resend). Projet ouvert - CI : front, landing, backend avec vérification des migrations, test Docker de bout en bout. - Déploiement lancé seulement après une CI réussie sur main. - Image API : CLI Prisma installée à part pour que les migrations tournent au démarrage. - SECURITY.md, CONTRIBUTING.md, modèles d'issue et de pull request. - Migration renommée avec un nom descriptif, contenu identique. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
e8e2485dcd
commit
0c4cc20c97
48 files changed
+1628
-651
No files matched your search
@@ -30,12 +30,3 @@ 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=
|
||||
@@ -1,2 +1,4 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
# Scripts lancés sous Linux (serveur, conteneurs, CI) : fins de ligne LF même sur un clone Windows
|
||||
*.sh text eol=lf
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
name: Bug
|
||||
about: Quelque chose ne fonctionne pas comme prévu
|
||||
labels: bug
|
||||
---
|
||||
|
||||
<!-- L'issue est publique : ne mettez ni noms, ni dates, ni captures d'une galaxie familiale réelle. -->
|
||||
|
||||
## Ce qui se passe
|
||||
|
||||
|
||||
|
||||
## Ce qui devrait se passer
|
||||
|
||||
|
||||
|
||||
## Pour reproduire
|
||||
|
||||
1.
|
||||
2.
|
||||
|
||||
## Contexte
|
||||
|
||||
- Où : parenthese.io, ou instance auto-hébergée (commit ou date de mise à jour)
|
||||
- Navigateur et appareil :
|
||||
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Signaler une faille de sécurité
|
||||
url: https://github.com/IliesAllali/parenthese/security/policy
|
||||
about: Jamais dans une issue publique. Signalement privé, voir SECURITY.md.
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
name: Idée
|
||||
about: Proposer une amélioration ou une nouvelle fonctionnalité
|
||||
labels: enhancement
|
||||
---
|
||||
|
||||
## Le besoin
|
||||
|
||||
<!-- Ce que vous cherchez à faire, et ce qui bloque aujourd'hui. -->
|
||||
|
||||
|
||||
|
||||
## Une piste
|
||||
|
||||
<!-- Facultatif : comment vous l'imagineriez. -->
|
||||
@@ -0,0 +1,14 @@
|
||||
## Ce que change cette PR
|
||||
|
||||
|
||||
|
||||
## Pourquoi
|
||||
|
||||
<!-- Lien vers l'issue si elle existe, par exemple : Closes #12 -->
|
||||
|
||||
## Vérifications
|
||||
|
||||
- [ ] Tests, lint et builds passent en local (voir CONTRIBUTING.md)
|
||||
- [ ] Les textes visibles sont en français
|
||||
- [ ] Une migration Prisma accompagne tout changement de schéma
|
||||
- [ ] Capture d'écran jointe si l'interface change
|
||||
@@ -0,0 +1,181 @@
|
||||
name: CI
|
||||
|
||||
# Tests, lint et builds des trois projets, plus une installation Docker complète testée comme un
|
||||
# auto-hébergeur. Le déploiement de production (deploy-hostinger.yml) attend la réussite de ce workflow.
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Un nouveau push sur la même branche annule la vérification devenue obsolète.
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
frontend:
|
||||
name: Frontend
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Dépendances
|
||||
run: npm ci --no-audit --no-fund
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Tests
|
||||
run: npm test
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
landing:
|
||||
name: Landing
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
defaults:
|
||||
run:
|
||||
working-directory: landing
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: landing/package-lock.json
|
||||
|
||||
- name: Dépendances
|
||||
run: npm ci --no-audit --no-fund
|
||||
|
||||
- name: Tests
|
||||
run: npm test
|
||||
|
||||
# Le build lit les polices dans ../frontend/src/assets, présentes dans le dépôt.
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
backend:
|
||||
name: Backend
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
defaults:
|
||||
run:
|
||||
working-directory: backend
|
||||
|
||||
# Les tests vitest remplacent Prisma par un mock et fixent leurs propres variables d'environnement :
|
||||
# ils n'utilisent pas la base. Elle sert à vérifier que les migrations s'appliquent sur une base
|
||||
# vide (ce que fait le premier démarrage Docker) et qu'elles reproduisent exactement schema.prisma.
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: genealogy
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U postgres -d genealogy"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 12
|
||||
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/genealogy
|
||||
CHECKPOINT_DISABLE: '1'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: backend/package-lock.json
|
||||
|
||||
- name: Dépendances
|
||||
run: npm ci --no-audit --no-fund
|
||||
|
||||
- name: Client Prisma
|
||||
run: npm run prisma:generate
|
||||
|
||||
- name: Build TypeScript
|
||||
run: npm run build
|
||||
|
||||
- name: Migrations sur une base vide
|
||||
run: npx prisma migrate deploy
|
||||
|
||||
- name: Schéma Prisma aligné sur les migrations
|
||||
run: npx prisma migrate diff --from-url "$DATABASE_URL" --to-schema-datamodel prisma/schema.prisma --exit-code
|
||||
|
||||
- name: Tests
|
||||
run: npm test
|
||||
|
||||
docker:
|
||||
name: Docker (auto-hébergement)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Fichier .env de test
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cp .env.example .env
|
||||
set_env() {
|
||||
if grep -q "^$1=" .env; then
|
||||
sed -i "s|^$1=.*|$1=$2|" .env
|
||||
else
|
||||
printf '%s=%s\n' "$1" "$2" >> .env
|
||||
fi
|
||||
}
|
||||
# Valeurs hexadécimales : sans caractère spécial, sûres dans l'URL de connexion PostgreSQL.
|
||||
set_env POSTGRES_PASSWORD "$(openssl rand -hex 24)"
|
||||
set_env JWT_SECRET "$(openssl rand -hex 48)"
|
||||
set_env WEB_PORT 8080
|
||||
set_env CORS_ORIGIN http://localhost:8080
|
||||
grep -Ev '^(POSTGRES_PASSWORD|JWT_SECRET)=' .env | grep -Ev '^\s*(#|$)'
|
||||
|
||||
- name: Configuration compose valide
|
||||
run: docker compose config --quiet
|
||||
|
||||
- name: Construction et démarrage
|
||||
run: docker compose up -d --build --wait --wait-timeout 420
|
||||
|
||||
- name: Test de fumée par le port web
|
||||
run: bash tools/docker-smoke.sh http://localhost:8080
|
||||
|
||||
- name: État et journaux en cas d'échec
|
||||
if: failure()
|
||||
run: |
|
||||
docker compose ps -a
|
||||
docker compose logs --no-color --timestamps
|
||||
|
||||
- name: Arrêt et suppression des volumes
|
||||
if: always()
|
||||
run: docker compose down -v --remove-orphans
|
||||
@@ -1,22 +1,18 @@
|
||||
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.
|
||||
# Déploie Parenthèse sur le VPS quand le workflow « CI » a réussi sur un push sur main, ou à la main.
|
||||
# Le travail est fait sur le serveur par deploy/deploy.sh, incrémental et verrouillé : seules les parties
|
||||
# modifiées sont reconstruites, et un run sans changement ne reconstruit rien.
|
||||
# Le serveur déploie origin/main au moment du run (fetch + reset) : si deux pushs se suivent de près,
|
||||
# c'est le plus récent qui part, sa propre CI déclenchera ensuite un run sans changement.
|
||||
on:
|
||||
push:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- CI
|
||||
types:
|
||||
- completed
|
||||
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:
|
||||
@@ -35,6 +31,18 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
# Uniquement sur le dépôt d'origine (jamais sur un fork), et après une CI réussie sur un vrai push sur main.
|
||||
if: >-
|
||||
github.repository == 'IliesAllali/parenthese' &&
|
||||
(
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
github.event.workflow_run.head_branch == 'main' &&
|
||||
github.event.workflow_run.head_repository.full_name == 'IliesAllali/parenthese'
|
||||
)
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Contribuer à Parenthèse
|
||||
|
||||
Parenthèse est maintenu par une seule personne. Les retours sont les bienvenus, et quelques repères font gagner du temps à tout le monde.
|
||||
|
||||
## Avant d'écrire du code
|
||||
|
||||
- Un bug ou une idée : ouvrez une issue avec le modèle correspondant.
|
||||
- Une faille de sécurité : pas d'issue publique, suivez [SECURITY.md](SECURITY.md).
|
||||
- Pour tout changement plus large qu'une correction, parlez-en d'abord dans une issue. Une pull request non discutée peut être refusée, même soignée.
|
||||
- Préférez de petites pull requests ciblées : un sujet par PR, un diff qui se relit en quelques minutes.
|
||||
|
||||
## Lancer le projet en local
|
||||
|
||||
Tout est dans la section « Développer » du [README](README.md).
|
||||
|
||||
## Tests
|
||||
|
||||
Avant d'ouvrir une pull request, faites passer ce que vérifie la CI :
|
||||
|
||||
```bash
|
||||
npm --prefix frontend ci && npm --prefix frontend run lint && npm --prefix frontend test && npm --prefix frontend run build
|
||||
npm --prefix landing ci && npm --prefix landing test && npm --prefix landing run build
|
||||
npm --prefix backend ci && npm --prefix backend run prisma:generate && npm --prefix backend run build && npm --prefix backend test
|
||||
```
|
||||
|
||||
La CI vérifie aussi que les migrations Prisma s'appliquent sur une base vide et reproduisent exactement `backend/prisma/schema.prisma`. Toute modification du schéma s'accompagne donc d'une migration (`npm --prefix backend run prisma:migrate`).
|
||||
|
||||
Si vous touchez à Docker ou à nginx, lancez `docker compose up -d --build` puis `bash tools/docker-smoke.sh http://localhost`.
|
||||
|
||||
## Conventions
|
||||
|
||||
- L'interface est en français : libellés, messages, textes d'aide.
|
||||
- Pas de nouvelle dépendance lourde (framework, bibliothèque d'interface, service externe) sans en avoir discuté dans une issue.
|
||||
- La promesse du README tient : pas de publicité, et aucune mesure d'audience activée par défaut sur une instance auto-hébergée.
|
||||
|
||||
## Licence
|
||||
|
||||
Parenthèse est publié sous [PolyForm Noncommercial 1.0.0](LICENSE). En proposant une contribution, vous acceptez qu'elle soit publiée sous cette même licence.
|
||||
@@ -1,5 +1,7 @@
|
||||
# Parenthèse
|
||||
|
||||
[](https://github.com/IliesAllali/parenthese/actions/workflows/ci.yml)
|
||||
|
||||
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)
|
||||
@@ -57,6 +59,8 @@ docker compose ps # état des conteneurs
|
||||
docker compose down # arrêt, les données sont conservées
|
||||
```
|
||||
|
||||
Pour vérifier qu'une installation fonctionne de bout en bout, lancez `bash tools/docker-smoke.sh http://localhost` (avec le port si `WEB_PORT` n'est pas 80). Le script a besoin de `curl` et `jq`. Il passe par le port web comme un navigateur : page d'accueil, santé de l'API, création d'un compte, connexion, création puis suppression d'une galaxie de test. Le compte de test (`smoke-...@example.com`) reste en base.
|
||||
|
||||
### Où sont les données
|
||||
|
||||
Deux volumes Docker, conservés entre les redémarrages et les mises à jour :
|
||||
@@ -99,6 +103,16 @@ 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`.
|
||||
|
||||
Sur n'importe quel système avec Docker, une base jetable remplace `db:bootstrap` :
|
||||
|
||||
```bash
|
||||
docker run -d --name parenthese-dev-db -p 5432:5432 -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=genealogy postgres:16
|
||||
npm --prefix backend run prisma:deploy
|
||||
npm --prefix backend run dev
|
||||
```
|
||||
|
||||
Avant `prisma:deploy`, `backend/.env` doit exister (copie de `backend/.env.example`) avec `DATABASE_URL="postgresql://postgres:postgres@localhost:5432/genealogy"`, qui correspond à ces identifiants. `docker stop parenthese-dev-db` arrête la base, `docker start parenthese-dev-db` la relance avec ses données.
|
||||
|
||||
L'API écoute sur [http://localhost:4000](http://localhost:4000) et répond `{ "ok": true }` sur `/health`.
|
||||
|
||||
### Frontend
|
||||
@@ -127,6 +141,10 @@ npm --prefix frontend test
|
||||
npm --prefix backend test
|
||||
```
|
||||
|
||||
## Contribuer
|
||||
|
||||
Issues et petites pull requests ciblées sont les bienvenues : la marche à suivre, les tests à faire passer et les conventions sont dans [CONTRIBUTING.md](CONTRIBUTING.md). Pour une faille de sécurité, pas d'issue publique : voir [SECURITY.md](SECURITY.md).
|
||||
|
||||
## Licence
|
||||
|
||||
Le code est publié sous [PolyForm Noncommercial 1.0.0](LICENSE). En clair :
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
# Sécurité
|
||||
|
||||
## Signaler une faille
|
||||
|
||||
Ne décrivez jamais une faille dans une issue, une discussion ou une pull request : tout y est public.
|
||||
|
||||
Deux canaux privés :
|
||||
|
||||
- l'onglet **Security** de ce dépôt GitHub, bouton « Report a vulnerability » ;
|
||||
- un email à pro.allali.ilies@gmail.com.
|
||||
|
||||
Indiquez si possible ce qui est touché (adresse, route de l'API, fichier), les étapes pour reproduire et l'impact que vous constatez. Un rapport court et précis suffit.
|
||||
|
||||
## Périmètre
|
||||
|
||||
- L'instance publique [parenthese.io](https://parenthese.io) : le site, l'application et son API.
|
||||
- Le code de ce dépôt, y compris la configuration d'auto-hébergement (`docker-compose.yml`, images Docker, configuration nginx).
|
||||
|
||||
Une instance auto-hébergée par quelqu'un d'autre relève de son administrateur. Si la faille vient du code, elle est dans le périmètre.
|
||||
|
||||
Sur parenthese.io, testez uniquement avec votre propre compte et vos propres galaxies. N'accédez pas aux données d'autres familles, et pas de déni de service ni de tests automatisés massifs.
|
||||
|
||||
## Délai de réponse
|
||||
|
||||
Parenthèse est maintenu par une seule personne. Vous recevrez un accusé de réception dès que possible, en général sous une semaine. Les failles graves passent avant tout le reste. Une fois le correctif publié, la faille peut être décrite publiquement, en laissant aux auto-hébergeurs le temps de mettre à jour.
|
||||
|
||||
Seule la dernière version de la branche `main` reçoit des correctifs.
|
||||
@@ -6,11 +6,3 @@ 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=""
|
||||
@@ -1,16 +1,21 @@
|
||||
# Parenthèse, API en production sans Docker (PM2 + nginx, voir deploy/).
|
||||
# Copiez ce fichier en backend/.env sur le serveur et remplacez chaque valeur d'exemple.
|
||||
# Pour une installation Docker, utilisez plutôt le .env.example à la racine du dépôt.
|
||||
|
||||
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"
|
||||
|
||||
# Base PostgreSQL dédiée. L'API refuse de démarrer en production avec l'URL de développement.
|
||||
DATABASE_URL="postgresql://UTILISATEUR:MOT_DE_PASSE@localhost:5432/parenthese"
|
||||
|
||||
# 16 caractères minimum. Générer avec : openssl rand -base64 48
|
||||
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"
|
||||
CORS_ORIGIN="https://app.parenthese.io,https://parenthese.io,https://www.parenthese.io"
|
||||
MEDIA_STORAGE_PATH="/var/www/genealogy/backend/storage/media"
|
||||
|
||||
# Resend waitlist
|
||||
RESEND_API_KEY="re_xxxxxxxxxxxxxxxxx"
|
||||
RESEND_WAITLIST_SEGMENT_ID="2e8b6bc6-04fa-4bf8-8218-9a73a0db7071"
|
||||
RESEND_WAITLIST_AUDIENCE_ID=""
|
||||
RESEND_WAITLIST_FROM="Parenthese <waitlist@parenthese.io>"
|
||||
RESEND_WAITLIST_REPLY_TO="hello@parenthese.io"
|
||||
RESEND_WAITLIST_UNSUBSCRIBE_URL="https://parenthese.io/waitlist/unsubscribe"
|
||||
# Adresses autorisées à appeler l'API, sans slash final, séparées par des virgules.
|
||||
CORS_ORIGIN="https://arbre.example.org"
|
||||
|
||||
# Dossier des médias envoyés. Un chemin relatif part du dossier backend/.
|
||||
MEDIA_STORAGE_PATH="/var/lib/parenthese/media"
|
||||
+20
-11
@@ -1,8 +1,8 @@
|
||||
# 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).
|
||||
# Étape 2 : image finale avec les seules dépendances de production, plus la CLI Prisma
|
||||
# installée à part (nécessaire à `prisma migrate deploy` au démarrage).
|
||||
|
||||
FROM node:24-bookworm-slim AS build
|
||||
|
||||
@@ -15,7 +15,7 @@ WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
COPY prisma ./prisma
|
||||
RUN npm ci
|
||||
RUN npm ci --no-audit --no-fund
|
||||
RUN npx prisma generate
|
||||
|
||||
COPY tsconfig.json ./
|
||||
@@ -31,11 +31,19 @@ RUN apt-get update \
|
||||
|
||||
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).
|
||||
# Dépendances de production seules.
|
||||
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")" \
|
||||
RUN npm ci --omit=dev --no-audit --no-fund \
|
||||
&& npm cache clean --force
|
||||
|
||||
# CLI Prisma : c'est une devDependency, donc absente de l'installation précédente. Elle est installée
|
||||
# dans un dossier séparé, à la version exacte du package-lock, pour ne pas toucher à node_modules
|
||||
# (un `npm install --omit=dev prisma` dans /app l'ignorerait, puisqu'elle est déclarée en dev).
|
||||
RUN PRISMA_VERSION="$(node -p "require('./package-lock.json').packages['node_modules/prisma'].version")" \
|
||||
&& mkdir -p /opt/prisma \
|
||||
&& cd /opt/prisma \
|
||||
&& echo '{"private":true}' > package.json \
|
||||
&& npm install --no-audit --no-fund "prisma@${PRISMA_VERSION}" \
|
||||
&& npm cache clean --force
|
||||
|
||||
# Client Prisma généré à l'étape 1 (même image de base, même cible de moteur).
|
||||
@@ -48,11 +56,12 @@ RUN mkdir -p /app/storage/media && chown -R node:node /app/storage
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
PORT=4000 \
|
||||
MEDIA_STORAGE_PATH=/app/storage/media
|
||||
MEDIA_STORAGE_PATH=/app/storage/media \
|
||||
CHECKPOINT_DISABLE=1 \
|
||||
PATH=/opt/prisma/node_modules/.bin:$PATH
|
||||
|
||||
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"]
|
||||
# Applique les migrations en attente puis démarre l'API.
|
||||
CMD ["sh", "-c", "prisma migrate deploy && exec node dist/server.js"]
|
||||
@@ -605,6 +605,57 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
|
||||
/auth/me:
|
||||
delete:
|
||||
tags: [Auth]
|
||||
summary: Permanently delete the current account
|
||||
description: >
|
||||
Requires the current password. Permanently deletes every tree owned by the user
|
||||
(including trees already marked as deleted) with their media files, then the account.
|
||||
Trees where the user is only a member or has shared access are kept.
|
||||
The token used for the request is revoked.
|
||||
security:
|
||||
- BearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [password]
|
||||
properties:
|
||||
password:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 128
|
||||
responses:
|
||||
'200':
|
||||
description: Account deleted
|
||||
'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: Invalid password
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'429':
|
||||
description: Too many requests
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
|
||||
/trees:
|
||||
get:
|
||||
tags: [Trees]
|
||||
@@ -764,6 +815,47 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
delete:
|
||||
tags: [Trees]
|
||||
summary: Permanently delete a tree
|
||||
description: >
|
||||
Owner only. Deletes the tree and all its data (persons, links, media, annotations,
|
||||
contributions, audit log) and removes its media directory.
|
||||
security:
|
||||
- BearerAuth: []
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Tree deleted
|
||||
'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}/access/unlock:
|
||||
post:
|
||||
|
||||
Generated
-73
@@ -15,7 +15,6 @@
|
||||
"bcryptjs": "^3.0.2",
|
||||
"dotenv": "^16.6.1",
|
||||
"fastify": "^5.6.1",
|
||||
"resend": "^6.9.2",
|
||||
"zod": "^4.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -343,12 +342,6 @@
|
||||
"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,
|
||||
@@ -1278,12 +1271,6 @@
|
||||
"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": [
|
||||
@@ -1672,12 +1659,6 @@
|
||||
"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,
|
||||
@@ -1797,27 +1778,6 @@
|
||||
"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,
|
||||
@@ -2331,16 +2291,6 @@
|
||||
"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,
|
||||
@@ -2368,16 +2318,6 @@
|
||||
"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",
|
||||
@@ -2497,19 +2437,6 @@
|
||||
"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,
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
"bcryptjs": "^3.0.2",
|
||||
"dotenv": "^16.6.1",
|
||||
"fastify": "^5.6.1",
|
||||
"resend": "^6.9.2",
|
||||
"zod": "^4.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
File renamed without changes.
@@ -0,0 +1,59 @@
|
||||
// Purge définitive des arbres marqués supprimés (deletedAt non nul) : base et dossiers médias.
|
||||
//
|
||||
// Usage, depuis backend/ après `npm run build` :
|
||||
// node scripts/purge-deleted-trees.mjs liste les arbres concernés, ne modifie rien
|
||||
// node scripts/purge-deleted-trees.mjs --apply les supprime définitivement
|
||||
//
|
||||
// Le script réutilise la logique compilée de dist/lib/tree-purge.js, la même que l'API.
|
||||
// Il n'affiche que des identifiants, jamais de nom d'arbre ni d'adresse email.
|
||||
import { existsSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
|
||||
const backendDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const distLibDir = path.join(backendDir, 'dist', 'lib')
|
||||
const apply = process.argv.includes('--apply')
|
||||
|
||||
// Même répertoire de travail que le serveur : .env et MEDIA_STORAGE_PATH relatif se résolvent pareil.
|
||||
process.chdir(backendDir)
|
||||
|
||||
if (!existsSync(path.join(distLibDir, 'tree-purge.js'))) {
|
||||
console.error('Build introuvable : lancez `npm run build` dans backend/ avant ce script.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const { prisma } = await import(pathToFileURL(path.join(distLibDir, 'prisma.js')).href)
|
||||
const { purgeTrees } = await import(pathToFileURL(path.join(distLibDir, 'tree-purge.js')).href)
|
||||
|
||||
const logger = {
|
||||
warn: (details, message) => console.warn(message, details),
|
||||
error: (details, message) => console.error(message, details),
|
||||
}
|
||||
|
||||
try {
|
||||
const trees = await prisma.tree.findMany({
|
||||
where: { deletedAt: { not: null } },
|
||||
select: { id: true },
|
||||
orderBy: { deletedAt: 'asc' },
|
||||
})
|
||||
const treeIds = trees.map((tree) => tree.id)
|
||||
|
||||
console.log(`${treeIds.length} arbre(s) marqué(s) supprimé(s).`)
|
||||
for (const treeId of treeIds) {
|
||||
console.log(` ${treeId}`)
|
||||
}
|
||||
|
||||
if (treeIds.length === 0) {
|
||||
// Rien à faire.
|
||||
} else if (!apply) {
|
||||
console.log('Simulation : aucune modification. Relancez avec --apply pour purger définitivement.')
|
||||
} else {
|
||||
const count = await purgeTrees(prisma, treeIds, logger)
|
||||
console.log(`${count} arbre(s) purgé(s) en base, dossiers médias supprimés.`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Échec de la purge :', error instanceof Error ? error.message : error)
|
||||
process.exitCode = 1
|
||||
} finally {
|
||||
await prisma.$disconnect()
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
import { Resend } from 'resend'
|
||||
|
||||
const apiKey = process.env.RESEND_API_KEY
|
||||
if (!apiKey) {
|
||||
console.error('RESEND_API_KEY is missing in environment.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const targetSegmentName = process.env.RESEND_WAITLIST_SEGMENT_NAME || 'parenthese-waitlist'
|
||||
const preferredDomain = process.env.RESEND_WAITLIST_DOMAIN || 'parenthese.io'
|
||||
const replyTo = process.env.RESEND_WAITLIST_REPLY_TO || 'hello@parenthese.io'
|
||||
|
||||
const resend = new Resend(apiKey)
|
||||
|
||||
function formatResendError(prefix, error) {
|
||||
if (!error) return prefix
|
||||
const code = error.name || 'unknown_error'
|
||||
const status = error.statusCode ?? 'n/a'
|
||||
const message = error.message || 'unknown message'
|
||||
return `${prefix} (${code}, status=${status}): ${message}`
|
||||
}
|
||||
|
||||
function findSegmentByName(items, name) {
|
||||
return items.find((item) => String(item.name || '').toLowerCase() === name.toLowerCase())
|
||||
}
|
||||
|
||||
function wait(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
async function withRetry(task, label) {
|
||||
const delays = [0, 700, 1400, 2200]
|
||||
let lastError = null
|
||||
|
||||
for (let index = 0; index < delays.length; index += 1) {
|
||||
if (delays[index] > 0) {
|
||||
await wait(delays[index])
|
||||
}
|
||||
|
||||
try {
|
||||
return await task()
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
lastError = error
|
||||
|
||||
if (!message.includes('rate_limit_exceeded') || index === delays.length - 1) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`${label} failed after retries: ${lastError instanceof Error ? lastError.message : String(lastError)}`)
|
||||
}
|
||||
|
||||
function firstVerifiedDomain(domains) {
|
||||
return domains.find((domain) => domain?.status === 'verified') || null
|
||||
}
|
||||
|
||||
function chooseSenderDomain(domains, preferred) {
|
||||
const preferredVerified = domains.find(
|
||||
(domain) => domain?.status === 'verified' && String(domain.name || '').toLowerCase() === preferred.toLowerCase(),
|
||||
)
|
||||
if (preferredVerified) return preferredVerified.name
|
||||
const fallbackVerified = firstVerifiedDomain(domains)
|
||||
if (fallbackVerified) return fallbackVerified.name
|
||||
return null
|
||||
}
|
||||
|
||||
async function ensureSegment(name) {
|
||||
const listResult = await withRetry(() => resend.segments.list({ limit: 100 }), 'segments.list')
|
||||
if (listResult.error) {
|
||||
throw new Error(formatResendError('Unable to list Resend segments', listResult.error))
|
||||
}
|
||||
|
||||
const existing = findSegmentByName(listResult.data?.data || [], name)
|
||||
if (existing) {
|
||||
return { id: existing.id, created: false }
|
||||
}
|
||||
|
||||
const createResult = await withRetry(() => resend.segments.create({ name }), 'segments.create')
|
||||
if (createResult.error) {
|
||||
throw new Error(formatResendError('Unable to create Resend segment', createResult.error))
|
||||
}
|
||||
|
||||
return { id: createResult.data?.id, created: true }
|
||||
}
|
||||
|
||||
async function resolveSenderAddress() {
|
||||
const domainResult = await withRetry(() => resend.domains.list({ limit: 100 }), 'domains.list')
|
||||
if (domainResult.error) {
|
||||
throw new Error(formatResendError('Unable to list Resend domains', domainResult.error))
|
||||
}
|
||||
|
||||
const domains = domainResult.data?.data || []
|
||||
const domain = chooseSenderDomain(domains, preferredDomain)
|
||||
if (!domain) {
|
||||
return {
|
||||
from: 'Parenthese <onboarding@resend.dev>',
|
||||
warning: 'No verified custom domain found. Using onboarding@resend.dev temporarily.',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
from: `Parenthese <waitlist@${domain}>`,
|
||||
warning: null,
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const segment = await ensureSegment(targetSegmentName)
|
||||
const sender = await resolveSenderAddress()
|
||||
|
||||
const summary = {
|
||||
segmentName: targetSegmentName,
|
||||
segmentId: segment.id,
|
||||
segmentCreatedNow: segment.created,
|
||||
fromAddress: sender.from,
|
||||
replyToAddress: replyTo,
|
||||
warning: sender.warning,
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(summary, null, 2))
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error))
|
||||
process.exit(1)
|
||||
})
|
||||
+6
-2
@@ -15,7 +15,6 @@ 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() {
|
||||
@@ -59,6 +58,12 @@ export function createApp() {
|
||||
return
|
||||
}
|
||||
|
||||
// Un compte supprimé invalide aussi les sessions ouvertes sur d'autres appareils
|
||||
const accountCount = await app.prisma.user.count({ where: { id: payload.userId } })
|
||||
if (accountCount === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
request.actor = {
|
||||
kind: 'user',
|
||||
userId: payload.userId,
|
||||
@@ -110,7 +115,6 @@ export function createApp() {
|
||||
app.register(contributionRoutes)
|
||||
app.register(annotationRoutes)
|
||||
app.register(gedcomRoutes)
|
||||
app.register(waitlistRoutes)
|
||||
|
||||
app.setErrorHandler((error: FastifyError | Error, request, reply) => {
|
||||
if (error instanceof Prisma.PrismaClientInitializationError) {
|
||||
|
||||
@@ -6,11 +6,6 @@ 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),
|
||||
@@ -23,12 +18,6 @@ const envSchema = z.object({
|
||||
.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)
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { rm } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
import type { Prisma, PrismaClient } from '@prisma/client'
|
||||
|
||||
import { env } from '../config/env.js'
|
||||
|
||||
export type PurgeLogger = {
|
||||
warn: (details: Record<string, unknown>, message: string) => void
|
||||
error: (details: Record<string, unknown>, message: string) => void
|
||||
}
|
||||
|
||||
export function getMediaStorageRoot(): string {
|
||||
return path.resolve(env.MEDIA_STORAGE_PATH)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dossier des médias d'un arbre : `<racine>/<treeId>`.
|
||||
* Renvoie null si l'identifiant désigne autre chose qu'un dossier situé directement sous la racine.
|
||||
*/
|
||||
export function resolveTreeMediaDirectory(treeId: string, storageRoot = getMediaStorageRoot()): string | null {
|
||||
if (!treeId || treeId.includes('/') || treeId.includes('\\')) {
|
||||
return null
|
||||
}
|
||||
|
||||
const root = path.resolve(storageRoot)
|
||||
const directory = path.resolve(root, treeId)
|
||||
if (directory === root || path.dirname(directory) !== root) {
|
||||
return null
|
||||
}
|
||||
|
||||
return directory
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime définitivement des arbres, dans la transaction fournie.
|
||||
* Personnes, liens, médias, annotations, contributions et journal d'audit partent en cascade.
|
||||
*/
|
||||
export async function deleteTreeRecords(tx: Prisma.TransactionClient, treeIds: string[]): Promise<number> {
|
||||
if (treeIds.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
await tx.user.updateMany({
|
||||
where: {
|
||||
lastOpenedTreeId: { in: treeIds },
|
||||
},
|
||||
data: {
|
||||
lastOpenedTreeId: null,
|
||||
lastOpenedAccessMode: null,
|
||||
lastOpenedRole: null,
|
||||
lastOpenedAt: null,
|
||||
},
|
||||
})
|
||||
|
||||
const deleted = await tx.tree.deleteMany({
|
||||
where: {
|
||||
id: { in: treeIds },
|
||||
},
|
||||
})
|
||||
|
||||
return deleted.count
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime les dossiers médias des arbres. À appeler une fois la transaction validée :
|
||||
* un échec est journalisé sans être propagé, la base restant la source de vérité.
|
||||
*/
|
||||
export async function removeTreeMediaDirectories(
|
||||
treeIds: string[],
|
||||
logger: PurgeLogger,
|
||||
storageRoot = getMediaStorageRoot(),
|
||||
): Promise<void> {
|
||||
for (const treeId of treeIds) {
|
||||
const directory = resolveTreeMediaDirectory(treeId, storageRoot)
|
||||
if (!directory) {
|
||||
logger.warn({ treeId }, 'Refused media directory path for tree purge')
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
} catch (error) {
|
||||
logger.error({ err: error, treeId }, 'Failed to remove tree media directory')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Suppression définitive d'arbres : base dans une transaction, puis fichiers. */
|
||||
export async function purgeTrees(prisma: PrismaClient, treeIds: string[], logger: PurgeLogger): Promise<number> {
|
||||
const uniqueIds = [...new Set(treeIds)]
|
||||
if (uniqueIds.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const count = await prisma.$transaction((tx) => deleteTreeRecords(tx, uniqueIds))
|
||||
await removeTreeMediaDirectories(uniqueIds, logger)
|
||||
return count
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
import { Resend, type ErrorResponse } from 'resend'
|
||||
|
||||
import { env } from '../config/env.js'
|
||||
|
||||
type WaitlistSignupPayload = {
|
||||
email: string
|
||||
prenom: string | null
|
||||
source: string
|
||||
}
|
||||
|
||||
type LoggerLike = {
|
||||
info: (obj: unknown, msg?: string) => void
|
||||
warn: (obj: unknown, msg?: string) => void
|
||||
error: (obj: unknown, msg?: string) => void
|
||||
}
|
||||
|
||||
const resendClient = env.RESEND_API_KEY ? new Resend(env.RESEND_API_KEY) : null
|
||||
const hasWaitlistContactTarget = Boolean(env.RESEND_WAITLIST_SEGMENT_ID || env.RESEND_WAITLIST_AUDIENCE_ID)
|
||||
|
||||
function isDuplicateLikeError(error: ErrorResponse | null): boolean {
|
||||
if (!error?.message) {
|
||||
return false
|
||||
}
|
||||
|
||||
const normalizedMessage = error.message.toLowerCase()
|
||||
return normalizedMessage.includes('already') || normalizedMessage.includes('exists')
|
||||
}
|
||||
|
||||
function sanitizeTagValue(rawValue: string): string {
|
||||
const normalized = rawValue.toLowerCase().replace(/[^a-z0-9_-]/g, '_')
|
||||
return normalized.slice(0, 256) || 'direct'
|
||||
}
|
||||
|
||||
function escapeHtml(rawValue: string): string {
|
||||
return rawValue
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function resolveUnsubscribeLink(): string {
|
||||
if (env.RESEND_WAITLIST_UNSUBSCRIBE_URL) {
|
||||
return env.RESEND_WAITLIST_UNSUBSCRIBE_URL
|
||||
}
|
||||
|
||||
const unsubscribeEmail = (env.RESEND_WAITLIST_REPLY_TO || 'hello@parenthese.io').trim()
|
||||
const subject = encodeURIComponent('Désinscription waitlist Parenthèse')
|
||||
return `mailto:${unsubscribeEmail}?subject=${subject}`
|
||||
}
|
||||
|
||||
function buildWaitlistEmailHtml(firstName: string, unsubscribeLink: string): string {
|
||||
const escapedFirstName = escapeHtml(firstName)
|
||||
const escapedUnsubscribeLink = escapeHtml(unsubscribeLink)
|
||||
const shareLink = 'https://parenthese.io/?source=waitlist-share'
|
||||
|
||||
return `
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Parenthèse - inscription confirmée</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:#fef9ed;color:#5d524b;font-family:'Segoe UI',Tahoma,Geneva,Verdana,sans-serif;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background:#fef9ed;padding:24px 12px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="max-width:600px;background:#f7ecd9;border:1px solid #eadac0;border-radius:16px;overflow:hidden;">
|
||||
<tr>
|
||||
<td style="padding:28px 28px 20px;background:linear-gradient(180deg,#fff5df 0%,#fef9ed 100%);text-align:center;">
|
||||
<div style="display:inline-flex;align-items:center;gap:10px;">
|
||||
<span style="display:inline-block;width:42px;height:42px;border-radius:10px;background:#a67c52;color:#fef9ed;line-height:42px;font-weight:700;font-size:20px;text-align:center;">P</span>
|
||||
<span style="font-size:22px;font-weight:700;color:#5d524b;">Parenthèse</span>
|
||||
</div>
|
||||
<h1 style="margin:14px 0 6px;font-size:30px;line-height:1.15;font-weight:700;color:#5d524b;">Merci pour votre inscription</h1>
|
||||
<p style="margin:0;font-size:15px;line-height:1.6;color:#6b5e56;">
|
||||
Bonjour ${escapedFirstName}, votre place sur la waitlist Parenthèse est confirmée.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:24px 28px 16px;">
|
||||
<p style="margin:0 0 14px;font-size:15px;line-height:1.7;color:#5d524b;">
|
||||
Nous vous préviendrons dès que les prochaines invitations seront ouvertes, avec uniquement des nouvelles utiles.
|
||||
</p>
|
||||
<p style="margin:0 0 16px;font-size:15px;line-height:1.7;color:#5d524b;">
|
||||
Merci de faire partie des premières familles qui nous font confiance.
|
||||
</p>
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td style="border-radius:999px;background:#a67c52;">
|
||||
<a href="${shareLink}" style="display:inline-block;padding:12px 20px;color:#fef9ed;text-decoration:none;font-size:14px;font-weight:600;">
|
||||
Partager Parenthèse
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:18px 28px 26px;border-top:1px solid #eadac0;">
|
||||
<p style="margin:0 0 6px;font-size:13px;line-height:1.6;color:#6b5e56;">
|
||||
À bientôt,<br /><strong>Parenthèse</strong>
|
||||
</p>
|
||||
<p style="margin:0 0 8px;font-size:11px;line-height:1.6;color:#8f7f72;">
|
||||
Vous recevez cet email suite à votre inscription volontaire à la waitlist.
|
||||
</p>
|
||||
<p style="margin:0;font-size:11px;line-height:1.6;">
|
||||
<a href="${escapedUnsubscribeLink}" style="color:#8f6f52;text-decoration:underline;">Se désinscrire de la waitlist</a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`.trim()
|
||||
}
|
||||
|
||||
function buildWaitlistEmailText(firstName: string, unsubscribeLink: string): string {
|
||||
const shareLink = 'https://parenthese.io/?source=waitlist-share'
|
||||
|
||||
return [
|
||||
`Bonjour ${firstName},`,
|
||||
'',
|
||||
'Merci, votre inscription à la waitlist Parenthèse est confirmée.',
|
||||
'Nous vous écrirons dès que les prochaines invitations seront ouvertes.',
|
||||
'Merci de faire partie des premières familles qui nous font confiance.',
|
||||
'',
|
||||
`Partager Parenthèse : ${shareLink}`,
|
||||
'',
|
||||
`Se désinscrire: ${unsubscribeLink}`,
|
||||
'',
|
||||
'À bientôt,',
|
||||
'Parenthèse',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
async function upsertContactInResend(payload: WaitlistSignupPayload): Promise<void> {
|
||||
if (!resendClient || !hasWaitlistContactTarget) {
|
||||
return
|
||||
}
|
||||
|
||||
const createPayload: {
|
||||
email: string
|
||||
unsubscribed: boolean
|
||||
firstName?: string
|
||||
segments?: Array<{ id: string }>
|
||||
audienceId?: string
|
||||
} = {
|
||||
email: payload.email,
|
||||
unsubscribed: false,
|
||||
}
|
||||
|
||||
if (payload.prenom) {
|
||||
createPayload.firstName = payload.prenom
|
||||
}
|
||||
|
||||
if (env.RESEND_WAITLIST_SEGMENT_ID) {
|
||||
createPayload.segments = [{ id: env.RESEND_WAITLIST_SEGMENT_ID }]
|
||||
}
|
||||
|
||||
if (env.RESEND_WAITLIST_AUDIENCE_ID) {
|
||||
createPayload.audienceId = env.RESEND_WAITLIST_AUDIENCE_ID
|
||||
}
|
||||
|
||||
const createResult = await resendClient.contacts.create(createPayload)
|
||||
|
||||
if (!createResult.error) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isDuplicateLikeError(createResult.error)) {
|
||||
throw new Error(`Resend contact create failed: ${createResult.error.message}`)
|
||||
}
|
||||
|
||||
const updatePayload: {
|
||||
email: string
|
||||
unsubscribed: boolean
|
||||
firstName: string | null
|
||||
audienceId?: string
|
||||
} = {
|
||||
email: payload.email,
|
||||
unsubscribed: false,
|
||||
firstName: payload.prenom ?? null,
|
||||
}
|
||||
|
||||
if (env.RESEND_WAITLIST_AUDIENCE_ID) {
|
||||
updatePayload.audienceId = env.RESEND_WAITLIST_AUDIENCE_ID
|
||||
}
|
||||
|
||||
const updateResult = await resendClient.contacts.update(updatePayload)
|
||||
if (updateResult.error) {
|
||||
throw new Error(`Resend contact update failed: ${updateResult.error.message}`)
|
||||
}
|
||||
|
||||
if (env.RESEND_WAITLIST_SEGMENT_ID) {
|
||||
const segmentResult = await resendClient.contacts.segments.add({
|
||||
email: payload.email,
|
||||
segmentId: env.RESEND_WAITLIST_SEGMENT_ID,
|
||||
})
|
||||
|
||||
if (segmentResult.error && !isDuplicateLikeError(segmentResult.error)) {
|
||||
throw new Error(`Resend segment add failed: ${segmentResult.error.message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function sendWaitlistConfirmation(payload: WaitlistSignupPayload): Promise<void> {
|
||||
if (!resendClient || !env.RESEND_WAITLIST_FROM) {
|
||||
return
|
||||
}
|
||||
|
||||
const firstName = payload.prenom ?? 'la famille'
|
||||
const sourceLabel = payload.source || 'direct'
|
||||
const unsubscribeLink = resolveUnsubscribeLink()
|
||||
const html = buildWaitlistEmailHtml(firstName, unsubscribeLink)
|
||||
const text = buildWaitlistEmailText(firstName, unsubscribeLink)
|
||||
|
||||
const sendResult = await resendClient.emails.send({
|
||||
from: env.RESEND_WAITLIST_FROM,
|
||||
to: payload.email,
|
||||
...(env.RESEND_WAITLIST_REPLY_TO ? { replyTo: env.RESEND_WAITLIST_REPLY_TO } : {}),
|
||||
subject: 'Parenthèse - merci pour votre inscription',
|
||||
text,
|
||||
html,
|
||||
tags: [
|
||||
{ name: 'flow', value: 'waitlist_signup' },
|
||||
{ name: 'source', value: sanitizeTagValue(sourceLabel) },
|
||||
],
|
||||
})
|
||||
|
||||
if (sendResult.error) {
|
||||
throw new Error(`Resend email send failed: ${sendResult.error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncWaitlistSignup(payload: WaitlistSignupPayload, log: LoggerLike): Promise<void> {
|
||||
if (!resendClient) {
|
||||
log.warn({ email: payload.email }, 'Resend waitlist skipped: RESEND_API_KEY is not configured')
|
||||
return
|
||||
}
|
||||
|
||||
if (!hasWaitlistContactTarget && !env.RESEND_WAITLIST_FROM) {
|
||||
log.warn({ email: payload.email }, 'Resend configured but no waitlist segment/audience or sender configured')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await upsertContactInResend(payload)
|
||||
await sendWaitlistConfirmation(payload)
|
||||
} catch (error) {
|
||||
log.error({ err: error, email: payload.email, source: payload.source }, 'Waitlist Resend sync failed')
|
||||
throw error
|
||||
}
|
||||
|
||||
log.info({ email: payload.email, source: payload.source }, 'Waitlist Resend sync completed')
|
||||
}
|
||||
@@ -5,6 +5,7 @@ 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 { deleteTreeRecords, removeTreeMediaDirectories } from '../lib/tree-purge.js'
|
||||
import type { AnyJwtPayload } from '../types/auth.js'
|
||||
|
||||
const credentialsSchema = z.object({
|
||||
@@ -12,6 +13,10 @@ const credentialsSchema = z.object({
|
||||
password: z.string().min(8).max(128),
|
||||
})
|
||||
|
||||
const deleteAccountSchema = z.object({
|
||||
password: z.string().min(1).max(128),
|
||||
})
|
||||
|
||||
const updateLastTreeSchema = z.object({
|
||||
treeId: z.string().min(1),
|
||||
accessMode: z.enum(['member', 'share']),
|
||||
@@ -279,6 +284,77 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
})
|
||||
})
|
||||
|
||||
app.delete(
|
||||
'/me',
|
||||
{
|
||||
config: {
|
||||
rateLimit: {
|
||||
max: 5,
|
||||
timeWindow: '1 minute',
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (!request.actor || request.actor.kind !== 'user') {
|
||||
return reply.code(401).send({ error: 'authentication_required' })
|
||||
}
|
||||
|
||||
const parsed = deleteAccountSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: 'invalid_payload', details: parsed.error.flatten() })
|
||||
}
|
||||
|
||||
const userId = request.actor.userId
|
||||
const user = await app.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
passwordHash: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
return reply.code(401).send({ error: 'authentication_required' })
|
||||
}
|
||||
|
||||
const isValid = await verifyPassword(parsed.data.password, user.passwordHash)
|
||||
if (!isValid) {
|
||||
return reply.code(403).send({ error: 'invalid_password' })
|
||||
}
|
||||
|
||||
const payload = await request.jwtVerify<AnyJwtPayload & { exp?: number }>()
|
||||
|
||||
// Arbres possédés (y compris ceux marqués supprimés) puis compte, dans une seule transaction :
|
||||
// la relation propriétaire est en Restrict, les arbres doivent partir avant l'utilisateur.
|
||||
const ownedTreeIds = await app.prisma.$transaction(async (tx) => {
|
||||
const ownedTrees = await tx.tree.findMany({
|
||||
where: { ownerUserId: userId },
|
||||
select: { id: true },
|
||||
})
|
||||
const treeIds = ownedTrees.map((tree) => tree.id)
|
||||
|
||||
await deleteTreeRecords(tx, treeIds)
|
||||
|
||||
// Les contributions envoyées à d'autres familles restent, sans l'adresse email de l'auteur.
|
||||
await tx.contributionSession.updateMany({
|
||||
where: { submittedByUserId: userId },
|
||||
data: { submittedByLabel: 'Contributeur' },
|
||||
})
|
||||
|
||||
await tx.user.delete({ where: { id: userId } })
|
||||
return treeIds
|
||||
})
|
||||
|
||||
if (payload.kind === 'user' && typeof payload.exp === 'number') {
|
||||
revokeUserToken(payload.jti, payload.exp)
|
||||
}
|
||||
|
||||
await removeTreeMediaDirectories(ownedTreeIds, request.log)
|
||||
|
||||
return reply.send({ ok: true })
|
||||
},
|
||||
)
|
||||
|
||||
app.post('/logout', async (request, reply) => {
|
||||
if (!request.actor || request.actor.kind !== 'user') {
|
||||
return reply.code(401).send({ error: 'authentication_required' })
|
||||
|
||||
@@ -4,6 +4,7 @@ import { z } from 'zod'
|
||||
|
||||
import { env } from '../config/env.js'
|
||||
import { hashPassword, verifyPassword } from '../lib/auth.js'
|
||||
import { purgeTrees } from '../lib/tree-purge.js'
|
||||
import type { MembershipRole } from '../types/auth.js'
|
||||
import { findParentChildValidationError, findUnionValidationError } from '../utils/relationship-guards.js'
|
||||
|
||||
@@ -624,43 +625,22 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
|
||||
return reply.code(403).send({ error: 'forbidden' })
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const deletedTree = await app.prisma.tree.updateMany({
|
||||
const existingTree = await app.prisma.tree.findFirst({
|
||||
where: {
|
||||
id: params.data.id,
|
||||
deletedAt: null,
|
||||
},
|
||||
data: {
|
||||
deletedAt: now,
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (deletedTree.count === 0) {
|
||||
if (!existingTree) {
|
||||
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,
|
||||
},
|
||||
})
|
||||
// Suppression définitive : données en cascade (journal d'audit compris) puis dossier médias.
|
||||
await purgeTrees(app.prisma, [existingTree.id], request.log)
|
||||
|
||||
return reply.send({ ok: true })
|
||||
})
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
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 })
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ const { prismaMock } = vi.hoisted(() => ({
|
||||
prismaMock: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
count: vi.fn().mockResolvedValue(1),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ const { prismaMock } = vi.hoisted(() => ({
|
||||
prismaMock: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
count: vi.fn().mockResolvedValue(1),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { prismaMock } = vi.hoisted(() => ({
|
||||
prismaMock: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
count: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
tree: {
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
treeMembership: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
treeAccessPasswords: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
contributionSession: {
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
$disconnect: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../src/lib/prisma.js', () => ({
|
||||
prisma: prismaMock,
|
||||
}))
|
||||
|
||||
const PASSWORD = 'correct-password'
|
||||
|
||||
// user-1 possède deux arbres (dont un déjà marqué supprimé) et n'est que membre du troisième.
|
||||
const TREES = [
|
||||
{ id: 'tree-owned', ownerUserId: 'user-1' },
|
||||
{ id: 'tree-owned-archived', ownerUserId: 'user-1' },
|
||||
{ id: 'tree-member', ownerUserId: 'user-2' },
|
||||
]
|
||||
|
||||
let storageRoot = ''
|
||||
let passwordHash = ''
|
||||
let createApp: () => FastifyInstance
|
||||
let resolveTreeMediaDirectory: (treeId: string, root?: string) => string | null
|
||||
|
||||
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'
|
||||
process.env.MEDIA_STORAGE_PATH = storageRoot
|
||||
}
|
||||
|
||||
async function createUserToken(app: FastifyInstance, userId = 'user-1'): Promise<string> {
|
||||
await app.ready()
|
||||
return (app as any).jwt.sign(
|
||||
{
|
||||
kind: 'user',
|
||||
sub: userId,
|
||||
jti: randomUUID(),
|
||||
userId,
|
||||
email: 'user@example.com',
|
||||
},
|
||||
{
|
||||
expiresIn: '1h',
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async function writeMediaFile(treeId: string, relativePath: string): Promise<string> {
|
||||
const filePath = path.join(storageRoot, treeId, relativePath)
|
||||
await mkdir(path.dirname(filePath), { recursive: true })
|
||||
await writeFile(filePath, 'media')
|
||||
return filePath
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
storageRoot = await mkdtemp(path.join(tmpdir(), 'parenthese-purge-'))
|
||||
applyTestEnv()
|
||||
|
||||
const authModule = await import('../src/lib/auth.js')
|
||||
passwordHash = await authModule.hashPassword(PASSWORD)
|
||||
|
||||
createApp = (await import('../src/app.js')).createApp
|
||||
resolveTreeMediaDirectory = (await import('../src/lib/tree-purge.js')).resolveTreeMediaDirectory
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(storageRoot, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
await rm(storageRoot, { recursive: true, force: true })
|
||||
await mkdir(storageRoot, { recursive: true })
|
||||
|
||||
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-1', passwordHash })
|
||||
prismaMock.user.count.mockResolvedValue(1)
|
||||
prismaMock.user.updateMany.mockResolvedValue({ count: 0 })
|
||||
prismaMock.user.delete.mockResolvedValue({ id: 'user-1' })
|
||||
prismaMock.tree.findMany.mockImplementation(async (args: any) => (
|
||||
TREES.filter((tree) => tree.ownerUserId === args?.where?.ownerUserId).map(({ id }) => ({ id }))
|
||||
))
|
||||
prismaMock.tree.findFirst.mockResolvedValue({ id: 'tree-owned' })
|
||||
prismaMock.tree.deleteMany.mockImplementation(async (args: any) => ({ count: args?.where?.id?.in?.length ?? 0 }))
|
||||
prismaMock.treeMembership.findUnique.mockResolvedValue({ treeId: 'tree-owned', userId: 'user-1', role: 'owner' })
|
||||
prismaMock.treeAccessPasswords.findUnique.mockResolvedValue(null)
|
||||
prismaMock.contributionSession.updateMany.mockResolvedValue({ count: 0 })
|
||||
prismaMock.$transaction.mockImplementation(async (callback: (tx: typeof prismaMock) => Promise<unknown>) => callback(prismaMock))
|
||||
prismaMock.$disconnect.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
describe('DELETE /auth/me', () => {
|
||||
it('requires an authenticated user', async () => {
|
||||
const app = createApp()
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/auth/me',
|
||||
payload: { password: PASSWORD },
|
||||
})
|
||||
|
||||
expect(response.statusCode).toBe(401)
|
||||
expect(prismaMock.$transaction).not.toHaveBeenCalled()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('rejects a missing password', async () => {
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/auth/me',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {},
|
||||
})
|
||||
|
||||
expect(response.statusCode).toBe(400)
|
||||
expect(prismaMock.$transaction).not.toHaveBeenCalled()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('rejects a wrong password without deleting anything', async () => {
|
||||
const ownedFile = await writeMediaFile('tree-owned', 'person-1/photo.jpg')
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/auth/me',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { password: 'wrong-password' },
|
||||
})
|
||||
|
||||
expect(response.statusCode).toBe(403)
|
||||
expect(response.json()).toEqual({ error: 'invalid_password' })
|
||||
expect(prismaMock.$transaction).not.toHaveBeenCalled()
|
||||
expect(prismaMock.tree.deleteMany).not.toHaveBeenCalled()
|
||||
expect(prismaMock.user.delete).not.toHaveBeenCalled()
|
||||
expect(existsSync(ownedFile)).toBe(true)
|
||||
|
||||
// Le jeton reste valide après un mot de passe erroné.
|
||||
const lastTreeResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/auth/me/last-tree',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(lastTreeResponse.statusCode).toBe(200)
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('deletes the user, owned trees and their media, keeps other trees, revokes the token', async () => {
|
||||
const ownedFile = await writeMediaFile('tree-owned', 'person-1/photo.jpg')
|
||||
const archivedFile = await writeMediaFile('tree-owned-archived', 'annotation-photos/note.png')
|
||||
const memberFile = await writeMediaFile('tree-member', 'person-9/photo.jpg')
|
||||
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/auth/me',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { password: PASSWORD },
|
||||
})
|
||||
|
||||
expect(response.statusCode).toBe(200)
|
||||
expect(response.json()).toEqual({ ok: true })
|
||||
|
||||
expect(prismaMock.tree.findMany).toHaveBeenCalledWith({
|
||||
where: { ownerUserId: 'user-1' },
|
||||
select: { id: true },
|
||||
})
|
||||
expect(prismaMock.tree.deleteMany).toHaveBeenCalledTimes(1)
|
||||
expect(prismaMock.tree.deleteMany).toHaveBeenCalledWith({
|
||||
where: { id: { in: ['tree-owned', 'tree-owned-archived'] } },
|
||||
})
|
||||
expect(prismaMock.user.updateMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { lastOpenedTreeId: { in: ['tree-owned', 'tree-owned-archived'] } },
|
||||
}))
|
||||
expect(prismaMock.contributionSession.updateMany).toHaveBeenCalledWith({
|
||||
where: { submittedByUserId: 'user-1' },
|
||||
data: { submittedByLabel: 'Contributeur' },
|
||||
})
|
||||
expect(prismaMock.user.delete).toHaveBeenCalledWith({ where: { id: 'user-1' } })
|
||||
|
||||
// Les arbres doivent partir avant l'utilisateur (relation propriétaire en Restrict).
|
||||
expect(prismaMock.tree.deleteMany.mock.invocationCallOrder[0])
|
||||
.toBeLessThan(prismaMock.user.delete.mock.invocationCallOrder[0])
|
||||
|
||||
expect(existsSync(ownedFile)).toBe(false)
|
||||
expect(existsSync(path.join(storageRoot, 'tree-owned'))).toBe(false)
|
||||
expect(existsSync(archivedFile)).toBe(false)
|
||||
expect(existsSync(memberFile)).toBe(true)
|
||||
|
||||
prismaMock.user.findUnique.mockResolvedValue(null)
|
||||
prismaMock.user.count.mockResolvedValue(0)
|
||||
const afterResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/auth/me/last-tree',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(afterResponse.statusCode).toBe(401)
|
||||
|
||||
const treesResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/trees',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(treesResponse.statusCode).toBe(401)
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('keeps the response successful when media removal fails', async () => {
|
||||
prismaMock.tree.findMany.mockResolvedValue([{ id: '..' }])
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/auth/me',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { password: PASSWORD },
|
||||
})
|
||||
|
||||
expect(response.statusCode).toBe(200)
|
||||
expect(existsSync(storageRoot)).toBe(true)
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('rejects a session opened on another device once the account is gone', async () => {
|
||||
const app = createApp()
|
||||
const otherDeviceToken = await createUserToken(app)
|
||||
prismaMock.user.count.mockResolvedValue(0)
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/trees',
|
||||
headers: { authorization: `Bearer ${otherDeviceToken}` },
|
||||
})
|
||||
|
||||
expect(response.statusCode).toBe(401)
|
||||
expect(prismaMock.user.count).toHaveBeenCalledWith({ where: { id: 'user-1' } })
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /trees/:id', () => {
|
||||
it('permanently deletes the tree and its media directory', async () => {
|
||||
const ownedFile = await writeMediaFile('tree-owned', 'person-1/photo.jpg')
|
||||
const otherFile = await writeMediaFile('tree-member', 'person-9/photo.jpg')
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/trees/tree-owned',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
|
||||
expect(response.statusCode).toBe(200)
|
||||
expect(response.json()).toEqual({ ok: true })
|
||||
expect(prismaMock.tree.deleteMany).toHaveBeenCalledWith({
|
||||
where: { id: { in: ['tree-owned'] } },
|
||||
})
|
||||
expect(prismaMock.user.updateMany).toHaveBeenCalledWith({
|
||||
where: { lastOpenedTreeId: { in: ['tree-owned'] } },
|
||||
data: {
|
||||
lastOpenedTreeId: null,
|
||||
lastOpenedAccessMode: null,
|
||||
lastOpenedRole: null,
|
||||
lastOpenedAt: null,
|
||||
},
|
||||
})
|
||||
expect(existsSync(ownedFile)).toBe(false)
|
||||
expect(existsSync(otherFile)).toBe(true)
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('forbids a non-owner member', async () => {
|
||||
const ownedFile = await writeMediaFile('tree-owned', 'person-1/photo.jpg')
|
||||
prismaMock.treeMembership.findUnique.mockResolvedValue({ treeId: 'tree-owned', userId: 'user-1', role: 'admin' })
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/trees/tree-owned',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
|
||||
expect(response.statusCode).toBe(403)
|
||||
expect(prismaMock.tree.deleteMany).not.toHaveBeenCalled()
|
||||
expect(existsSync(ownedFile)).toBe(true)
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('returns 404 for a tree already marked as deleted', async () => {
|
||||
prismaMock.tree.findFirst.mockResolvedValue(null)
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/trees/tree-owned',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
|
||||
expect(response.statusCode).toBe(404)
|
||||
expect(prismaMock.tree.deleteMany).not.toHaveBeenCalled()
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveTreeMediaDirectory', () => {
|
||||
it('accepts a plain tree id directly under the storage root', () => {
|
||||
expect(resolveTreeMediaDirectory('tree-1', storageRoot)).toBe(path.join(storageRoot, 'tree-1'))
|
||||
})
|
||||
|
||||
it('rejects ids that would escape or target the storage root', () => {
|
||||
for (const treeId of ['', '.', '..', '../other', 'a/b', 'a\\b', '..\\other']) {
|
||||
expect(resolveTreeMediaDirectory(treeId, storageRoot)).toBeNull()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -16,6 +16,7 @@ const { prismaMock } = vi.hoisted(() => ({
|
||||
prismaMock: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
count: vi.fn().mockResolvedValue(1),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@ Ce document concerne l'instance hébergée par Ilies Allali. Pour installer Pare
|
||||
|
||||
## 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.
|
||||
Chaque push sur `main` lance d'abord la CI (`.github/workflows/ci.yml`). Si elle passe, `.github/workflows/deploy-hostinger.yml` démarre. 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
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
#!/bin/bash
|
||||
# Parenthèse : déploiement sur le VPS.
|
||||
#
|
||||
# Lancé par GitHub Actions à chaque push sur main (.github/workflows/deploy-hostinger.yml),
|
||||
# Lancé par GitHub Actions après une CI réussie 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
|
||||
|
||||
@@ -124,19 +124,6 @@ server {
|
||||
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;
|
||||
|
||||
+15
-4
@@ -3,6 +3,7 @@
|
||||
# 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)
|
||||
# bash tools/docker-smoke.sh vérifier l'installation
|
||||
|
||||
name: parenthese
|
||||
|
||||
@@ -28,7 +29,7 @@ services:
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
# Tout le .env passe à l'API (JWT_SECRET, CORS_ORIGIN, RESEND_* ...).
|
||||
# Tout le .env passe à l'API (JWT_SECRET, CORS_ORIGIN, durées de session).
|
||||
env_file: .env
|
||||
# Ces valeurs sont imposées par le déploiement Docker et priment sur le .env.
|
||||
environment:
|
||||
@@ -38,8 +39,8 @@ services:
|
||||
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"]
|
||||
# Au démarrage, l'image applique les migrations Prisma en attente puis lance le serveur
|
||||
# (CMD de backend/Dockerfile).
|
||||
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
|
||||
@@ -53,9 +54,19 @@ services:
|
||||
dockerfile: Dockerfile.web
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- api
|
||||
api:
|
||||
# nginx résout l'adresse de `api` une seule fois, à son démarrage : il attend une API prête,
|
||||
# et redémarre quand le conteneur api est recréé (mise à jour), sinon il garderait l'ancienne adresse.
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
ports:
|
||||
- "${WEB_PORT:-80}:80"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1/"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
|
||||
volumes:
|
||||
db-data:
|
||||
|
||||
@@ -33,4 +33,10 @@ export default defineConfig([
|
||||
'react-hooks/exhaustive-deps': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['*.config.js'],
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -2901,3 +2901,67 @@
|
||||
.tree-settings-radio small {
|
||||
color: rgba(93, 82, 75, 0.7);
|
||||
}
|
||||
|
||||
/* Écran compte : suppression du compte */
|
||||
.account-delete-zone {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 6px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #eadfcd;
|
||||
}
|
||||
|
||||
.account-delete-link {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 4px 0;
|
||||
color: #9a6a5f;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.account-delete-link:hover:not(:disabled) {
|
||||
color: #862f2f;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.account-delete-link:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.account-delete-list {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
list-style: disc;
|
||||
color: #6e6258;
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.account-delete-form {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.account-delete-form label {
|
||||
font-size: 14px;
|
||||
color: #6e6258;
|
||||
}
|
||||
|
||||
.account-delete-modal .ghost {
|
||||
border: 1px solid #cdb89a;
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
background: transparent;
|
||||
color: #6a4f32;
|
||||
font-weight: 450;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.account-delete-modal .ghost:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
+75
-11
@@ -48,6 +48,7 @@ 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 { getSiblingParentLinks } from './utils/familyLinks'
|
||||
import { getUploadMimeType, validateMediaFile } from './utils/mediaUpload'
|
||||
import {
|
||||
identifyUser,
|
||||
@@ -830,6 +831,23 @@ function App() {
|
||||
setAddPersonError('')
|
||||
|
||||
try {
|
||||
// Un frère ou une sœur reçoit les mêmes parents que la personne choisie :
|
||||
// sans parent connu, on s'arrête avant de créer quoi que ce soit.
|
||||
const siblingParentLinks = []
|
||||
if (tree.canEditCurrentTree || isDemoMode) {
|
||||
for (const rel of formData.relations) {
|
||||
if (rel.type !== 'sibling') continue
|
||||
const links = getSiblingParentLinks(rel.personId, filiations, unions)
|
||||
if (links.length === 0) {
|
||||
const sibling = persons.find((person) => String(person.id) === String(rel.personId))
|
||||
const siblingName = [sibling?.firstName, sibling?.lastName].filter(Boolean).join(' ') || 'cette personne'
|
||||
setAddPersonError(`Ajoutez d'abord un parent à ${siblingName} pour pouvoir lui rattacher un frère ou une sœur.`)
|
||||
return
|
||||
}
|
||||
siblingParentLinks.push(...links)
|
||||
}
|
||||
}
|
||||
|
||||
if (!tree.canEditCurrentTree) {
|
||||
if (isDemoMode) {
|
||||
const personId = makeLocalId('local-person')
|
||||
@@ -871,8 +889,8 @@ function App() {
|
||||
} else if (rel.type === 'parent') {
|
||||
filiations.push({
|
||||
id: makeLocalId('local-filiation'),
|
||||
childId: personId,
|
||||
parentId: rel.personId,
|
||||
childId: rel.personId,
|
||||
parentId: personId,
|
||||
unionId: null,
|
||||
parentageType: 'biologique',
|
||||
displayOrder: filiations.length + 1,
|
||||
@@ -880,8 +898,8 @@ function App() {
|
||||
} else if (rel.type === 'child') {
|
||||
filiations.push({
|
||||
id: makeLocalId('local-filiation'),
|
||||
childId: rel.personId,
|
||||
parentId: personId,
|
||||
childId: personId,
|
||||
parentId: rel.personId,
|
||||
unionId: null,
|
||||
parentageType: 'biologique',
|
||||
displayOrder: filiations.length + 1,
|
||||
@@ -889,6 +907,24 @@ function App() {
|
||||
}
|
||||
}
|
||||
|
||||
for (const link of siblingParentLinks) {
|
||||
const alreadyLinked = filiations.some((filiation) => (
|
||||
String(filiation.childId) === String(personId)
|
||||
&& (link.unionId
|
||||
? String(filiation.unionId) === link.unionId
|
||||
: String(filiation.parentId) === link.parentId)
|
||||
))
|
||||
if (alreadyLinked) continue
|
||||
filiations.push({
|
||||
id: makeLocalId('local-filiation'),
|
||||
childId: personId,
|
||||
parentId: link.unionId ? null : link.parentId,
|
||||
unionId: link.unionId,
|
||||
parentageType: 'biologique',
|
||||
displayOrder: filiations.length + 1,
|
||||
})
|
||||
}
|
||||
|
||||
tree.bumpGraphRevision()
|
||||
tree.setSelectedPerson({ ...newPerson })
|
||||
}
|
||||
@@ -923,8 +959,10 @@ function App() {
|
||||
)
|
||||
|
||||
// Créer les liens de relation
|
||||
const linkedParentIds = new Set()
|
||||
for (const rel of formData.relations) {
|
||||
if (rel.type === 'child') {
|
||||
linkedParentIds.add(String(rel.personId))
|
||||
await createParentChildLink(
|
||||
tree.treeContext.treeId,
|
||||
{ parentPersonId: rel.personId, childPersonId: newPerson.id, parentageType: 'biologique', displayOrder: 1 },
|
||||
@@ -943,7 +981,22 @@ function App() {
|
||||
auth.userAuth.token
|
||||
)
|
||||
}
|
||||
// TODO: sibling (nécessite de trouver les parents communs)
|
||||
}
|
||||
|
||||
for (const link of siblingParentLinks) {
|
||||
if (linkedParentIds.has(link.parentId)) continue
|
||||
linkedParentIds.add(link.parentId)
|
||||
await createParentChildLink(
|
||||
tree.treeContext.treeId,
|
||||
{
|
||||
parentPersonId: link.parentId,
|
||||
childPersonId: newPerson.id,
|
||||
viaUnionId: link.unionId,
|
||||
parentageType: 'biologique',
|
||||
displayOrder: 1,
|
||||
},
|
||||
auth.userAuth.token
|
||||
)
|
||||
}
|
||||
|
||||
// Mettre à jour le draft avec la nouvelle personne
|
||||
@@ -2135,6 +2188,22 @@ function App() {
|
||||
const handleLogout = async () => {
|
||||
setTreeNotFound(false)
|
||||
await auth.handleLogout()
|
||||
resetSignedOutState()
|
||||
}
|
||||
|
||||
// Suppression du compte : même remise à zéro que la déconnexion une fois la session effacée.
|
||||
const handleDeleteAccount = async (password) => {
|
||||
const errorMessage = await auth.handleDeleteAccount(password)
|
||||
if (errorMessage) {
|
||||
return errorMessage
|
||||
}
|
||||
|
||||
setTreeNotFound(false)
|
||||
resetSignedOutState()
|
||||
return null
|
||||
}
|
||||
|
||||
function resetSignedOutState() {
|
||||
tree.setWizardState({ visible: false, loading: false, error: '' })
|
||||
setAccountPanelVisible(false)
|
||||
setTreeWizardVisible(false)
|
||||
@@ -2268,6 +2337,7 @@ function App() {
|
||||
onOpenTree={handleOpenTreeFromAccount}
|
||||
onStartTreeWizard={handleOpenTreeWizard}
|
||||
onLogout={handleLogout}
|
||||
onDeleteAccount={handleDeleteAccount}
|
||||
onBackToAccess={handleBackToAccess}
|
||||
onUseDemo={handleUseDemo}
|
||||
/>
|
||||
@@ -2324,12 +2394,6 @@ function App() {
|
||||
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é) */}
|
||||
<DraftRestoreBanner
|
||||
|
||||
@@ -29,6 +29,18 @@ export async function logoutUser(token) {
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteAccount(token, password) {
|
||||
const data = await apiRequest('/auth/me', {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ password }),
|
||||
})
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchLastOpenedTree(token) {
|
||||
const data = await apiRequest('/auth/me/last-tree', {
|
||||
method: 'GET',
|
||||
|
||||
@@ -13,6 +13,7 @@ const AccountDashboard = ({
|
||||
onOpenTree,
|
||||
onStartTreeWizard,
|
||||
onLogout,
|
||||
onDeleteAccount,
|
||||
onBackToAccess,
|
||||
onUseDemo,
|
||||
}) => {
|
||||
@@ -20,6 +21,41 @@ const AccountDashboard = ({
|
||||
const [firstName, setFirstName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false)
|
||||
const [deletePassword, setDeletePassword] = useState('')
|
||||
const [deleteError, setDeleteError] = useState('')
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
const openDeleteModal = () => {
|
||||
setDeletePassword('')
|
||||
setDeleteError('')
|
||||
setDeleteModalOpen(true)
|
||||
}
|
||||
|
||||
const closeDeleteModal = () => {
|
||||
if (deleting) return
|
||||
setDeleteModalOpen(false)
|
||||
setDeletePassword('')
|
||||
setDeleteError('')
|
||||
}
|
||||
|
||||
const handleDeleteSubmit = async (event) => {
|
||||
event.preventDefault()
|
||||
if (deleting || !deletePassword || !onDeleteAccount) return
|
||||
|
||||
setDeleting(true)
|
||||
setDeleteError('')
|
||||
const errorMessage = await onDeleteAccount(deletePassword)
|
||||
setDeleting(false)
|
||||
|
||||
if (errorMessage) {
|
||||
setDeleteError(errorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
setDeleteModalOpen(false)
|
||||
setDeletePassword('')
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setMode(defaultMode === 'register' ? 'register' : 'login')
|
||||
@@ -184,7 +220,63 @@ const AccountDashboard = ({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{onDeleteAccount && (
|
||||
<div className="account-delete-zone">
|
||||
<button type="button" className="account-delete-link" onClick={openDeleteModal} disabled={loading}>
|
||||
Supprimer mon compte
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{deleteModalOpen && (
|
||||
<div className="settings-delete-modal-overlay" onClick={closeDeleteModal}>
|
||||
<div
|
||||
className="settings-delete-modal account-delete-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="accountDeleteTitle"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<h3 id="accountDeleteTitle">Supprimer mon compte</h3>
|
||||
<p>Cette suppression est définitive, elle ne pourra pas être annulée.</p>
|
||||
<ul className="account-delete-list">
|
||||
<li>Votre compte et votre adresse email sont effacés.</li>
|
||||
<li>
|
||||
Les arbres que vous avez créés sont supprimés avec toutes leurs personnes et tous leurs médias,
|
||||
pour toute la famille qui y avait accès.
|
||||
</li>
|
||||
<li>Les arbres partagés avec vous restent à leurs propriétaires. Vous n'y aurez simplement plus accès.</li>
|
||||
</ul>
|
||||
|
||||
<form className="account-delete-form" onSubmit={handleDeleteSubmit}>
|
||||
<label htmlFor="accountDeletePassword">Mot de passe actuel</label>
|
||||
<input
|
||||
id="accountDeletePassword"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={deletePassword}
|
||||
onChange={(event) => setDeletePassword(event.target.value)}
|
||||
disabled={deleting}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
|
||||
{deleteError && <div className="account-error">{deleteError}</div>}
|
||||
|
||||
<div className="settings-delete-actions">
|
||||
<button type="button" className="ghost" onClick={closeDeleteModal} disabled={deleting}>
|
||||
Annuler
|
||||
</button>
|
||||
<button type="submit" className="danger-action" disabled={deleting || !deletePassword}>
|
||||
{deleting ? 'Suppression...' : 'Supprimer définitivement'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
deleteAccount,
|
||||
fetchLastOpenedTree,
|
||||
loginUser,
|
||||
logoutUser,
|
||||
@@ -143,6 +144,23 @@ export function useAuth() {
|
||||
setLastOpenedTree(null)
|
||||
}
|
||||
|
||||
// Renvoie null en cas de succès (session locale effacée), sinon un message d'erreur affichable.
|
||||
const handleDeleteAccount = async (password) => {
|
||||
const token = userAuth.token
|
||||
if (!token) {
|
||||
return errorMessages.getAccountDeletionErrorMessage({ status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteAccount(token, password)
|
||||
} catch (error) {
|
||||
return errorMessages.getAccountDeletionErrorMessage(error)
|
||||
}
|
||||
|
||||
clearStoredUserToken()
|
||||
return null
|
||||
}
|
||||
|
||||
const persistLastOpenedTree = async (payload) => {
|
||||
if (!userAuth.token) {
|
||||
return null
|
||||
@@ -168,6 +186,7 @@ export function useAuth() {
|
||||
handleLogin,
|
||||
handleRegister,
|
||||
handleLogout,
|
||||
handleDeleteAccount,
|
||||
restoreUserToken,
|
||||
restoreUserProfile: loadUserProfile,
|
||||
clearStoredUserToken,
|
||||
|
||||
@@ -14,6 +14,26 @@ export function getShareErrorMessage(error) {
|
||||
return 'Connexion backend impossible. Vérifiez API/DB puis réessayez.'
|
||||
}
|
||||
|
||||
export function getAccountDeletionErrorMessage(error) {
|
||||
if (error?.status === 403) {
|
||||
return 'Mot de passe incorrect.'
|
||||
}
|
||||
|
||||
if (error?.status === 401) {
|
||||
return 'Session expirée. Reconnectez-vous puis réessayez.'
|
||||
}
|
||||
|
||||
if (error?.status === 400) {
|
||||
return 'Saisissez votre mot de passe actuel.'
|
||||
}
|
||||
|
||||
if (error?.status === 429) {
|
||||
return 'Trop de tentatives. Réessayez dans une minute.'
|
||||
}
|
||||
|
||||
return 'Impossible de supprimer le compte pour le moment. Réessayez plus tard.'
|
||||
}
|
||||
|
||||
export function getAccountErrorMessage(error) {
|
||||
const errorCode = error?.payload?.error || error?.message || ''
|
||||
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getShareErrorMessage, getAccountErrorMessage } from './errorMessages'
|
||||
import { getShareErrorMessage, getAccountErrorMessage, getAccountDeletionErrorMessage } from './errorMessages'
|
||||
|
||||
describe('getAccountDeletionErrorMessage', () => {
|
||||
it('returns wrong password message for 403', () => {
|
||||
expect(getAccountDeletionErrorMessage({ status: 403 })).toBe('Mot de passe incorrect.')
|
||||
})
|
||||
|
||||
it('returns session message for 401', () => {
|
||||
expect(getAccountDeletionErrorMessage({ status: 401 })).toBe('Session expirée. Reconnectez-vous puis réessayez.')
|
||||
})
|
||||
|
||||
it('returns rate limit message for 429', () => {
|
||||
expect(getAccountDeletionErrorMessage({ status: 429 })).toBe('Trop de tentatives. Réessayez dans une minute.')
|
||||
})
|
||||
|
||||
it('returns default message for unknown errors', () => {
|
||||
expect(getAccountDeletionErrorMessage(null))
|
||||
.toBe('Impossible de supprimer le compte pour le moment. Réessayez plus tard.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getShareErrorMessage', () => {
|
||||
it('returns password message for 401', () => {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Parents à rattacher à un nouveau frère ou une nouvelle sœur de `personId`.
|
||||
* Un lien passant par une union compte pour ses deux partenaires, avec l'identifiant de l'union.
|
||||
*
|
||||
* @param {string} personId - Personne dont on ajoute un frère ou une sœur
|
||||
* @param {Array} filiations - Liens parent-enfant du graphe courant ({ childId, parentId, unionId })
|
||||
* @param {Array} unions - Unions du graphe courant ({ id, partner1Id, partner2Id })
|
||||
* @returns {Array<{ parentId: string, unionId: string|null }>}
|
||||
*/
|
||||
export function getSiblingParentLinks(personId, filiations, unions) {
|
||||
const childKey = String(personId)
|
||||
const links = []
|
||||
const seenParentIds = new Set()
|
||||
|
||||
const addParent = (parentId, unionId) => {
|
||||
if (parentId === null || parentId === undefined || parentId === '') return
|
||||
const key = String(parentId)
|
||||
if (seenParentIds.has(key)) return
|
||||
seenParentIds.add(key)
|
||||
links.push({ parentId: key, unionId: unionId ? String(unionId) : null })
|
||||
}
|
||||
|
||||
for (const filiation of filiations || []) {
|
||||
if (String(filiation.childId) !== childKey) continue
|
||||
|
||||
if (filiation.unionId) {
|
||||
const union = (unions || []).find((candidate) => String(candidate.id) === String(filiation.unionId))
|
||||
if (union) {
|
||||
addParent(union.partner1Id, union.id)
|
||||
addParent(union.partner2Id, union.id)
|
||||
}
|
||||
} else {
|
||||
addParent(filiation.parentId, null)
|
||||
}
|
||||
}
|
||||
|
||||
return links
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getSiblingParentLinks } from './familyLinks'
|
||||
|
||||
describe('getSiblingParentLinks', () => {
|
||||
const unions = [{ id: 'u1', partner1Id: 'p1', partner2Id: 'p2' }]
|
||||
|
||||
it('returns both partners of the union the person belongs to', () => {
|
||||
const filiations = [{ id: 'f1', childId: 'c1', parentId: null, unionId: 'u1' }]
|
||||
expect(getSiblingParentLinks('c1', filiations, unions)).toEqual([
|
||||
{ parentId: 'p1', unionId: 'u1' },
|
||||
{ parentId: 'p2', unionId: 'u1' },
|
||||
])
|
||||
})
|
||||
|
||||
it('returns direct parents without union', () => {
|
||||
const filiations = [{ id: 'f1', childId: 'c1', parentId: 'p3', unionId: null }]
|
||||
expect(getSiblingParentLinks('c1', filiations, unions)).toEqual([{ parentId: 'p3', unionId: null }])
|
||||
})
|
||||
|
||||
it('does not return the same parent twice', () => {
|
||||
const filiations = [
|
||||
{ id: 'f1', childId: 'c1', parentId: null, unionId: 'u1' },
|
||||
{ id: 'f2', childId: 'c1', parentId: 'p1', unionId: null },
|
||||
]
|
||||
expect(getSiblingParentLinks('c1', filiations, unions)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('returns an empty list when the person has no known parent', () => {
|
||||
const filiations = [{ id: 'f1', childId: 'other', parentId: 'p1', unionId: null }]
|
||||
expect(getSiblingParentLinks('c1', filiations, unions)).toEqual([])
|
||||
expect(getSiblingParentLinks('c1', undefined, undefined)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -90,9 +90,9 @@
|
||||
<ul>
|
||||
<li><strong>Consulter.</strong> Tout ce que Parenthèse sait de votre arbre est dans l'application.</li>
|
||||
<li><strong>Exporter.</strong> Paramètres de l'arbre, section Données, bouton « Télécharger .ged ». Le fichier GEDCOM est un standard que lisent les autres logiciels de généalogie. Il contient les personnes et leurs liens, pas les fichiers médias.</li>
|
||||
<li><strong>Supprimer un arbre.</strong> Paramètres de l'arbre, Paramètres avancés, « Supprimer cet arbre ». L'arbre disparaît aussitôt pour tout le monde, propriétaire compris. Ses données sont marquées supprimées en base, et les fichiers restent sur le disque jusqu'à un nettoyage manuel : pour un effacement définitif, sauvegardes comprises, écrivez-moi.</li>
|
||||
<li><strong>Supprimer votre compte.</strong> Ce n'est pas encore possible depuis l'application. Écrivez à <a href="mailto:pro.allali.ilies@gmail.com">pro.allali.ilies@gmail.com</a> depuis l'adresse du compte, je le supprime avec ses arbres.</li>
|
||||
<li><strong>Liste d'attente.</strong> Si vous vous étiez inscrit avant l'ouverture, votre email, votre prénom et la page d'origine sont conservés en base et chez Resend, le prestataire qui a envoyé la confirmation. Un email suffit pour les retirer.</li>
|
||||
<li><strong>Supprimer un arbre.</strong> Paramètres de l'arbre, Paramètres avancés, « Supprimer cet arbre ». L'arbre disparaît aussitôt pour tout le monde, propriétaire compris. Il est effacé définitivement : personnes, liens, médias, contributions et journal, en base comme sur le disque. Seules les sauvegardes le gardent encore, 14 jours au plus.</li>
|
||||
<li><strong>Supprimer votre compte.</strong> Écran « Votre compte », bouton « Supprimer mon compte », puis votre mot de passe pour confirmer. Votre compte et les arbres que vous avez créés sont effacés définitivement, avec leurs médias. Les arbres des autres familles auxquels vous aviez accès restent à leurs propriétaires : vos contributions y sont conservées, mais votre adresse email en est retirée et remplacée par « Contributeur ». Toutes vos sessions sont fermées, sur tous vos appareils.</li>
|
||||
<li><strong>Liste d'attente.</strong> Elle est fermée. Si vous vous étiez inscrit avant l'ouverture, votre email, votre prénom et la page d'origine sont encore conservés en base et chez Resend, le prestataire qui a envoyé la confirmation. Un email suffit pour les retirer.</li>
|
||||
<li><strong>Réclamation.</strong> Si vous estimez que vos droits ne sont pas respectés, vous pouvez saisir la <a href="https://www.cnil.fr/" rel="noopener">CNIL</a>.</li>
|
||||
</ul>
|
||||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env bash
|
||||
# Test de fumée d'une instance Parenthèse, par le port web uniquement, comme un vrai utilisateur.
|
||||
#
|
||||
# bash tools/docker-smoke.sh instance sur http://localhost
|
||||
# bash tools/docker-smoke.sh http://localhost:8080 si WEB_PORT=8080
|
||||
#
|
||||
# Vérifie : page d'accueil, /health (direct et via /api), création de compte, connexion,
|
||||
# création d'une galaxie, lecture de son graphe, puis suppression de la galaxie.
|
||||
# Le compte de test (smoke-...@example.com) reste en base : l'API n'a pas de route de suppression de compte.
|
||||
# Nécessite curl et jq. Code de sortie 0 si tout passe, 1 sinon.
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="${1:-${BASE_URL:-http://localhost}}"
|
||||
BASE_URL="${BASE_URL%/}"
|
||||
WAIT_SECONDS="${SMOKE_WAIT_SECONDS:-90}"
|
||||
|
||||
for cmd in curl jq; do
|
||||
if ! command -v "$cmd" >/dev/null 2>&1; then
|
||||
echo "Commande manquante : $cmd" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
WORK_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK_DIR"' EXIT
|
||||
BODY="$WORK_DIR/body"
|
||||
|
||||
fail() {
|
||||
echo "ÉCHEC : $*" >&2
|
||||
if [ -s "$BODY" ]; then
|
||||
echo "Réponse reçue :" >&2
|
||||
head -c 2000 "$BODY" >&2
|
||||
echo >&2
|
||||
fi
|
||||
exit 1
|
||||
}
|
||||
|
||||
ok() {
|
||||
echo "ok $*"
|
||||
}
|
||||
|
||||
# request MÉTHODE CHEMIN [CORPS_JSON] [JETON] : écrit le corps dans $BODY et affiche le code HTTP.
|
||||
request() {
|
||||
local method="$1" path="$2" body="${3:-}" token="${4:-}"
|
||||
local args=(-sS -o "$BODY" -w '%{http_code}' -X "$method" --max-time 30)
|
||||
if [ -n "$body" ]; then
|
||||
args+=(-H 'Content-Type: application/json' --data "$body")
|
||||
fi
|
||||
if [ -n "$token" ]; then
|
||||
args+=(-H "Authorization: Bearer $token")
|
||||
fi
|
||||
curl "${args[@]}" "$BASE_URL$path"
|
||||
}
|
||||
|
||||
expect_status() {
|
||||
local got="$1" expected="$2" label="$3"
|
||||
[ "$got" = "$expected" ] || fail "$label : HTTP $got, attendu $expected"
|
||||
}
|
||||
|
||||
echo "Instance testée : $BASE_URL"
|
||||
|
||||
# 0. Attente de l'API derrière nginx
|
||||
deadline=$((SECONDS + WAIT_SECONDS))
|
||||
until curl -fsS --max-time 5 -o /dev/null "$BASE_URL/health" 2>/dev/null; do
|
||||
if [ "$SECONDS" -ge "$deadline" ]; then
|
||||
: > "$BODY"
|
||||
fail "$BASE_URL/health ne répond pas après ${WAIT_SECONDS} s"
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# 1. Page d'accueil servie par nginx
|
||||
status="$(curl -sS -o "$BODY" -w '%{http_code} %{content_type}' --max-time 30 "$BASE_URL/")" \
|
||||
|| fail "page d'accueil injoignable"
|
||||
code="${status%% *}"
|
||||
content_type="${status#* }"
|
||||
expect_status "$code" 200 "GET /"
|
||||
case "$content_type" in
|
||||
text/html*) ;;
|
||||
*) fail "GET / : type $content_type, attendu text/html" ;;
|
||||
esac
|
||||
grep -q 'id="root"' "$BODY" || fail "GET / : la page ne contient pas l'application"
|
||||
ok "GET / (HTML)"
|
||||
|
||||
# 2. Santé de l'API, relayée telle quelle et via le préfixe /api retiré par nginx
|
||||
for path in /health /api/health; do
|
||||
code="$(request GET "$path")" || fail "$path injoignable"
|
||||
expect_status "$code" 200 "GET $path"
|
||||
jq -e '.ok == true' "$BODY" >/dev/null || fail "GET $path : réponse inattendue"
|
||||
ok "GET $path"
|
||||
done
|
||||
|
||||
# 3. Création de compte
|
||||
stamp="$(date +%s)-$RANDOM"
|
||||
email="smoke-$stamp@example.com"
|
||||
password="smoke-$RANDOM$RANDOM$RANDOM"
|
||||
credentials="$(jq -nc --arg email "$email" --arg password "$password" \
|
||||
'{firstName: "Test", email: $email, password: $password}')"
|
||||
|
||||
code="$(request POST /auth/register "$credentials")" || fail "POST /auth/register injoignable"
|
||||
expect_status "$code" 201 "POST /auth/register"
|
||||
jq -e '(.token | type == "string" and length > 0) and .user.email == $email' --arg email "$email" "$BODY" >/dev/null \
|
||||
|| fail "POST /auth/register : jeton ou utilisateur absent"
|
||||
ok "POST /auth/register ($email)"
|
||||
|
||||
# 4. Connexion
|
||||
code="$(request POST /auth/login "$credentials")" || fail "POST /auth/login injoignable"
|
||||
expect_status "$code" 200 "POST /auth/login"
|
||||
token="$(jq -r '.token // empty' "$BODY")"
|
||||
[ -n "$token" ] || fail "POST /auth/login : jeton absent"
|
||||
ok "POST /auth/login"
|
||||
|
||||
# 5. Création d'une galaxie
|
||||
tree_payload="$(jq -nc --arg slug "smoke-$stamp" \
|
||||
'{name: "Galaxie de test", slug: $slug, visitorPassword: "visiteur-test-1", contributorPassword: "contributeur-test-1"}')"
|
||||
code="$(request POST /trees "$tree_payload" "$token")" || fail "POST /trees injoignable"
|
||||
expect_status "$code" 201 "POST /trees"
|
||||
tree_id="$(jq -r '.tree.id // empty' "$BODY")"
|
||||
[ -n "$tree_id" ] || fail "POST /trees : identifiant absent"
|
||||
ok "POST /trees ($tree_id)"
|
||||
|
||||
# 6. Lecture du graphe
|
||||
code="$(request GET "/trees/$tree_id/graph" "" "$token")" || fail "GET /trees/:id/graph injoignable"
|
||||
expect_status "$code" 200 "GET /trees/:id/graph"
|
||||
jq -e --arg id "$tree_id" '.treeId == $id and (.graph.persons | type == "array")' "$BODY" >/dev/null \
|
||||
|| fail "GET /trees/:id/graph : graphe inattendu"
|
||||
ok "GET /trees/:id/graph"
|
||||
|
||||
# 7. Suppression de la galaxie de test
|
||||
code="$(request DELETE "/trees/$tree_id" "" "$token")" || fail "DELETE /trees/:id injoignable"
|
||||
expect_status "$code" 200 "DELETE /trees/:id"
|
||||
ok "DELETE /trees/:id"
|
||||
|
||||
echo "Instance opérationnelle."
|
||||
@@ -208,13 +208,21 @@ const graph = await api(`/trees/${treeId}/graph`, { token: owner.token })
|
||||
const applied = (graph.graph?.persons || []).some(p => p.firstName === 'Directe')
|
||||
check('8. contribution appliquée directement et visible dans l arbre', directSession.status === 'approved' && applied, `${directSession.status}, ${(graph.graph?.persons || []).length} personne(s)`)
|
||||
|
||||
// ---------- Nettoyage : l'arbre de test est supprimé (les deux comptes de test restent, pas de route de suppression) ----------
|
||||
// ---------- Nettoyage : les deux comptes de test sont supprimés, l'arbre du propriétaire part avec le sien ----------
|
||||
if (process.env.CLEANUP === '1') {
|
||||
for (const [label, account, token] of [['famille', FAMILY, familyLogin.token], ['propriétaire', OWNER, owner.token]]) {
|
||||
try {
|
||||
await api('/auth/me', { method: 'DELETE', token, body: { password: account.password } })
|
||||
check(`9. compte ${label} supprimé`, true)
|
||||
} catch (err) {
|
||||
check(`9. compte ${label} supprimé`, false, String(err.message).slice(0, 120))
|
||||
}
|
||||
}
|
||||
try {
|
||||
await api(`/trees/${treeId}`, { method: 'DELETE', token: owner.token })
|
||||
check('9. arbre de test supprimé', true, `comptes restants : ${OWNER.email}, ${FAMILY.email}`)
|
||||
} catch (err) {
|
||||
check('9. arbre de test supprimé', false, String(err.message).slice(0, 120))
|
||||
await api(`/trees/${treeId}/graph`, { token: owner.token })
|
||||
check('9. arbre de test inaccessible', false, 'le graphe répond encore')
|
||||
} catch {
|
||||
check('9. arbre de test inaccessible', true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user