L'application initialise la base elle-même : plus de scripts montés
Résout définitivement « Role sbc_app does not exist » : l'initialisation ne dépend plus de docker-entrypoint-initdb.d ni de bind mounts du dépôt (fragiles selon le mode de déploiement Portainer). - Nouveau bootstrap idempotent exécuté par l'app à chaque démarrage avec le superuser (PG_SUPERUSER_PASSWORD) : création de la base et du rôle sbc_app si absents, réalignement du mot de passe, schéma en CREATE ... IF NOT EXISTS, données de démo si la base est vide, grants - Schéma et seed embarqués dans l'image (server/src/sql), seed par recherche de noms (indépendant des séquences) - Compose réduit à deux services (db + app), suppression de db-sync et du montage ./db/init ; répertoire db/ retiré Vérifié : volume totalement vierge auto-initialisé, redémarrage sans re-seed, changement d'APP_DB_PASSWORD auto-réparé, 34 tests e2e verts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fkg15RCxNgUys4ru73He2o
This commit is contained in:
10 files changed
+181
-149
No files matched your search
@@ -12,7 +12,7 @@ Deux conteneurs orchestrés par Docker Compose, sur des ports peu utilisés
|
|||||||
| Service | Rôle | Port hôte |
|
| Service | Rôle | Port hôte |
|
||||||
|---------|------|-----------|
|
|---------|------|-----------|
|
||||||
| `app` | Node.js 22 / Express : API REST **et** frontend React compilé **et** images `/uploads` | `8321` (`APP_PORT`) |
|
| `app` | Node.js 22 / Express : API REST **et** frontend React compilé **et** images `/uploads` | `8321` (`APP_PORT`) |
|
||||||
| `db` | PostgreSQL 16 (schéma + données de démo au premier démarrage) | `127.0.0.1:58412` (`DB_PORT`) — loopback uniquement, pour l'administration locale |
|
| `db` | PostgreSQL 16 (vierge : l'application l'initialise elle-même) | `127.0.0.1:58412` (`DB_PORT`) — loopback uniquement, pour l'administration locale |
|
||||||
|
|
||||||
```
|
```
|
||||||
Navigateur ──> app (Express :8321) ──> db (PostgreSQL, 127.0.0.1:58412)
|
Navigateur ──> app (Express :8321) ──> db (PostgreSQL, 127.0.0.1:58412)
|
||||||
@@ -44,8 +44,9 @@ réseau du proxy.
|
|||||||
(`SLUC Business Club.dc.html`).
|
(`SLUC Business Club.dc.html`).
|
||||||
- **Backend** : Express, `pg` (requêtes paramétrées), `zod` (validation), `bcryptjs`
|
- **Backend** : Express, `pg` (requêtes paramétrées), `zod` (validation), `bcryptjs`
|
||||||
(hachage), JWT en cookie httpOnly, `helmet`, `express-rate-limit`, `multer` (uploads).
|
(hachage), JWT en cookie httpOnly, `helmet`, `express-rate-limit`, `multer` (uploads).
|
||||||
- **Base** : PostgreSQL 16, initialisée par `db/init/` (rôle applicatif restreint,
|
- **Base** : PostgreSQL 16. L'application l'initialise **elle-même à chaque
|
||||||
schéma, données de démonstration).
|
démarrage**, de façon idempotente (base, rôle restreint `sbc_app`, schéma,
|
||||||
|
données de démo) : aucun script monté, aucun état de volume requis.
|
||||||
|
|
||||||
## Démarrage
|
## Démarrage
|
||||||
|
|
||||||
@@ -137,11 +138,11 @@ formulaire « Mot de passe », endpoint `POST /api/auth/change-password`).
|
|||||||
|
|
||||||
## Dépannage
|
## Dépannage
|
||||||
|
|
||||||
- **`password authentication failed for user "sbc_app"`** : le volume de la base
|
- **`password authentication failed for user "sbc_app"`** : impossible dans la
|
||||||
a été initialisé avec un autre `APP_DB_PASSWORD`. Le service one-shot `db-sync`
|
version courante tant que `PG_SUPERUSER_PASSWORD` correspond au mot de passe
|
||||||
resynchronise automatiquement le mot de passe du rôle à chaque démarrage de la
|
superuser du volume — l'application recrée/realigne le rôle et le schéma à
|
||||||
stack — redéployez simplement. Si le mot de passe **superuser** a lui aussi
|
chaque démarrage. Si le mot de passe **superuser** du volume diffère de
|
||||||
changé (`POSTGRES_PASSWORD`), remettez l'ancienne valeur ou supprimez le volume
|
`POSTGRES_PASSWORD`, remettez l'ancienne valeur ou supprimez le volume
|
||||||
`db_data` pour repartir de zéro (les données de démo seront recréées).
|
`db_data` pour repartir de zéro (les données de démo seront recréées).
|
||||||
- **Port déjà utilisé** : changez `APP_PORT` ou `DB_PORT` dans les variables
|
- **Port déjà utilisé** : changez `APP_PORT` ou `DB_PORT` dans les variables
|
||||||
d'environnement.
|
d'environnement.
|
||||||
@@ -170,7 +171,6 @@ cd web && npm install && npm run dev # proxy /api → localhost:8321
|
|||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
├── Dockerfile # multi-étages : build React → dépendances API → image finale
|
├── Dockerfile # multi-étages : build React → dépendances API → image finale
|
||||||
├── .env.example
|
├── .env.example
|
||||||
├── db/init/ # 01 rôle applicatif · 02 schéma · 03 données de démo
|
├── server/ # API Express (src/routes, src/sql = schéma + seed, uploads)
|
||||||
├── server/ # API Express (src/routes, src/middleware, uploads, statique)
|
|
||||||
└── web/ # sources React + Vite (compilées dans l'image)
|
└── web/ # sources React + Vite (compilées dans l'image)
|
||||||
```
|
```
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Creates the restricted application role the API connects with.
|
|
||||||
# Runs once, on first initialization of the data volume.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Escape single quotes so an exotic password cannot break the SQL literal
|
|
||||||
ESCAPED_PASSWORD="${APP_DB_PASSWORD//\'/\'\'}"
|
|
||||||
|
|
||||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
|
|
||||||
CREATE ROLE sbc_app LOGIN PASSWORD '${ESCAPED_PASSWORD}' NOSUPERUSER NOCREATEDB NOCREATEROLE;
|
|
||||||
EOSQL
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
-- SLUC Business Club — demo seed data (content from the design mock)
|
|
||||||
-- User passwords are NOT stored here: accounts are created with the
|
|
||||||
-- unusable placeholder hash '*seed*' and receive their real (bcrypt)
|
|
||||||
-- password from the API at startup, based on ADMIN_INITIAL_PASSWORD /
|
|
||||||
-- MEMBER_INITIAL_PASSWORD environment variables.
|
|
||||||
|
|
||||||
INSERT INTO categories (name) VALUES
|
|
||||||
('Conseil & Finance'),
|
|
||||||
('Industrie & BTP'),
|
|
||||||
('Communication & Digital'),
|
|
||||||
('Services'),
|
|
||||||
('Santé');
|
|
||||||
|
|
||||||
INSERT INTO members (nom, secteur, categorie_id, dirigeant, adhesion, email, tel, site, presentation, valide) VALUES
|
|
||||||
('Lorraine Assurances', 'Assurance & Prévoyance', 1, 'Sophie Marchand', 2014, 'contact@lorraine-assurances.fr', '+33 3 83 12 45 60', 'lorraine-assurances.fr', 'Cabinet de courtage indépendant accompagnant les entreprises et dirigeants du Grand Est sur l''ensemble de leurs besoins en assurance et prévoyance.', true),
|
|
||||||
('Groupe Batigest', 'BTP & Construction', 2, 'Philippe Aubry', 2009, 'contact@batigest.fr', '+33 3 83 22 18 04', 'groupe-batigest.fr', 'Entreprise générale de construction et de rénovation, acteur majeur du bâtiment en Lorraine depuis plus de trente ans.', true),
|
|
||||||
('Moselle Digital', 'Agence digitale', 3, 'Karim Benali', 2019, 'hello@moselle-digital.fr', '+33 3 83 55 71 22', 'moselle-digital.fr', 'Agence de communication digitale spécialisée dans la création de sites, le référencement et les stratégies social media pour les PME.', true),
|
|
||||||
('Renard & Associés', 'Cabinet d''avocats', 1, 'Me Claire Renard', 2012, 'contact@renard-avocats.fr', '+33 3 83 30 09 15', 'renard-avocats.fr', 'Cabinet d''avocats d''affaires conseillant dirigeants et entreprises en droit des sociétés, droit social et droit commercial.', true),
|
|
||||||
('NancyTech Solutions', 'Solutions informatiques', 3, 'Thomas Villeret', 2020, 'contact@nancytech.fr', '+33 3 83 41 88 90', 'nancytech.fr', 'Infogérance, cybersécurité et déploiement d''outils métiers pour accompagner la transformation numérique des entreprises régionales.', true),
|
|
||||||
('Est Immobilier', 'Immobilier d''entreprise', 4, 'Nathalie Perrin', 2016, 'contact@est-immobilier.fr', '+33 3 83 17 62 30', 'est-immobilier.fr', 'Conseil en immobilier d''entreprise : bureaux, locaux commerciaux et investissement sur l''agglomération nancéienne.', true),
|
|
||||||
('Vosges Logistique', 'Transport & Logistique', 4, 'Bruno Kieffer', 2011, 'contact@vosges-logistique.fr', '+33 3 29 34 12 78', 'vosges-logistique.fr', 'Solutions de transport, entreposage et distribution au service des industriels et distributeurs du Grand Est.', false),
|
|
||||||
('Meurthe Industries', 'Industrie & Métallurgie', 2, 'Laurent Schmitt', 2008, 'contact@meurthe-industries.fr', '+33 3 83 49 05 11', 'meurthe-industries.fr', 'Sous-traitance industrielle de précision et travail des métaux pour les secteurs de l''automobile et de l''énergie.', true),
|
|
||||||
('Grand Est Finance', 'Conseil financier', 1, 'Isabelle Fontaine', 2017, 'contact@grandest-finance.fr', '+33 3 83 25 44 90', 'grandest-finance.fr', 'Conseil en gestion de patrimoine et financement d''entreprise, aux côtés des dirigeants dans leurs projets de croissance.', true),
|
|
||||||
('Atelier Lumière', 'Communication & Design', 3, 'Julie Mercier', 2021, 'bonjour@atelier-lumiere.fr', '+33 3 83 60 77 08', 'atelier-lumiere.fr', 'Studio de création graphique et d''identité de marque, du logo à la signalétique, pour donner du sens à votre image.', true),
|
|
||||||
('Pharma Lorraine', 'Laboratoire pharmaceutique', 5, 'Dr Antoine Rousseau', 2013, 'contact@pharma-lorraine.fr', '+33 3 83 90 21 47', 'pharma-lorraine.fr', 'Laboratoire de production et de distribution de solutions de santé, engagé dans l''innovation et la qualité.', false),
|
|
||||||
('Cristal Événements', 'Événementiel', 4, 'Émilie Colin', 2018, 'contact@cristal-events.fr', '+33 3 83 38 66 12', 'cristal-events.fr', 'Agence événementielle imaginant séminaires, soirées d''entreprise et événements sur-mesure clés en main.', true);
|
|
||||||
|
|
||||||
INSERT INTO rencontres (titre, date_renc, heure, lieu, description, places) VALUES
|
|
||||||
('Afterwork Business & Basket', '2026-09-17', '18h30', 'Palais des Sports J. Weille', 'Networking en tribune VIP autour d''un cocktail, suivi du match SLUC Nancy – Cholet. Le rendez-vous incontournable de la rentrée.', 40),
|
|
||||||
('Petit-déjeuner des Dirigeants', '2026-10-06', '08h00', 'Hôtel Mercure Nancy Centre', 'Échanges entre dirigeants autour d''un intervenant invité, dans un format intimiste propice aux affaires.', 25),
|
|
||||||
('Conférence — L''esprit d''équipe', '2026-11-19', '19h00', 'Grand Salon, Hôtel de Ville', 'Un coach de haut niveau partage les ressorts de la performance collective, du terrain à l''entreprise.', 80);
|
|
||||||
|
|
||||||
INSERT INTO inscriptions (rencontre_id, nom, entreprise, email, tel, statut) VALUES
|
|
||||||
(1, 'Sophie Marchand', 'Lorraine Assurances', 's.marchand@lorraine-assurances.fr', '+33 3 83 12 45 60', 'confirmee'),
|
|
||||||
(1, 'Karim Benali', 'Moselle Digital', 'k.benali@moselle-digital.fr', '+33 3 83 55 71 22', 'en_attente'),
|
|
||||||
(2, 'Thomas Villeret', 'NancyTech Solutions', 't.villeret@nancytech.fr', '+33 3 83 41 88 90', 'confirmee'),
|
|
||||||
(3, 'Nathalie Perrin', 'Est Immobilier', 'n.perrin@est-immobilier.fr', '+33 3 83 17 62 30', 'en_attente'),
|
|
||||||
(2, 'Bruno Kieffer', 'Vosges Logistique', 'b.kieffer@vosges-logistique.fr', '+33 3 29 34 12 78', 'confirmee'),
|
|
||||||
(1, 'Julie Mercier', 'Atelier Lumière', 'j.mercier@atelier-lumiere.fr', '+33 3 83 60 77 08', 'en_attente'),
|
|
||||||
(1, 'Laurent Schmitt', 'Meurthe Industries', 'l.schmitt@meurthe-industries.fr', '+33 3 83 49 05 11', 'confirmee'),
|
|
||||||
(3, 'Isabelle Fontaine', 'Grand Est Finance', 'i.fontaine@grandest-finance.fr', '+33 3 83 25 44 90', 'confirmee'),
|
|
||||||
(2, 'Me Claire Renard', 'Renard & Associés', 'c.renard@renard-avocats.fr', '+33 3 83 30 09 15', 'en_attente'),
|
|
||||||
(1, 'Émilie Colin', 'Cristal Événements', 'e.colin@cristal-events.fr', '+33 3 83 38 66 12', 'confirmee'),
|
|
||||||
(3, 'Philippe Aubry', 'Groupe Batigest', 'p.aubry@batigest.fr', '+33 3 83 22 18 04', 'confirmee');
|
|
||||||
|
|
||||||
INSERT INTO rencontres_passees (date_label, lieu, titre, texte, participants, nb_photos) VALUES
|
|
||||||
('Juin 2026', 'Château de Rémicourt', 'Soirée de Gala annuelle', 'Plus de 200 convives réunis pour célébrer une saison d''exception. Une soirée d''élégance mêlant remise de trophées, dîner gastronomique et rencontres privilégiées entre membres du Club.', 210, 48),
|
|
||||||
('Avril 2026', 'Site industriel, Florange', 'Visite privée — Usine ArcelorMittal', 'Immersion industrielle exclusive pour nos membres au cœur d''un fleuron de la métallurgie lorraine, suivie d''un échange avec la direction du site.', 34, 22),
|
|
||||||
('Février 2026', 'La Filature, Nancy', 'Table ronde — Digitaliser sa PME', 'Retours d''expérience et bonnes pratiques : trois dirigeants membres ont partagé leur parcours de transformation numérique devant une salle comble.', 56, 15);
|
|
||||||
|
|
||||||
INSERT INTO demandes_adhesion (nom, fonction, entreprise, email) VALUES
|
|
||||||
('Marc Dubois', 'Directeur général', 'Dubois Traiteur', 'm.dubois@dubois-traiteur.fr'),
|
|
||||||
('Léa Hoffmann', 'Présidente', 'Hoffmann Conseil', 'l.hoffmann@hoffmann-conseil.fr'),
|
|
||||||
('Julien Weber', 'Gérant', 'Weber Menuiserie', 'j.weber@weber-menuiserie.fr');
|
|
||||||
|
|
||||||
INSERT INTO site_content (key, value) VALUES
|
|
||||||
('hero_quote_text', 'On ne réussit jamais seul.'),
|
|
||||||
('hero_quote_author', 'L''esprit du Club'),
|
|
||||||
('hero_photo', '');
|
|
||||||
|
|
||||||
-- Accounts: one admin + one portal account per member company.
|
|
||||||
-- '*seed*' is an intentionally unusable hash; real passwords are applied
|
|
||||||
-- by the API at startup from environment variables.
|
|
||||||
INSERT INTO users (email, password_hash, role, member_id)
|
|
||||||
SELECT email, '*seed*', 'member', id FROM members WHERE email IS NOT NULL;
|
|
||||||
|
|
||||||
INSERT INTO users (email, password_hash, role) VALUES
|
|
||||||
('admin@sluc-businessclub.fr', '*seed*', 'admin');
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# Runs at every stack start (db-sync service): keeps the sbc_app role's
|
|
||||||
# password in sync with APP_DB_PASSWORD, even when the database volume was
|
|
||||||
# initialized with a different value. Idempotent.
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
ESCAPED=$(printf %s "$APP_DB_PASSWORD" | sed "s/'/''/g")
|
|
||||||
|
|
||||||
psql -v ON_ERROR_STOP=1 <<EOSQL
|
|
||||||
DO \$\$
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'sbc_app') THEN
|
|
||||||
CREATE ROLE sbc_app LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE;
|
|
||||||
GRANT USAGE ON SCHEMA public TO sbc_app;
|
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO sbc_app;
|
|
||||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO sbc_app;
|
|
||||||
END IF;
|
|
||||||
END
|
|
||||||
\$\$;
|
|
||||||
ALTER ROLE sbc_app WITH LOGIN PASSWORD '${ESCAPED}' NOSUPERUSER NOCREATEDB NOCREATEROLE;
|
|
||||||
EOSQL
|
|
||||||
|
|
||||||
echo "sbc_app role synchronized."
|
|
||||||
+3
-22
@@ -10,10 +10,8 @@ services:
|
|||||||
# Preconfigured defaults so the stack deploys without any .env
|
# Preconfigured defaults so the stack deploys without any .env
|
||||||
# (Portainer, etc.). Override them in production.
|
# (Portainer, etc.). Override them in production.
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-sbc-pg-c7f3a91d284e6b05}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-sbc-pg-c7f3a91d284e6b05}
|
||||||
APP_DB_PASSWORD: ${APP_DB_PASSWORD:-sbc-app-9e12d47ab8c3f605}
|
|
||||||
volumes:
|
volumes:
|
||||||
- db_data:/var/lib/postgresql/data
|
- db_data:/var/lib/postgresql/data
|
||||||
- ./db/init:/docker-entrypoint-initdb.d:ro
|
|
||||||
ports:
|
ports:
|
||||||
# Loopback only: reachable from the host machine (psql, backups),
|
# Loopback only: reachable from the host machine (psql, backups),
|
||||||
# never from the network. Remove this mapping to close it entirely.
|
# never from the network. Remove this mapping to close it entirely.
|
||||||
@@ -30,24 +28,6 @@ services:
|
|||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 12
|
retries: 12
|
||||||
|
|
||||||
# One-shot at every stack start: realigns the sbc_app role password with
|
|
||||||
# APP_DB_PASSWORD, healing volumes initialized with an older value.
|
|
||||||
db-sync:
|
|
||||||
image: postgres:16-alpine
|
|
||||||
restart: "no"
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
environment:
|
|
||||||
PGHOST: sbc-db
|
|
||||||
PGUSER: postgres
|
|
||||||
PGDATABASE: sbc
|
|
||||||
PGPASSWORD: ${POSTGRES_PASSWORD:-sbc-pg-c7f3a91d284e6b05}
|
|
||||||
APP_DB_PASSWORD: ${APP_DB_PASSWORD:-sbc-app-9e12d47ab8c3f605}
|
|
||||||
volumes:
|
|
||||||
- ./db/sync-app-role.sh:/sync-app-role.sh:ro
|
|
||||||
entrypoint: ["/bin/sh", "/sync-app-role.sh"]
|
|
||||||
|
|
||||||
app:
|
app:
|
||||||
build: .
|
build: .
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -61,6 +41,9 @@ services:
|
|||||||
PGDATABASE: sbc
|
PGDATABASE: sbc
|
||||||
PGUSER: sbc_app
|
PGUSER: sbc_app
|
||||||
PGPASSWORD: ${APP_DB_PASSWORD:-sbc-app-9e12d47ab8c3f605}
|
PGPASSWORD: ${APP_DB_PASSWORD:-sbc-app-9e12d47ab8c3f605}
|
||||||
|
# Lets the app bootstrap the database itself at every startup
|
||||||
|
# (role, schema, demo data) — fully idempotent, heals any volume state.
|
||||||
|
PG_SUPERUSER_PASSWORD: ${POSTGRES_PASSWORD:-sbc-pg-c7f3a91d284e6b05}
|
||||||
# Empty by default: the app then generates a random ephemeral secret at
|
# Empty by default: the app then generates a random ephemeral secret at
|
||||||
# startup. Set a fixed value to keep sessions across restarts.
|
# startup. Set a fixed value to keep sessions across restarts.
|
||||||
JWT_SECRET: ${JWT_SECRET:-}
|
JWT_SECRET: ${JWT_SECRET:-}
|
||||||
@@ -82,8 +65,6 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
db-sync:
|
|
||||||
condition: service_completed_successfully
|
|
||||||
read_only: true
|
read_only: true
|
||||||
tmpfs:
|
tmpfs:
|
||||||
- /tmp
|
- /tmp
|
||||||
|
|||||||
Vendored
+85
@@ -0,0 +1,85 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import pg from 'pg';
|
||||||
|
import { config } from './config.js';
|
||||||
|
|
||||||
|
const read = (name) => fs.readFileSync(new URL(`./sql/${name}`, import.meta.url), 'utf8');
|
||||||
|
|
||||||
|
const escLiteral = (v) => String(v).replace(/'/g, "''");
|
||||||
|
const escIdent = (v) => `"${String(v).replace(/"/g, '""')}"`;
|
||||||
|
|
||||||
|
async function connectWithRetry(options, retries = 30, delayMs = 1000) {
|
||||||
|
for (let i = 1; i <= retries; i++) {
|
||||||
|
const client = new pg.Client({ ...options, connectionTimeoutMillis: 5000 });
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
return client;
|
||||||
|
} catch (err) {
|
||||||
|
await client.end().catch(() => {});
|
||||||
|
if (i === retries) throw err;
|
||||||
|
await new Promise((r) => setTimeout(r, delayMs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fully idempotent bootstrap, run at every startup with the PostgreSQL
|
||||||
|
// superuser: heals ANY volume state (missing database, missing role, wrong
|
||||||
|
// role password, missing schema, empty data). No init scripts, no bind
|
||||||
|
// mounts, no extra container required.
|
||||||
|
export async function bootstrapDatabase() {
|
||||||
|
if (!config.db.superuserPassword) {
|
||||||
|
console.log('PG_SUPERUSER_PASSWORD not set: skipping automatic database bootstrap.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const su = {
|
||||||
|
host: config.db.host,
|
||||||
|
port: config.db.port,
|
||||||
|
user: config.db.superuser,
|
||||||
|
password: config.db.superuserPassword,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1) Maintenance database: ensure application database and role exist
|
||||||
|
const admin = await connectWithRetry({ ...su, database: 'postgres' });
|
||||||
|
try {
|
||||||
|
const dbExists = await admin.query('SELECT 1 FROM pg_database WHERE datname = $1', [
|
||||||
|
config.db.database,
|
||||||
|
]);
|
||||||
|
if (dbExists.rowCount === 0) {
|
||||||
|
await admin.query(`CREATE DATABASE ${escIdent(config.db.database)}`);
|
||||||
|
console.log(`Database "${config.db.database}" created.`);
|
||||||
|
}
|
||||||
|
const roleExists = await admin.query('SELECT 1 FROM pg_roles WHERE rolname = $1', [
|
||||||
|
config.db.user,
|
||||||
|
]);
|
||||||
|
if (roleExists.rowCount === 0) {
|
||||||
|
await admin.query(
|
||||||
|
`CREATE ROLE ${escIdent(config.db.user)} LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE`
|
||||||
|
);
|
||||||
|
console.log(`Role "${config.db.user}" created.`);
|
||||||
|
}
|
||||||
|
// Always realign the password with the current environment value
|
||||||
|
await admin.query(
|
||||||
|
`ALTER ROLE ${escIdent(config.db.user)} WITH LOGIN PASSWORD '${escLiteral(config.db.password)}' NOSUPERUSER NOCREATEDB NOCREATEROLE`
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await admin.end().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Application database: schema, demo seed (once), grants
|
||||||
|
const db = await connectWithRetry({ ...su, database: config.db.database });
|
||||||
|
try {
|
||||||
|
await db.query(read('schema.sql'));
|
||||||
|
const seeded = await db.query('SELECT COUNT(*)::int AS n FROM categories');
|
||||||
|
if (seeded.rows[0].n === 0) {
|
||||||
|
await db.query(read('seed.sql'));
|
||||||
|
console.log('Demo data seeded.');
|
||||||
|
}
|
||||||
|
await db.query(`
|
||||||
|
GRANT USAGE ON SCHEMA public TO ${escIdent(config.db.user)};
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO ${escIdent(config.db.user)};
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO ${escIdent(config.db.user)};
|
||||||
|
`);
|
||||||
|
} finally {
|
||||||
|
await db.end().catch(() => {});
|
||||||
|
}
|
||||||
|
console.log('Database bootstrap complete.');
|
||||||
|
}
|
||||||
@@ -29,6 +29,10 @@ export const config = {
|
|||||||
database: process.env.PGDATABASE || 'sbc',
|
database: process.env.PGDATABASE || 'sbc',
|
||||||
user: process.env.PGUSER || 'sbc_app',
|
user: process.env.PGUSER || 'sbc_app',
|
||||||
password: required('PGPASSWORD'),
|
password: required('PGPASSWORD'),
|
||||||
|
// When provided, the app bootstraps the database itself at startup
|
||||||
|
// (create database/role, apply schema, seed demo data) — idempotent.
|
||||||
|
superuser: process.env.PG_SUPERUSER || 'postgres',
|
||||||
|
superuserPassword: process.env.PG_SUPERUSER_PASSWORD || '',
|
||||||
},
|
},
|
||||||
jwtSecret,
|
jwtSecret,
|
||||||
cookieSecure: process.env.COOKIE_SECURE === 'true',
|
cookieSecure: process.env.COOKIE_SECURE === 'true',
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import compression from 'compression';
|
|||||||
import cookieParser from 'cookie-parser';
|
import cookieParser from 'cookie-parser';
|
||||||
import { config } from './config.js';
|
import { config } from './config.js';
|
||||||
import { waitForDb } from './db.js';
|
import { waitForDb } from './db.js';
|
||||||
|
import { bootstrapDatabase } from './bootstrap-db.js';
|
||||||
import { applyInitialPasswords } from './bootstrap.js';
|
import { applyInitialPasswords } from './bootstrap.js';
|
||||||
import { attachUser } from './middleware/auth.js';
|
import { attachUser } from './middleware/auth.js';
|
||||||
import { csrfOriginCheck, globalLimiter } from './middleware/security.js';
|
import { csrfOriginCheck, globalLimiter } from './middleware/security.js';
|
||||||
@@ -107,6 +108,7 @@ app.use((err, _req, res, _next) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await bootstrapDatabase();
|
||||||
await waitForDb();
|
await waitForDb();
|
||||||
await applyInitialPasswords();
|
await applyInitialPasswords();
|
||||||
app.listen(config.port, () => {
|
app.listen(config.port, () => {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
-- SLUC Business Club — schema
|
-- SLUC Business Club — idempotent schema (applied at every app startup)
|
||||||
CREATE EXTENSION IF NOT EXISTS citext;
|
CREATE EXTENSION IF NOT EXISTS citext;
|
||||||
|
|
||||||
CREATE TABLE categories (
|
CREATE TABLE IF NOT EXISTS categories (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
name TEXT NOT NULL UNIQUE CHECK (char_length(name) BETWEEN 1 AND 80)
|
name TEXT NOT NULL UNIQUE CHECK (char_length(name) BETWEEN 1 AND 80)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE members (
|
CREATE TABLE IF NOT EXISTS members (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
nom TEXT NOT NULL CHECK (char_length(nom) BETWEEN 1 AND 120),
|
nom TEXT NOT NULL CHECK (char_length(nom) BETWEEN 1 AND 120),
|
||||||
secteur TEXT NOT NULL DEFAULT '' CHECK (char_length(secteur) <= 120),
|
secteur TEXT NOT NULL DEFAULT '' CHECK (char_length(secteur) <= 120),
|
||||||
@@ -24,7 +24,7 @@ CREATE TABLE members (
|
|||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
email CITEXT NOT NULL UNIQUE CHECK (char_length(email) <= 254),
|
email CITEXT NOT NULL UNIQUE CHECK (char_length(email) <= 254),
|
||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
@@ -33,7 +33,7 @@ CREATE TABLE users (
|
|||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE rencontres (
|
CREATE TABLE IF NOT EXISTS rencontres (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
titre TEXT NOT NULL CHECK (char_length(titre) BETWEEN 1 AND 200),
|
titre TEXT NOT NULL CHECK (char_length(titre) BETWEEN 1 AND 200),
|
||||||
date_renc DATE NOT NULL,
|
date_renc DATE NOT NULL,
|
||||||
@@ -44,7 +44,7 @@ CREATE TABLE rencontres (
|
|||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE inscriptions (
|
CREATE TABLE IF NOT EXISTS inscriptions (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
rencontre_id INTEGER NOT NULL REFERENCES rencontres(id) ON DELETE CASCADE,
|
rencontre_id INTEGER NOT NULL REFERENCES rencontres(id) ON DELETE CASCADE,
|
||||||
nom TEXT NOT NULL CHECK (char_length(nom) BETWEEN 1 AND 120),
|
nom TEXT NOT NULL CHECK (char_length(nom) BETWEEN 1 AND 120),
|
||||||
@@ -54,9 +54,9 @@ CREATE TABLE inscriptions (
|
|||||||
statut TEXT NOT NULL DEFAULT 'en_attente' CHECK (statut IN ('confirmee', 'en_attente')),
|
statut TEXT NOT NULL DEFAULT 'en_attente' CHECK (statut IN ('confirmee', 'en_attente')),
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
CREATE INDEX idx_inscriptions_rencontre ON inscriptions(rencontre_id);
|
CREATE INDEX IF NOT EXISTS idx_inscriptions_rencontre ON inscriptions(rencontre_id);
|
||||||
|
|
||||||
CREATE TABLE rencontres_passees (
|
CREATE TABLE IF NOT EXISTS rencontres_passees (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
date_label TEXT NOT NULL,
|
date_label TEXT NOT NULL,
|
||||||
lieu TEXT NOT NULL,
|
lieu TEXT NOT NULL,
|
||||||
@@ -66,7 +66,7 @@ CREATE TABLE rencontres_passees (
|
|||||||
nb_photos INTEGER NOT NULL DEFAULT 0
|
nb_photos INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE demandes_adhesion (
|
CREATE TABLE IF NOT EXISTS demandes_adhesion (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
nom TEXT NOT NULL CHECK (char_length(nom) BETWEEN 1 AND 120),
|
nom TEXT NOT NULL CHECK (char_length(nom) BETWEEN 1 AND 120),
|
||||||
fonction TEXT NOT NULL DEFAULT '' CHECK (char_length(fonction) <= 120),
|
fonction TEXT NOT NULL DEFAULT '' CHECK (char_length(fonction) <= 120),
|
||||||
@@ -76,12 +76,7 @@ CREATE TABLE demandes_adhesion (
|
|||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE site_content (
|
CREATE TABLE IF NOT EXISTS site_content (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
value TEXT NOT NULL DEFAULT ''
|
value TEXT NOT NULL DEFAULT ''
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Least-privilege grants for the application role (no DDL, no other schemas)
|
|
||||||
GRANT USAGE ON SCHEMA public TO sbc_app;
|
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO sbc_app;
|
|
||||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO sbc_app;
|
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
-- Demo data — executed only when the categories table is empty.
|
||||||
|
-- All references use name lookups so the seed works whatever the sequences.
|
||||||
|
|
||||||
|
INSERT INTO categories (name) VALUES
|
||||||
|
('Conseil & Finance'),
|
||||||
|
('Industrie & BTP'),
|
||||||
|
('Communication & Digital'),
|
||||||
|
('Services'),
|
||||||
|
('Santé');
|
||||||
|
|
||||||
|
INSERT INTO members (nom, secteur, categorie_id, dirigeant, adhesion, email, tel, site, presentation, valide) VALUES
|
||||||
|
('Lorraine Assurances', 'Assurance & Prévoyance', (SELECT id FROM categories WHERE name = 'Conseil & Finance'), 'Sophie Marchand', 2014, 'contact@lorraine-assurances.fr', '+33 3 83 12 45 60', 'lorraine-assurances.fr', 'Cabinet de courtage indépendant accompagnant les entreprises et dirigeants du Grand Est sur l''ensemble de leurs besoins en assurance et prévoyance.', true),
|
||||||
|
('Groupe Batigest', 'BTP & Construction', (SELECT id FROM categories WHERE name = 'Industrie & BTP'), 'Philippe Aubry', 2009, 'contact@batigest.fr', '+33 3 83 22 18 04', 'groupe-batigest.fr', 'Entreprise générale de construction et de rénovation, acteur majeur du bâtiment en Lorraine depuis plus de trente ans.', true),
|
||||||
|
('Moselle Digital', 'Agence digitale', (SELECT id FROM categories WHERE name = 'Communication & Digital'), 'Karim Benali', 2019, 'hello@moselle-digital.fr', '+33 3 83 55 71 22', 'moselle-digital.fr', 'Agence de communication digitale spécialisée dans la création de sites, le référencement et les stratégies social media pour les PME.', true),
|
||||||
|
('Renard & Associés', 'Cabinet d''avocats', (SELECT id FROM categories WHERE name = 'Conseil & Finance'), 'Me Claire Renard', 2012, 'contact@renard-avocats.fr', '+33 3 83 30 09 15', 'renard-avocats.fr', 'Cabinet d''avocats d''affaires conseillant dirigeants et entreprises en droit des sociétés, droit social et droit commercial.', true),
|
||||||
|
('NancyTech Solutions', 'Solutions informatiques', (SELECT id FROM categories WHERE name = 'Communication & Digital'), 'Thomas Villeret', 2020, 'contact@nancytech.fr', '+33 3 83 41 88 90', 'nancytech.fr', 'Infogérance, cybersécurité et déploiement d''outils métiers pour accompagner la transformation numérique des entreprises régionales.', true),
|
||||||
|
('Est Immobilier', 'Immobilier d''entreprise', (SELECT id FROM categories WHERE name = 'Services'), 'Nathalie Perrin', 2016, 'contact@est-immobilier.fr', '+33 3 83 17 62 30', 'est-immobilier.fr', 'Conseil en immobilier d''entreprise : bureaux, locaux commerciaux et investissement sur l''agglomération nancéienne.', true),
|
||||||
|
('Vosges Logistique', 'Transport & Logistique', (SELECT id FROM categories WHERE name = 'Services'), 'Bruno Kieffer', 2011, 'contact@vosges-logistique.fr', '+33 3 29 34 12 78', 'vosges-logistique.fr', 'Solutions de transport, entreposage et distribution au service des industriels et distributeurs du Grand Est.', false),
|
||||||
|
('Meurthe Industries', 'Industrie & Métallurgie', (SELECT id FROM categories WHERE name = 'Industrie & BTP'), 'Laurent Schmitt', 2008, 'contact@meurthe-industries.fr', '+33 3 83 49 05 11', 'meurthe-industries.fr', 'Sous-traitance industrielle de précision et travail des métaux pour les secteurs de l''automobile et de l''énergie.', true),
|
||||||
|
('Grand Est Finance', 'Conseil financier', (SELECT id FROM categories WHERE name = 'Conseil & Finance'), 'Isabelle Fontaine', 2017, 'contact@grandest-finance.fr', '+33 3 83 25 44 90', 'grandest-finance.fr', 'Conseil en gestion de patrimoine et financement d''entreprise, aux côtés des dirigeants dans leurs projets de croissance.', true),
|
||||||
|
('Atelier Lumière', 'Communication & Design', (SELECT id FROM categories WHERE name = 'Communication & Digital'), 'Julie Mercier', 2021, 'bonjour@atelier-lumiere.fr', '+33 3 83 60 77 08', 'atelier-lumiere.fr', 'Studio de création graphique et d''identité de marque, du logo à la signalétique, pour donner du sens à votre image.', true),
|
||||||
|
('Pharma Lorraine', 'Laboratoire pharmaceutique', (SELECT id FROM categories WHERE name = 'Santé'), 'Dr Antoine Rousseau', 2013, 'contact@pharma-lorraine.fr', '+33 3 83 90 21 47', 'pharma-lorraine.fr', 'Laboratoire de production et de distribution de solutions de santé, engagé dans l''innovation et la qualité.', false),
|
||||||
|
('Cristal Événements', 'Événementiel', (SELECT id FROM categories WHERE name = 'Services'), 'Émilie Colin', 2018, 'contact@cristal-events.fr', '+33 3 83 38 66 12', 'cristal-events.fr', 'Agence événementielle imaginant séminaires, soirées d''entreprise et événements sur-mesure clés en main.', true);
|
||||||
|
|
||||||
|
INSERT INTO rencontres (titre, date_renc, heure, lieu, description, places) VALUES
|
||||||
|
('Afterwork Business & Basket', '2026-09-17', '18h30', 'Palais des Sports J. Weille', 'Networking en tribune VIP autour d''un cocktail, suivi du match SLUC Nancy – Cholet. Le rendez-vous incontournable de la rentrée.', 40),
|
||||||
|
('Petit-déjeuner des Dirigeants', '2026-10-06', '08h00', 'Hôtel Mercure Nancy Centre', 'Échanges entre dirigeants autour d''un intervenant invité, dans un format intimiste propice aux affaires.', 25),
|
||||||
|
('Conférence — L''esprit d''équipe', '2026-11-19', '19h00', 'Grand Salon, Hôtel de Ville', 'Un coach de haut niveau partage les ressorts de la performance collective, du terrain à l''entreprise.', 80);
|
||||||
|
|
||||||
|
INSERT INTO inscriptions (rencontre_id, nom, entreprise, email, tel, statut) VALUES
|
||||||
|
((SELECT id FROM rencontres WHERE titre = 'Afterwork Business & Basket'), 'Sophie Marchand', 'Lorraine Assurances', 's.marchand@lorraine-assurances.fr', '+33 3 83 12 45 60', 'confirmee'),
|
||||||
|
((SELECT id FROM rencontres WHERE titre = 'Afterwork Business & Basket'), 'Karim Benali', 'Moselle Digital', 'k.benali@moselle-digital.fr', '+33 3 83 55 71 22', 'en_attente'),
|
||||||
|
((SELECT id FROM rencontres WHERE titre = 'Petit-déjeuner des Dirigeants'), 'Thomas Villeret', 'NancyTech Solutions', 't.villeret@nancytech.fr', '+33 3 83 41 88 90', 'confirmee'),
|
||||||
|
((SELECT id FROM rencontres WHERE titre = 'Conférence — L''esprit d''équipe'), 'Nathalie Perrin', 'Est Immobilier', 'n.perrin@est-immobilier.fr', '+33 3 83 17 62 30', 'en_attente'),
|
||||||
|
((SELECT id FROM rencontres WHERE titre = 'Petit-déjeuner des Dirigeants'), 'Bruno Kieffer', 'Vosges Logistique', 'b.kieffer@vosges-logistique.fr', '+33 3 29 34 12 78', 'confirmee'),
|
||||||
|
((SELECT id FROM rencontres WHERE titre = 'Afterwork Business & Basket'), 'Julie Mercier', 'Atelier Lumière', 'j.mercier@atelier-lumiere.fr', '+33 3 83 60 77 08', 'en_attente'),
|
||||||
|
((SELECT id FROM rencontres WHERE titre = 'Afterwork Business & Basket'), 'Laurent Schmitt', 'Meurthe Industries', 'l.schmitt@meurthe-industries.fr', '+33 3 83 49 05 11', 'confirmee'),
|
||||||
|
((SELECT id FROM rencontres WHERE titre = 'Conférence — L''esprit d''équipe'), 'Isabelle Fontaine', 'Grand Est Finance', 'i.fontaine@grandest-finance.fr', '+33 3 83 25 44 90', 'confirmee'),
|
||||||
|
((SELECT id FROM rencontres WHERE titre = 'Petit-déjeuner des Dirigeants'), 'Me Claire Renard', 'Renard & Associés', 'c.renard@renard-avocats.fr', '+33 3 83 30 09 15', 'en_attente'),
|
||||||
|
((SELECT id FROM rencontres WHERE titre = 'Afterwork Business & Basket'), 'Émilie Colin', 'Cristal Événements', 'e.colin@cristal-events.fr', '+33 3 83 38 66 12', 'confirmee'),
|
||||||
|
((SELECT id FROM rencontres WHERE titre = 'Conférence — L''esprit d''équipe'), 'Philippe Aubry', 'Groupe Batigest', 'p.aubry@batigest.fr', '+33 3 83 22 18 04', 'confirmee');
|
||||||
|
|
||||||
|
INSERT INTO rencontres_passees (date_label, lieu, titre, texte, participants, nb_photos) VALUES
|
||||||
|
('Juin 2026', 'Château de Rémicourt', 'Soirée de Gala annuelle', 'Plus de 200 convives réunis pour célébrer une saison d''exception. Une soirée d''élégance mêlant remise de trophées, dîner gastronomique et rencontres privilégiées entre membres du Club.', 210, 48),
|
||||||
|
('Avril 2026', 'Site industriel, Florange', 'Visite privée — Usine ArcelorMittal', 'Immersion industrielle exclusive pour nos membres au cœur d''un fleuron de la métallurgie lorraine, suivie d''un échange avec la direction du site.', 34, 22),
|
||||||
|
('Février 2026', 'La Filature, Nancy', 'Table ronde — Digitaliser sa PME', 'Retours d''expérience et bonnes pratiques : trois dirigeants membres ont partagé leur parcours de transformation numérique devant une salle comble.', 56, 15);
|
||||||
|
|
||||||
|
INSERT INTO demandes_adhesion (nom, fonction, entreprise, email) VALUES
|
||||||
|
('Marc Dubois', 'Directeur général', 'Dubois Traiteur', 'm.dubois@dubois-traiteur.fr'),
|
||||||
|
('Léa Hoffmann', 'Présidente', 'Hoffmann Conseil', 'l.hoffmann@hoffmann-conseil.fr'),
|
||||||
|
('Julien Weber', 'Gérant', 'Weber Menuiserie', 'j.weber@weber-menuiserie.fr');
|
||||||
|
|
||||||
|
INSERT INTO site_content (key, value) VALUES
|
||||||
|
('hero_quote_text', 'On ne réussit jamais seul.'),
|
||||||
|
('hero_quote_author', 'L''esprit du Club'),
|
||||||
|
('hero_photo', '')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
-- Accounts: created locked ('*seed*'); real passwords are applied at startup
|
||||||
|
-- from ADMIN_INITIAL_PASSWORD / MEMBER_INITIAL_PASSWORD.
|
||||||
|
INSERT INTO users (email, password_hash, role, member_id)
|
||||||
|
SELECT email, '*seed*', 'member', id FROM members WHERE email IS NOT NULL
|
||||||
|
ON CONFLICT (email) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO users (email, password_hash, role) VALUES
|
||||||
|
('admin@sluc-businessclub.fr', '*seed*', 'admin')
|
||||||
|
ON CONFLICT (email) DO NOTHING;
|
||||||
Reference in new issue
Block a user