- TypeScript 58.3%
- Svelte 40.7%
- CSS 0.3%
- PowerShell 0.3%
- Liquid 0.1%
`process.env.npm_package_version` is only set when the process is spawned through a package-manager script, so it was undefined in the Docker image (which runs `node dist/main.js` directly) and `GET /` advertised the `0.0.0` fallback in production. Read the version from the package manifest on disk instead and make it the single source of truth for both `GET /` and the Swagger document, which duplicated it as a hardcoded literal. |
||
|---|---|---|
| .claude | ||
| .dev | ||
| .forgejo/workflows | ||
| .vscode | ||
| apps | ||
| .dockerignore | ||
| .env.example | ||
| .gitattributes | ||
| .gitignore | ||
| .mcp.json | ||
| biome.json | ||
| CLAUDE.md | ||
| docker-compose.yml | ||
| Dockerfile | ||
| package.json | ||
| pnpm-lock.yaml | ||
| pnpm-workspace.yaml | ||
| README.md | ||
exKey
Monorepo contenant l'API et l'interface admin du projet exKey.
exkey/
├── apps/
│ ├── api/ # NestJS 11 (Swagger, Drizzle ORM, PostgreSQL, Brevo API)
│ ├── admin/ # SvelteKit 2 + Svelte 5 + Tailwind v4 + Paraglide i18n
│ └── widgets/ # widgets de reservation embarquables (Svelte 5, bundles IIFE servis par l'API)
├── .claude/ # configuration des agents IA (rules / skills / memory)
├── .forgejo/ # workflows CI Forgejo Actions
└── docker-compose.yml
Pour les conventions internes (style, archi, i18n, naming DB…), voir CLAUDE.md et le dossier .claude/.
Prerequis
- Node 22 LTS (ou superieur)
- pnpm 10 (
corepack enable && corepack prepare pnpm@10.4.1 --activate) - PostgreSQL 18 local pour le mode dev (ou Docker uniquement)
- Docker + Docker Compose v2 pour la stack complete
- Un compte Brevo avec une cle API (dashboard Brevo > SMTP & API > Cles API) - necessaire des le premier demarrage car le wizard de setup envoie un email de verification via l'API Brevo
Quickstart - mode dev natif (Postgres local)
-
Sur ton PostgreSQL local, cree le user et la base :
CREATE USER exkey WITH PASSWORD 'exkey'; CREATE DATABASE exkey OWNER exkey; -
Installe les dependances depuis la racine :
pnpm install -
Copie les fichiers d'environnement :
cp .env.example .env cp apps/api/.env.example apps/api/.env cp apps/admin/.env.example apps/admin/.envRenseigne au minimum
BREVO_API_KEY/MAILER_ADDRESS_FROMdans.env(ouapps/api/.env). -
Lance API + Admin + widgets en parallele :
pnpm devLes migrations Drizzle s'appliquent automatiquement au demarrage de l'API (un advisory lock Postgres evite les courses si plusieurs instances bootent en meme temps). Pas besoin de
pnpm db:migrateau premier lancement.Cote admin, le premier chargement compile le graphe de modules a la demande : le shell de l'app est pre-compile des le demarrage du dev server (ligne
🔥 module graph warmed up), et toute page qui met plus de 2 s a repondre logge sa progression dans le terminal (⏳ compiling /page… 4s (717 modules so far)puis✅ /page served in 7.4s). Pas de log = chargement instantane.
URLs disponibles :
- Admin : http://localhost:5173
- API : http://localhost:3000
- Swagger : http://localhost:3000/docs
- Liveness probe : http://localhost:3000/health
Au premier acces a l'admin, tu es redirige automatiquement vers
/setuppour creer le tenant, le premier etablissement et le compte owner. Tant que le setup n'est pas valide, toutes les routes redirigent vers le wizard.
Quickstart - stack complete via Docker Compose
pnpm compose:up
Le script builde d'abord les images exkey-api:latest / exkey-admin:latest depuis le Dockerfile racine (pnpm compose:build), puis lance docker compose up. Le docker-compose.yml ne contient volontairement aucune section build: (voir la section deploiement ci-dessous), donc un docker compose up --build seul ne builderait rien.
Cela demarre 3 conteneurs :
exkey-db- PostgreSQL 18 (port hote5433par defaut pour ne pas entrer en conflit avec un Postgres local)exkey-api- NestJS sur3000exkey-admin- SvelteKit (adapter-node) sur5173
Pour personnaliser les ports / credentials / SMTP, copie .env.example en .env a la racine et adapte les valeurs.
Deployer avec les images du registre (sans build local)
La CI pousse les images exkey-api / exkey-admin sur le registre OCI a chaque push sur main (tag dev) et a chaque release (latest + tag de version).
Le docker-compose.yml est pull-only (aucune section build:) : compose tire les images du registre meme quand la commande contient --build. C'est ce qui permet de le coller tel quel comme source compose « raw » sur une plateforme de deploiement (type Dokploy) qui impose docker compose up -d --build.
-
Plateforme de deploiement (source compose « raw ») : coller le contenu de
docker-compose.yml, puis definir dans l'environnement du projetAPI_IMAGE,ADMIN_IMAGE,IMAGE_TAG(voir.env.example) et les autres variables requises (BETTER_AUTH_SECRET,POSTGRES_PASSWORD, etc.). La plateforme doit etre authentifiee sur le registre pour pouvoir pull.Pour exposer la stack sur internet, creer un domaine par service dans la plateforme (ex.
admin.mondomaine.tld→ serviceadmin,api.mondomaine.tld→ serviceapi), en pointant chaque domaine sur le port conteneur 3000 (les deux apps ecoutent sur 3000 dans leur conteneur). Les URLs publiques n'ont pas de port : le reverse proxy de la plateforme termine le HTTPS sur 443. Definir ensuite dans l'environnement :ADMIN_URL=https://admin.mondomaine.tld API_PUBLIC_URL=https://api.mondomaine.tld TRUST_PROXY=trueLe compose en derive
ORIGIN/PUBLIC_API_URL(admin) et les defaults Better Auth (api). Les ports hotesAPI_HOST_PORT/ADMIN_HOST_PORTne servent qu'a l'acces directip:port(debug) et sont independants des domaines - sur un serveur ou le port 3000 est deja pris (UI de la plateforme...), les changer ou ignorer l'acces direct. -
Serveur avec clone du depot :
docker login <registre>
docker compose pull api admin
docker compose up -d
latest et dev etant des tags mouvants, relance docker compose pull avant chaque up pour recuperer la derniere image.
Premier demarrage : wizard de setup
L'app n'expose aucune table metier seedee. Au premier acces, le layout admin probe l'API (GET /setup/status) et oriente l'utilisateur selon une logique tri-etat :
- API injoignable → page
/unavailable(avec message d'erreur et bouton "reessayer"). - API OK + non initialisee → wizard
/setup. - API OK + initialisee → page
/login(l'authentification reelle arrivera dans une iteration suivante).
Le wizard execute trois pre-checks avant de soumettre :
GET /health- l'API repond ;GET /setup/check/database-SELECT 1+ latence ;POST /setup/check/smtp- envoi reel d'un email de test via le relai configure.
A la soumission, POST /setup cree dans une transaction exkey_tenants, exkey_establishments et l'enregistrement owner dans exkey_users (mot de passe hash en argon2id). Le tenant est singleton au niveau base via un index unique partiel - toute reinitialisation se fait en truncant les tables setup (voir apps/api/scripts/reset-setup.mjs).
Donnees de demonstration (optionnel)
Si l'API demarre avec SETUP_ALLOW_DEMO_DATA=true, le wizard affiche un switch
« Injecter des donnees de demonstration » (desactive par defaut). Une fois coche,
le setup injecte apres sa reussite un jeu de donnees d'exemple : 4 etablissements
panaches (modules varies), leurs chambres / restaurants / spa / navettes /
materiels de location avec photos placeholder, 20 clients et 28 avis clients
(repondus pour partie, 2 bloques pour illustrer la moderation) - sans
reservations ni factures. Le seed vit
dans apps/api/src/setup/demo-data/ (UUIDs
deterministes dd0000…, identifiables via WHERE id::text LIKE 'dd0000%').
L'injection n'est possible que pendant le setup : une fois l'instance
initialisee, POST /setup repond 409 et aucun autre point d'entree (CLI ou
endpoint) n'existe. Flag off ou switch decoche → l'application reste vierge de
toute donnee de demo.
Jobs en arriere-plan & metriques
L'API embarque pg-boss pour exécuter des
tâches hors du cycle HTTP. pg-boss tourne sur la même base PostgreSQL (via
DATABASE_URL) mais dans un schéma dédié pgboss auto-créé/migré au
démarrage - il n'interfère pas avec les migrations Drizzle et postgres.js reste
le driver applicatif.
- File
email.send: l'envoi d'email (ex. invitations) est désormais enfilé puis drainé par un worker, avec retry automatique (5 essais, backoff). La requête HTTP ne bloque plus sur le relai SMTP. - Métriques : un job planifié (
metrics.snapshot, toutes les 15 min par défaut) enregistre les compteurs de file dansexkey_metric_samples; un job quotidien (metrics.prune) purge les points au-delà deMETRICS_RETENTION_DAYS. - Endpoints owner-only :
GET /queue/stats(compteurs live) etGET /metrics/series?metric=...&bucket=hour(historique agrégé). - Dashboard : la page d'accueil admin affiche, pour l'owner, des cartes KPI et un graphique (shadcn-svelte / LayerChart) du backlog email.
- Arrêt gracieux :
app.enableShutdownHooks()laisse pg-boss terminer les jobs en cours surSIGTERM/SIGINT.
Scripts racine
| Commande | Description |
|---|---|
pnpm dev |
Lance API + Admin en watch + bundles widgets (watch) + playground |
pnpm widgets |
Watch des bundles widgets seul (sans le reste de la stack) |
pnpm build |
Build toutes les apps |
pnpm lint |
Lint Biome (verification) |
pnpm lint:fix |
Lint Biome avec auto-fix |
pnpm format |
Formattage Biome |
pnpm test |
Tests toutes les apps |
pnpm db:generate |
Genere une migration Drizzle depuis src/db/schema.ts |
pnpm db:migrate |
Applique les migrations en attente (rarement utile : auto au boot) |
pnpm compose:up |
Build + demarre la stack Docker |
pnpm compose:down |
Arrete la stack Docker |
Stack technique
API (apps/api)
- NestJS 11, TypeScript, ESM via
nest build - Swagger / OpenAPI 3 sur
/docs - Drizzle ORM 0.45 + driver
postgres(postgres.js) - Migrations Drizzle Kit, appliquees automatiquement au demarrage (advisory lock)
- Validation Zod 4 (env + DTO)
class-validator/class-transformerpour les DTO Nest- Hash de mot de passe via
@node-rs/argon2(argon2id) - Emails transactionnels via l'API REST Brevo (
BREVO_API_KEY) - Jobs en arrière-plan + planification via
pg-boss(file sur PostgreSQL, schémapgbossdédié) - Métriques persistées (séries temporelles) dans
exkey_metric_samples - Recherche globale :
GET /api/search(session uniquement) - requêtesILIKEmulti-établissements, résultats groupés par type d'entité (registre extensible danssrc/search/searchers.ts) - Healthchecks via
@nestjs/terminus
Admin (apps/admin)
- SvelteKit 2 + Svelte 5 (runes), adapter-node, Vite
- Tailwind CSS v4 (plugin Vite officiel) +
@tailwindcss/forms+@tailwindcss/typography - Composants UI :
shadcn-svelte+bits-ui+formsnap+sveltekit-superforms - Toasts :
svelte-sonner - Theme clair/sombre :
mode-watcher - Icones :
@lucide/svelte - i18n : Paraglide JS (locales
en/fr/de/es/it/pt, detection cookie + Accept-Language, runtime compile danssrc/lib/paraglide/) - Recherche globale Ctrl+K / Cmd+K : palette centrée (loupe dans l'en-tête) avec recherche live sur les réservations, clients, bons cadeaux, produits, chambres, restaurants, prestations spa, navettes, matériels de location, widgets, établissements, pages et documentation (docs proposées seulement si le réglage
interface.show_documentationest actif) ; bascule automatique d'établissement au clic (registre extensible danssrc/lib/search/registry.ts) - Tableau de bord en deux onglets : Aujourd'hui (arrivées/départs, clients présents, occupation ce soir, paiements en attente, volumes du jour des modules activés) et Performance, le comparatif période vs période - préréglages (7 j, 30 j, mois en cours, mois dernier, année) ou plage libre, bascule « vs période précédente » / « vs même période N-1 », portée établissement courant ou tous les établissements (totaux + ventilation par établissement, montants totalisés seulement à devise commune), graphique en superposition CA/réservations. Les agrégats sont calculés à la volée côté API (
GET /establishments/:id/bookings/stats/compareetGET /bookings/stats/compare) : CA des réservations confirmées/terminées à la date de confirmation, bons cadeaux comptés à la date d'émission (séparés du CA réservations), état de la vue conservé dans l'URL - Module Locations (location de matériel : skis, vélos, bateaux...) : chaque fiche décrit un matériel générique dont les déclinaisons sont des variantes portant le stock, avec une unité de location fixée à la création (heure, demi-journée, journée ou nuit). Le tarif de base par unité peut être complété par des tarifs saisonniers réutilisant les saisons de l'établissement (gérées dans l'onglet « Saisons » de sa page de paramètres) ; le tout est intégré au tunnel de réservation admin (étape « Locations » du wizard, prix figé à l'ajout)
- Avis clients (Webmarketing > Avis clients) : notes 1-5 étoiles + commentaire, ciblant l'établissement ou un service (chambres, restaurant, spa, navettes, locations). Note globale et moyennes par service au-dessus du tableau (filtres sujet/note/réponse/modération), réponse unique et définitive de l'établissement (rôle gestionnaire, verrouillée côté API), modération réversible (avis bloqué = exclu de toutes les moyennes et du futur affichage public). La fiche client (onglets Profil / Préférences / Statistiques / Réservations / Avis) reprend les avis du client, et le tableau de bord affiche la note moyenne de chaque établissement. Le dépôt d'avis public (widget post-séjour) viendra plus tard
- Validation : Zod 4
Widgets embarquables (apps/widgets)
- Trois widgets de réservation rapide à intégrer sur des sites web tiers : Hôtel - Quick Search (dispo chambres par plage de dates + personnes, meilleur prix affiché à côté du bouton de recherche), Navette (date + passagers) et Restaurant (restaurant + couverts + date + heure), chacun conditionné au module correspondant de l'établissement ; formulaire de recherche sur une seule ligne dès que la place le permet
- Une soumission crée une vraie réservation en statut
pending_approval(canalwidget) qui bloque l'inventaire ; le staff l'accepte ou la refuse depuis l'admin (notification + emails invité localisés en 6 langues) ; expiration automatique via le réglagebookings.approval_ttl_hours - Bundles IIFE autonomes (Svelte 5 + Tailwind v4 + shadcn-svelte, CSS inliné) buildés dans
apps/widgets/dist/v1/et servis par l'API sous/public/widgets/v1/<type>.js(URL stable, ACAO*) ;pnpm devles builde et les garde en watch automatiquement (one-shot :pnpm --filter @exkey/widgets build) - Intégration en collant deux lignes (div + script) ; deux modes par widget : Shadow DOM (recommandé) ou iframe (page frame hébergée par l'API, hauteur auto via postMessage)
- Personnalisation par widget depuis l'admin (couleurs, arrondi, ombre, bordure, sélecteur de dates) appliquée sans re-copier le code ; overrides par page via data-attributes (
data-lang,data-primary-color,data-embed-mode, …) exposés dans l'éditeur par l'interrupteur « Inclure les options par page », pré-remplis en direct avec les réglages du formulaire (attribut vide = réglage enregistré, valides en shadow comme en iframe) - Langues servies = section Langues de l'établissement (sous-ensemble des 6 locales + langue par défaut)
- Surface publique anonyme
/api/public/widgets/:publicKey/*(config, disponibilités avec restrictions de vente appliquées, soumission) - clé publique 192 bits, rate limiting en mémoire, CORS ouvert sans credentials ; une clé de prévisualisation admin (?preview=wgtp_..., exposée uniquement par l'API authentifiée) débloque les lectures d'un widget en pause pour l'aperçu de l'éditeur, jamais la soumission - Playground de dev : démarré par
pnpm dev(port 5174, page hôte volontairement hostile) ; seul :pnpm --filter @exkey/widgets playground
Tooling
- pnpm workspaces (Node 22 LTS)
- Biome 2 (lint + format unifies pour tout le repo)
- Docker multi-stage avec cache pnpm
- CI : Forgejo Actions (lint + build + push registre OCI)
Variables d'environnement
.env racine
Consomme par docker-compose.yml et par l'API en mode dev natif (via dotenv en cascade). Voir .env.example pour la liste complete.
# PostgreSQL
POSTGRES_USER=exkey
POSTGRES_PASSWORD=exkey
POSTGRES_DB=exkey
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_SSL=false
POSTGRES_HOST_PORT=5433 # port hote du conteneur db
API_HOST_PORT=3000
ADMIN_HOST_PORT=5173
# Medias uploades (stockage disque local, servis sous /uploads)
UPLOAD_DIR=./var/uploads # volume monte (/app/uploads) en Docker
UPLOAD_MAX_FILE_SIZE_MB=10
# Donnees de demo au setup : si true, le wizard /setup propose un switch
# "Injecter des donnees de demonstration". A laisser false en production.
SETUP_ALLOW_DEMO_DATA=false
# Emails transactionnels (API REST Brevo)
BREVO_API_KEY=
MAILER_ADDRESS_FROM="exKey <no-reply@example.com>"
apps/api/.env
Surcharges propres a l'API. La connexion Postgres accepte soit DATABASE_URL (prioritaire), soit les variables POSTGRES_* separees.
NODE_ENV=development
PORT=3000
# DATABASE_URL=postgres://exkey:exkey@localhost:5432/exkey
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_USER=exkey
POSTGRES_PASSWORD=exkey
POSTGRES_DB=exkey
POSTGRES_SSL=false
# Jobs en arriere-plan (pg-boss) - valeurs par defaut, override optionnel
PGBOSS_SCHEMA=pgboss
PGBOSS_MAX=5
# Metriques (exkey_metric_samples)
METRICS_SNAPSHOT_CRON=*/15 * * * *
METRICS_RETENTION_DAYS=180
# Medias uploades (stockage disque local, servis sous /uploads)
UPLOAD_DIR=./var/uploads
UPLOAD_MAX_FILE_SIZE_MB=10
# Donnees de demo au setup (switch visible sur /setup uniquement si true)
SETUP_ALLOW_DEMO_DATA=false
# Emails transactionnels (override possible par rapport au .env racine)
BREVO_API_KEY=
MAILER_ADDRESS_FROM="exKey <no-reply@example.com>"
apps/admin/.env
PUBLIC_API_URL=http://localhost:3000
Schema base de donnees
Toutes les tables et enums crees par l'app sont prefixes exkey_ et utilisent le pluriel snake_case (cf. .claude/rules/db-naming.md).
Tables actuellement gerees (apps/api/src/db/schema/) :
exkey_tenants- singleton via index unique partielexkey_tenants_singleton_idxexkey_establishments- etablissements rattaches a un tenantexkey_users- comptes utilisateurs (mot de passe hashe en argon2id)exkey_establishment_memberships- pivot user <-> etablissement avec roleexkey_metric_samples- séries temporelles génériques (métriques du dashboard)exkey_app_settings- paramètres applicatifs regroupés par catégorie (page admin Paramètres, owner/admin ; le réglageinterface.show_documentationmasque la documentation de la sidebar et de la recherche globale)exkey_widgets- widgets de réservation embarquables (type, thème jsonb, clé publique, mode d'intégration)exkey_reviews- avis clients (note 1-5, sujet, réponse unique verrouillée, modération réversible viahidden_at)- enum
exkey_user_role(owner,admin,user) - enum
exkey_app_setting_categories(bookings,payments,emails,interface)
Le schéma
pgboss(tables de la file de jobs) est géré automatiquement par pg-boss et n'apparaît pas dansapps/api/src/db/schema/ni dans les migrations Drizzle.
Pour reinitialiser le setup en local :
node apps/api/scripts/reset-setup.mjs
Cette commande tronque les 4 tables setup en cascade. A ne jamais utiliser hors developpement.
CI Forgejo
Le workflow .forgejo/workflows/ci.yml tourne sur un runner self-hosted labelise runner09 (runs-on: runner09).
Declencheurs et etapes
- PR vers
main:pnpm install+pnpm -r build(pas de push d'image). - Push sur
main: lint/build, puis build + push des deux images Docker. - Release publiee (
release: { types: [published] }) : lint/build, puis build + push des deux images Docker.
Strategie de tags Docker
| Evenement | Tags pousses sur exkey-api et exkey-admin |
|---|---|
push sur main (commit simple) |
dev |
release publiee (tag ex. v1.2.3) |
latest et v1.2.3 (le nom du tag de release) |
Concretement :
- Un commit sur
mainecrase l'image:dev=> image "rolling" de la branche principale. - Une release ecrase
:latest(= derniere version stable) et publie egalement une image immuable taguee avec la version (:v1.2.3), pour pouvoir revenir en arriere ou epingler precisement une version en prod.
Secrets/variables a definir dans Forgejo
vars.FORGEJO_REGISTRY- ex :forgejo.example.comvars.FORGEJO_OWNER- ex :exkeysecrets.FORGEJO_USERNAMEsecrets.FORGEJO_TOKEN
Le runner Forgejo doit etre demarre avec le label
runner09(option--labels runner09:dockerou equivalent dansconfig.yml) et supporter l'execution de jobs en container (node:22-alpineest utilise pour le job de lint/build).
Hors-scope (pour l'instant)
- Pas d'authentification fonctionnelle (le compte owner est cree mais la page
/loginest un squelette) - CORS permissif (origin: true, credentials: true) - Pas de tests automatises ecrits
- Pas de
packages/shared(a extraire plus tard si besoin) - Pas de table metier au-dela des entites du setup et de la table de metriques (
exkey_metric_samples)