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
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',
|
||||
user: process.env.PGUSER || 'sbc_app',
|
||||
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,
|
||||
cookieSecure: process.env.COOKIE_SECURE === 'true',
|
||||
|
||||
@@ -6,6 +6,7 @@ import compression from 'compression';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import { config } from './config.js';
|
||||
import { waitForDb } from './db.js';
|
||||
import { bootstrapDatabase } from './bootstrap-db.js';
|
||||
import { applyInitialPasswords } from './bootstrap.js';
|
||||
import { attachUser } from './middleware/auth.js';
|
||||
import { csrfOriginCheck, globalLimiter } from './middleware/security.js';
|
||||
@@ -107,6 +108,7 @@ app.use((err, _req, res, _next) => {
|
||||
});
|
||||
|
||||
try {
|
||||
await bootstrapDatabase();
|
||||
await waitForDb();
|
||||
await applyInitialPasswords();
|
||||
app.listen(config.port, () => {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
-- SLUC Business Club — idempotent schema (applied at every app startup)
|
||||
CREATE EXTENSION IF NOT EXISTS citext;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE CHECK (char_length(name) BETWEEN 1 AND 80)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS members (
|
||||
id SERIAL PRIMARY KEY,
|
||||
nom TEXT NOT NULL CHECK (char_length(nom) BETWEEN 1 AND 120),
|
||||
secteur TEXT NOT NULL DEFAULT '' CHECK (char_length(secteur) <= 120),
|
||||
categorie_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
|
||||
dirigeant TEXT NOT NULL DEFAULT '' CHECK (char_length(dirigeant) <= 120),
|
||||
adhesion SMALLINT,
|
||||
email CITEXT CHECK (char_length(email) <= 254),
|
||||
tel TEXT CHECK (char_length(tel) <= 30),
|
||||
site TEXT CHECK (char_length(site) <= 200),
|
||||
presentation TEXT NOT NULL DEFAULT '' CHECK (char_length(presentation) <= 2000),
|
||||
valide BOOLEAN NOT NULL DEFAULT false,
|
||||
logo_path TEXT,
|
||||
photo_path TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
email CITEXT NOT NULL UNIQUE CHECK (char_length(email) <= 254),
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK (role IN ('member', 'admin')),
|
||||
member_id INTEGER UNIQUE REFERENCES members(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rencontres (
|
||||
id SERIAL PRIMARY KEY,
|
||||
titre TEXT NOT NULL CHECK (char_length(titre) BETWEEN 1 AND 200),
|
||||
date_renc DATE NOT NULL,
|
||||
heure TEXT NOT NULL DEFAULT '' CHECK (char_length(heure) <= 20),
|
||||
lieu TEXT NOT NULL DEFAULT '' CHECK (char_length(lieu) <= 200),
|
||||
description TEXT NOT NULL DEFAULT '' CHECK (char_length(description) <= 2000),
|
||||
places INTEGER NOT NULL CHECK (places >= 0 AND places <= 100000),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS inscriptions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
rencontre_id INTEGER NOT NULL REFERENCES rencontres(id) ON DELETE CASCADE,
|
||||
nom TEXT NOT NULL CHECK (char_length(nom) BETWEEN 1 AND 120),
|
||||
entreprise TEXT NOT NULL CHECK (char_length(entreprise) BETWEEN 1 AND 120),
|
||||
email CITEXT CHECK (char_length(email) <= 254),
|
||||
tel TEXT CHECK (char_length(tel) <= 30),
|
||||
statut TEXT NOT NULL DEFAULT 'en_attente' CHECK (statut IN ('confirmee', 'en_attente')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_inscriptions_rencontre ON inscriptions(rencontre_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rencontres_passees (
|
||||
id SERIAL PRIMARY KEY,
|
||||
date_label TEXT NOT NULL,
|
||||
lieu TEXT NOT NULL,
|
||||
titre TEXT NOT NULL,
|
||||
texte TEXT NOT NULL,
|
||||
participants INTEGER NOT NULL DEFAULT 0,
|
||||
nb_photos INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS demandes_adhesion (
|
||||
id SERIAL PRIMARY KEY,
|
||||
nom TEXT NOT NULL CHECK (char_length(nom) BETWEEN 1 AND 120),
|
||||
fonction TEXT NOT NULL DEFAULT '' CHECK (char_length(fonction) <= 120),
|
||||
entreprise TEXT NOT NULL CHECK (char_length(entreprise) BETWEEN 1 AND 120),
|
||||
email CITEXT NOT NULL CHECK (char_length(email) <= 254),
|
||||
statut TEXT NOT NULL DEFAULT 'nouvelle' CHECK (statut IN ('nouvelle', 'traitee')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS site_content (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
@@ -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