Base de données — Schéma et requêtes
Configuration du pool
Fichier : src/lib/postgres.ts
const pool = new Pool({
host: process.env.PG_HOST,
port: parseInt(process.env.PG_PORT || '5432'),
database: process.env.PG_DATABASE,
user: process.env.PG_USER,
password: process.env.PG_PASSWORD,
ssl: process.env.PG_SSL === 'true' ? { rejectUnauthorized: false } : false,
max: 20, // connexions simultanées max
idleTimeoutMillis: 30000, // délai avant de fermer une connexion inactive
connectionTimeoutMillis: 10000, // délai d'attente pour obtenir une connexion
});
Schéma des tables
Table languages
CREATE TABLE languages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL UNIQUE,
code VARCHAR(10) NOT NULL UNIQUE, -- ex: 'pul', 'wol'
access_code VARCHAR(255) NOT NULL UNIQUE, -- code joueur
speaker_access_code VARCHAR(255), -- code locuteur
speaker_name VARCHAR(255), -- nom du locuteur
is_active BOOLEAN DEFAULT false, -- visible publiquement
display_order INTEGER DEFAULT 0, -- ordre d'affichage
primary_color VARCHAR(7) DEFAULT '#D97706', -- ex: '#D97706'
icon_url TEXT, -- URL de l'icône dans MinIO
created_at TIMESTAMPTZ DEFAULT NOW()
);
Index : code, access_code, is_active
Table cards
CREATE TABLE cards (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
language_id UUID REFERENCES languages(id) ON DELETE CASCADE,
type VARCHAR(20) NOT NULL CHECK (type IN ('word', 'phrase')),
card_number INTEGER NOT NULL,
theme VARCHAR(255), -- ex: 'Famille', 'Nourriture'
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(language_id, type, card_number) -- unicité par langue et type
);
Table items
CREATE TABLE items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
card_id UUID REFERENCES cards(id) ON DELETE CASCADE,
position INTEGER NOT NULL, -- 1-6 pour mots, 1-4 pour phrases
original_text TEXT NOT NULL, -- texte en langue africaine
translation TEXT NOT NULL, -- traduction française
audio_url TEXT, -- URL vers MinIO (null si pas d'audio)
lot_number INTEGER, -- 1 ou 2 pour les phrases (null pour mots)
item_type VARCHAR(20), -- 'question' ou 'answer' (null pour mots)
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(card_id, position)
);
-- Index ajouté en v3 (pour IndexedDB)
CREATE INDEX idx_items_card_id ON items(card_id);
Table speakers
CREATE TABLE speakers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
language_id UUID REFERENCES languages(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
access_code VARCHAR(255) NOT NULL UNIQUE,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW()
);
Table admins
CREATE TABLE admins (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL UNIQUE,
password_hash TEXT NOT NULL, -- format: pbkdf2$iter$salt$hash
created_at TIMESTAMPTZ DEFAULT NOW()
);
Table statistics
CREATE TABLE statistics (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
language_id UUID REFERENCES languages(id) ON DELETE CASCADE,
card_id UUID REFERENCES cards(id) ON DELETE CASCADE,
item_id UUID REFERENCES items(id) ON DELETE CASCADE,
event_type VARCHAR(50) NOT NULL, -- 'unlock_language', 'view_card', 'play_audio'
count INTEGER DEFAULT 1,
event_date DATE NOT NULL, -- agrégé par jour
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(language_id, card_id, item_id, event_type, event_date)
);
Comportement : les événements sont agrégés par jour via un INSERT ... ON CONFLICT DO UPDATE SET count = count + 1 (upsert).
Table contact_messages
CREATE TABLE contact_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
subject VARCHAR(255) NOT NULL,
message TEXT NOT NULL,
status VARCHAR(50) DEFAULT 'unread', -- 'unread', 'read', 'replied', 'archived'
admin_notes TEXT, -- notes internes admin
created_at TIMESTAMPTZ DEFAULT NOW()
);
Table quiz_sessions
CREATE TABLE quiz_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
language_id UUID REFERENCES languages(id) ON DELETE CASCADE,
category_slug VARCHAR(255) NOT NULL, -- slug URL du thème
category_name VARCHAR(255), -- nom lisible du thème
total_questions INTEGER NOT NULL, -- 15, 18 ou 20
score INTEGER, -- null si session non terminée
started_at TIMESTAMPTZ DEFAULT NOW(),
completed_at TIMESTAMPTZ, -- null si session abandonnée
created_at TIMESTAMPTZ DEFAULT NOW()
);
Relations entre tables
languages ─┬─< cards ─┬─< items ─── statistics
│ └─────────────────────────── statistics
├─< speakers
└─< quiz_sessions
admins (table indépendante)
contact_messages (table indépendante)
Requêtes SQL utiles
Obtenir le contenu complet d'une langue (pour le téléchargement offline)
SELECT
c.id AS card_id, c.type, c.card_number, c.theme,
i.id AS item_id, i.position, i.original_text, i.translation,
i.audio_url, i.lot_number, i.item_type
FROM cards c
JOIN items i ON i.card_id = c.id
WHERE c.language_id = $1
ORDER BY c.type, c.card_number, i.position;
Progression audio d'une langue
SELECT
COUNT(*) AS total_items,
COUNT(i.audio_url) AS items_with_audio,
ROUND(COUNT(i.audio_url)::numeric / COUNT(*)::numeric * 100, 1) AS completion_pct
FROM cards c
JOIN items i ON i.card_id = c.id
WHERE c.language_id = $1;
Rechercher un item par texte
SELECT c.card_number, c.type, i.position, i.original_text, i.translation
FROM items i
JOIN cards c ON i.card_id = c.id
WHERE c.language_id = $1
AND (i.original_text ILIKE $2 OR i.translation ILIKE $2)
ORDER BY c.card_number, i.position;
-- $2 = '%terme%'
Cartes sans audio (pour le locuteur)
SELECT c.card_number, c.type, i.position, i.original_text
FROM items i
JOIN cards c ON i.card_id = c.id
WHERE c.language_id = $1
AND i.audio_url IS NULL
AND (c.type = 'word' OR i.position IN (1, 4)) -- mots: toutes positions, phrases: 1 et 4
ORDER BY c.type, c.card_number, i.position;
Migrations
Les fichiers de migration sont dans le dossier migrations/ :
| Fichier | Contenu |
|---|---|
001_initial_schema.sql |
Schéma initial : languages, cards, items, statistics, speakers, contact_messages |
002_create_admins.sql |
Table admins |
003_idb_card_id_index_note.sql |
Note de documentation (IndexedDB v3) — pas de SQL à exécuter |
004_quiz_results.sql |
Table quiz_sessions |
Pour appliquer une migration :
psql -h $PG_HOST -U $PG_USER -d $PG_DATABASE -f migrations/00X_nom.sql