mirror of
https://github.com/btouchard/ackify.git
synced 2026-05-19 07:18:23 -05:00
68426bc882
- Implement PKCE (Proof Key for Code Exchange) with S256 method - Add crypto/pkce module with code verifier and challenge generation - Modify OAuth flow to include code_challenge in authorization requests - Update HandleCallback to validate code_verifier during token exchange - Extend session lifetime from 7 to 30 days - Add comprehensive unit tests for PKCE functions - Maintain backward compatibility with fallback for non-PKCE sessions - Add detailed logging for OAuth flow with PKCE tracking PKCE enhances security by preventing authorization code interception attacks, as recommended by OAuth 2.1 and OIDC standards. feat: add encrypted refresh token storage with automatic cleanup - Add oauth_sessions table for storing encrypted refresh tokens - Implement AES-256-GCM encryption for refresh tokens using cookie secret - Create OAuth session repository with full CRUD operations - Add SessionWorker for automatic cleanup of expired sessions - Configure cleanup to run every 24h for sessions older than 37 days - Modify OAuth flow to store refresh tokens after successful authentication - Track client IP and user agent for session security validation - Link OAuth sessions to user sessions via session ID - Add comprehensive encryption tests with security validations - Integrate SessionWorker into server lifecycle with graceful shutdown This enables persistent OAuth sessions with secure token storage, reducing the need for frequent re-authentication from 7 to 30 days.
605 lines
16 KiB
Markdown
605 lines
16 KiB
Markdown
# Database
|
|
|
|
Schéma PostgreSQL, migrations, et garanties d'intégrité.
|
|
|
|
## Vue d'Ensemble
|
|
|
|
Ackify utilise **PostgreSQL 16+** avec :
|
|
- Migrations versionnées SQL
|
|
- Contraintes d'intégrité strictes
|
|
- Triggers pour immutabilité
|
|
- Index pour performances
|
|
|
|
## Schéma Principal
|
|
|
|
### Table `signatures`
|
|
|
|
Stocke les signatures cryptographiques Ed25519.
|
|
|
|
```sql
|
|
CREATE TABLE signatures (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
doc_id TEXT NOT NULL,
|
|
user_sub TEXT NOT NULL, -- OAuth user ID (sub claim)
|
|
user_email TEXT NOT NULL,
|
|
user_name TEXT, -- Nom utilisateur (optionnel)
|
|
signed_at TIMESTAMPTZ NOT NULL,
|
|
payload_hash TEXT NOT NULL, -- SHA-256 du payload
|
|
signature TEXT NOT NULL, -- Signature Ed25519 (base64)
|
|
nonce TEXT NOT NULL, -- Anti-replay attack
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
referer TEXT, -- Source (optionnel)
|
|
prev_hash TEXT, -- Hash de la signature précédente (chaînage)
|
|
UNIQUE (doc_id, user_sub) -- UNE signature par user/document
|
|
);
|
|
|
|
CREATE INDEX idx_signatures_doc_id ON signatures(doc_id);
|
|
CREATE INDEX idx_signatures_user_sub ON signatures(user_sub);
|
|
```
|
|
|
|
**Garanties** :
|
|
- ✅ Une signature par utilisateur/document (contrainte UNIQUE)
|
|
- ✅ Horodatage immutable via trigger PostgreSQL
|
|
- ✅ Chaînage hash (blockchain-like) via `prev_hash`
|
|
- ✅ Non-répudiation cryptographique (Ed25519)
|
|
|
|
### Table `documents`
|
|
|
|
Métadonnées des documents.
|
|
|
|
```sql
|
|
CREATE TABLE documents (
|
|
doc_id TEXT PRIMARY KEY,
|
|
title TEXT NOT NULL DEFAULT '',
|
|
url TEXT NOT NULL DEFAULT '', -- URL du document source
|
|
checksum TEXT NOT NULL DEFAULT '', -- SHA-256, SHA-512, ou MD5
|
|
checksum_algorithm TEXT NOT NULL DEFAULT 'SHA-256',
|
|
description TEXT NOT NULL DEFAULT '',
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
created_by TEXT NOT NULL DEFAULT '' -- user_sub de l'admin créateur
|
|
);
|
|
```
|
|
|
|
**Utilisation** :
|
|
- Titre, description affichés dans l'interface
|
|
- URL incluse dans les emails de rappel
|
|
- Checksum pour vérification d'intégrité (optionnel)
|
|
|
|
### Table `expected_signers`
|
|
|
|
Signataires attendus pour tracking.
|
|
|
|
```sql
|
|
CREATE TABLE expected_signers (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
doc_id TEXT NOT NULL,
|
|
email TEXT NOT NULL,
|
|
name TEXT NOT NULL DEFAULT '', -- Nom pour personnalisation
|
|
added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
added_by TEXT NOT NULL, -- Admin qui a ajouté
|
|
notes TEXT,
|
|
UNIQUE (doc_id, email)
|
|
);
|
|
|
|
CREATE INDEX idx_expected_signers_doc_id ON expected_signers(doc_id);
|
|
```
|
|
|
|
**Fonctionnalités** :
|
|
- Tracking de complétion (% signé)
|
|
- Envoi de rappels email
|
|
- Détection de signatures inattendues
|
|
|
|
### Table `reminder_logs`
|
|
|
|
Historique des rappels email.
|
|
|
|
```sql
|
|
CREATE TABLE reminder_logs (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
doc_id TEXT NOT NULL,
|
|
recipient_email TEXT NOT NULL,
|
|
sent_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
sent_by TEXT NOT NULL, -- Admin qui a envoyé
|
|
template_used TEXT NOT NULL,
|
|
status TEXT NOT NULL CHECK (status IN ('sent', 'failed', 'bounced')),
|
|
error_message TEXT,
|
|
FOREIGN KEY (doc_id, recipient_email)
|
|
REFERENCES expected_signers(doc_id, email)
|
|
);
|
|
|
|
CREATE INDEX idx_reminder_logs_doc_id ON reminder_logs(doc_id);
|
|
```
|
|
|
|
### Table `checksum_verifications`
|
|
|
|
Historique des vérifications d'intégrité.
|
|
|
|
```sql
|
|
CREATE TABLE checksum_verifications (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
doc_id TEXT NOT NULL,
|
|
verified_by TEXT NOT NULL,
|
|
verified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
stored_checksum TEXT NOT NULL,
|
|
calculated_checksum TEXT NOT NULL,
|
|
algorithm TEXT NOT NULL,
|
|
is_valid BOOLEAN NOT NULL,
|
|
error_message TEXT,
|
|
FOREIGN KEY (doc_id) REFERENCES documents(doc_id)
|
|
);
|
|
|
|
CREATE INDEX idx_checksum_verifications_doc_id ON checksum_verifications(doc_id);
|
|
```
|
|
|
|
### Table `oauth_sessions`
|
|
|
|
Sessions OAuth2 avec refresh tokens chiffrés.
|
|
|
|
```sql
|
|
CREATE TABLE oauth_sessions (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
session_id TEXT NOT NULL UNIQUE, -- ID session Gorilla
|
|
user_sub TEXT NOT NULL, -- OAuth user ID
|
|
refresh_token_encrypted BYTEA NOT NULL, -- Chiffré AES-256-GCM
|
|
access_token_expires_at TIMESTAMPTZ,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
last_refreshed_at TIMESTAMPTZ,
|
|
user_agent TEXT,
|
|
ip_address INET
|
|
);
|
|
|
|
CREATE INDEX idx_oauth_sessions_session_id ON oauth_sessions(session_id);
|
|
CREATE INDEX idx_oauth_sessions_user_sub ON oauth_sessions(user_sub);
|
|
CREATE INDEX idx_oauth_sessions_updated_at ON oauth_sessions(updated_at);
|
|
```
|
|
|
|
**Sécurité** :
|
|
- Refresh tokens chiffrés (AES-256-GCM)
|
|
- Cleanup automatique après 37 jours
|
|
- Tracking IP + User-Agent pour détecter vols
|
|
|
|
### Table `email_queue`
|
|
|
|
File d'attente d'emails asynchrone avec mécanisme de retry.
|
|
|
|
```sql
|
|
CREATE TABLE email_queue (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
|
|
-- Métadonnées email
|
|
to_addresses TEXT[] NOT NULL, -- Adresses email destinataires
|
|
cc_addresses TEXT[], -- Adresses CC (optionnel)
|
|
bcc_addresses TEXT[], -- Adresses BCC (optionnel)
|
|
subject TEXT NOT NULL, -- Sujet de l'email
|
|
template TEXT NOT NULL, -- Nom du template (ex: 'reminder')
|
|
locale TEXT NOT NULL DEFAULT 'fr', -- Langue email (en, fr, es, de, it)
|
|
data JSONB NOT NULL DEFAULT '{}', -- Variables du template
|
|
headers JSONB, -- Headers email personnalisés (optionnel)
|
|
|
|
-- Gestion de la file
|
|
status TEXT NOT NULL DEFAULT 'pending' -- pending, processing, sent, failed, cancelled
|
|
CHECK (status IN ('pending', 'processing', 'sent', 'failed', 'cancelled')),
|
|
priority INT NOT NULL DEFAULT 0, -- Plus élevé = traité en premier (0=normal, 10=high, 100=urgent)
|
|
retry_count INT NOT NULL DEFAULT 0, -- Nombre de tentatives de retry
|
|
max_retries INT NOT NULL DEFAULT 3, -- Limite maximale de retry
|
|
|
|
-- Suivi
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
scheduled_for TIMESTAMPTZ NOT NULL DEFAULT now(), -- Heure de traitement la plus tôt
|
|
processed_at TIMESTAMPTZ, -- Quand l'email a été envoyé
|
|
next_retry_at TIMESTAMPTZ, -- Heure de retry calculée (exponential backoff)
|
|
|
|
-- Suivi des erreurs
|
|
last_error TEXT, -- Dernier message d'erreur
|
|
error_details JSONB, -- Informations d'erreur détaillées
|
|
|
|
-- Suivi des références (optionnel)
|
|
reference_type TEXT, -- ex: 'reminder', 'notification'
|
|
reference_id TEXT, -- ex: doc_id
|
|
created_by TEXT -- Utilisateur qui a mis en file l'email
|
|
);
|
|
|
|
-- Index pour traitement efficace de la file
|
|
CREATE INDEX idx_email_queue_status_scheduled
|
|
ON email_queue(status, scheduled_for)
|
|
WHERE status IN ('pending', 'processing');
|
|
|
|
CREATE INDEX idx_email_queue_priority_scheduled
|
|
ON email_queue(priority DESC, scheduled_for ASC)
|
|
WHERE status = 'pending';
|
|
|
|
CREATE INDEX idx_email_queue_retry
|
|
ON email_queue(next_retry_at)
|
|
WHERE status = 'processing' AND retry_count < max_retries;
|
|
|
|
CREATE INDEX idx_email_queue_reference
|
|
ON email_queue(reference_type, reference_id);
|
|
|
|
CREATE INDEX idx_email_queue_created_at
|
|
ON email_queue(created_at DESC);
|
|
```
|
|
|
|
**Fonctionnalités** :
|
|
- **Traitement asynchrone** : Emails traités par worker en arrière-plan
|
|
- **Mécanisme de retry** : Exponential backoff (1min, 2min, 4min, 8min, 16min, 32min...)
|
|
- **Support de priorité** : Emails haute priorité traités en premier
|
|
- **Envoi programmé** : Retarder la livraison d'email avec `scheduled_for`
|
|
- **Suivi des erreurs** : Logging détaillé des erreurs et historique des retries
|
|
- **Suivi des références** : Lier les emails aux documents ou autres entités
|
|
|
|
**Calcul automatique du retry** :
|
|
```sql
|
|
-- Fonction pour calculer le temps de retry suivant avec exponential backoff
|
|
CREATE OR REPLACE FUNCTION calculate_next_retry_time(retry_count INT)
|
|
RETURNS TIMESTAMPTZ AS $$
|
|
BEGIN
|
|
-- Exponential backoff: 1min, 2min, 4min, 8min, 16min, 32min...
|
|
RETURN now() + (interval '1 minute' * power(2, retry_count));
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
```
|
|
|
|
**Configuration du worker** :
|
|
- Taille de lot : 10 emails par lot
|
|
- Intervalle de polling : 5 secondes
|
|
- Envois concurrents : 5 emails simultanés
|
|
- Cleanup des anciens emails : Rétention de 7 jours pour emails envoyés/échoués
|
|
|
|
## Migrations
|
|
|
|
### Gestion des Migrations
|
|
|
|
Les migrations sont dans `/backend/migrations/` avec le format :
|
|
|
|
```
|
|
XXXX_description.up.sql # Migration "up"
|
|
XXXX_description.down.sql # Rollback "down"
|
|
```
|
|
|
|
**Fichiers actuels** :
|
|
- `0001_init.up.sql` - Table signatures
|
|
- `0002_expected_signers.up.sql` - Expected signers
|
|
- `0003_reminder_logs.up.sql` - Reminder logs
|
|
- `0004_add_name_to_expected_signers.up.sql` - Noms signataires
|
|
- `0005_create_documents_table.up.sql` - Documents metadata
|
|
- `0006_create_new_tables.up.sql` - Checksum verifications et email queue
|
|
- `0007_oauth_sessions.up.sql` - OAuth sessions avec refresh tokens
|
|
|
|
### Appliquer les Migrations
|
|
|
|
**Via Docker Compose** (automatique) :
|
|
|
|
```bash
|
|
docker compose up -d
|
|
# Le service ackify-migrate applique les migrations au démarrage
|
|
```
|
|
|
|
**Manuellement** :
|
|
|
|
```bash
|
|
cd backend
|
|
go run ./cmd/migrate up
|
|
```
|
|
|
|
**Rollback dernière migration** :
|
|
|
|
```bash
|
|
go run ./cmd/migrate down
|
|
```
|
|
|
|
### Migrations Personnalisées
|
|
|
|
Pour créer une nouvelle migration :
|
|
|
|
1. Créer `XXXX_my_feature.up.sql` :
|
|
```sql
|
|
-- Migration up
|
|
ALTER TABLE signatures ADD COLUMN new_field TEXT;
|
|
```
|
|
|
|
2. Créer `XXXX_my_feature.down.sql` :
|
|
```sql
|
|
-- Rollback
|
|
ALTER TABLE signatures DROP COLUMN new_field;
|
|
```
|
|
|
|
3. Appliquer :
|
|
```bash
|
|
go run ./cmd/migrate up
|
|
```
|
|
|
|
## Triggers PostgreSQL
|
|
|
|
### Immutabilité de `created_at`
|
|
|
|
Trigger qui empêche la modification de `created_at` :
|
|
|
|
```sql
|
|
CREATE OR REPLACE FUNCTION prevent_created_at_update()
|
|
RETURNS TRIGGER AS $$
|
|
BEGIN
|
|
IF NEW.created_at <> OLD.created_at THEN
|
|
RAISE EXCEPTION 'created_at cannot be modified';
|
|
END IF;
|
|
RETURN NEW;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
CREATE TRIGGER prevent_signatures_created_at_update
|
|
BEFORE UPDATE ON signatures
|
|
FOR EACH ROW
|
|
EXECUTE FUNCTION prevent_created_at_update();
|
|
```
|
|
|
|
**Garantie** : Aucune signature ne peut être backdatée.
|
|
|
|
### Auto-update de `updated_at`
|
|
|
|
Pour les tables avec `updated_at` :
|
|
|
|
```sql
|
|
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
|
RETURNS TRIGGER AS $$
|
|
BEGIN
|
|
NEW.updated_at = now();
|
|
RETURN NEW;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
CREATE TRIGGER update_documents_updated_at
|
|
BEFORE UPDATE ON documents
|
|
FOR EACH ROW
|
|
EXECUTE FUNCTION update_updated_at_column();
|
|
```
|
|
|
|
## Requêtes Utiles
|
|
|
|
### Voir les signatures d'un document
|
|
|
|
```sql
|
|
SELECT
|
|
user_email,
|
|
user_name,
|
|
signed_at,
|
|
payload_hash,
|
|
signature
|
|
FROM signatures
|
|
WHERE doc_id = 'my_document'
|
|
ORDER BY signed_at DESC;
|
|
```
|
|
|
|
### Statut de complétion
|
|
|
|
```sql
|
|
WITH expected AS (
|
|
SELECT COUNT(*) as total
|
|
FROM expected_signers
|
|
WHERE doc_id = 'my_document'
|
|
),
|
|
signed AS (
|
|
SELECT COUNT(*) as count
|
|
FROM signatures s
|
|
INNER JOIN expected_signers e ON s.user_email = e.email AND s.doc_id = e.doc_id
|
|
WHERE s.doc_id = 'my_document'
|
|
)
|
|
SELECT
|
|
e.total as expected,
|
|
s.count as signed,
|
|
ROUND(100.0 * s.count / NULLIF(e.total, 0), 2) as completion_pct
|
|
FROM expected e, signed s;
|
|
```
|
|
|
|
### Signataires manquants
|
|
|
|
```sql
|
|
SELECT
|
|
e.email,
|
|
e.name,
|
|
e.added_at
|
|
FROM expected_signers e
|
|
LEFT JOIN signatures s ON e.email = s.user_email AND e.doc_id = s.doc_id
|
|
WHERE e.doc_id = 'my_document' AND s.id IS NULL
|
|
ORDER BY e.added_at;
|
|
```
|
|
|
|
### Signatures inattendues
|
|
|
|
```sql
|
|
SELECT
|
|
s.user_email,
|
|
s.signed_at
|
|
FROM signatures s
|
|
LEFT JOIN expected_signers e ON s.user_email = e.email AND s.doc_id = e.doc_id
|
|
WHERE s.doc_id = 'my_document' AND e.id IS NULL
|
|
ORDER BY s.signed_at DESC;
|
|
```
|
|
|
|
### Statut de la file d'emails
|
|
|
|
```sql
|
|
-- Voir les emails en attente
|
|
SELECT
|
|
id,
|
|
to_addresses,
|
|
subject,
|
|
status,
|
|
priority,
|
|
retry_count,
|
|
scheduled_for,
|
|
created_at
|
|
FROM email_queue
|
|
WHERE status IN ('pending', 'processing')
|
|
ORDER BY priority DESC, scheduled_for ASC
|
|
LIMIT 20;
|
|
|
|
-- Emails échoués nécessitant attention
|
|
SELECT
|
|
id,
|
|
to_addresses,
|
|
subject,
|
|
retry_count,
|
|
max_retries,
|
|
last_error,
|
|
next_retry_at
|
|
FROM email_queue
|
|
WHERE status = 'failed'
|
|
ORDER BY created_at DESC;
|
|
|
|
-- Statistiques des emails par statut
|
|
SELECT
|
|
status,
|
|
COUNT(*) as count,
|
|
MIN(created_at) as oldest,
|
|
MAX(created_at) as newest
|
|
FROM email_queue
|
|
GROUP BY status
|
|
ORDER BY status;
|
|
```
|
|
|
|
## Sauvegarde & Restauration
|
|
|
|
### Backup PostgreSQL
|
|
|
|
```bash
|
|
# Backup complet
|
|
docker compose exec ackify-db pg_dump -U ackifyr ackify > backup.sql
|
|
|
|
# Backup avec compression
|
|
docker compose exec ackify-db pg_dump -U ackifyr ackify | gzip > backup.sql.gz
|
|
```
|
|
|
|
### Restore
|
|
|
|
```bash
|
|
# Restore depuis backup
|
|
cat backup.sql | docker compose exec -T ackify-db psql -U ackifyr ackify
|
|
|
|
# Restore depuis backup compressé
|
|
gunzip -c backup.sql.gz | docker compose exec -T ackify-db psql -U ackifyr ackify
|
|
```
|
|
|
|
### Backup Automatisé
|
|
|
|
Exemple de cron pour backup quotidien :
|
|
|
|
```bash
|
|
0 2 * * * docker compose -f /path/to/compose.yml exec -T ackify-db pg_dump -U ackifyr ackify | gzip > /backups/ackify-$(date +\%Y\%m\%d).sql.gz
|
|
```
|
|
|
|
## Performance
|
|
|
|
### Index
|
|
|
|
Les index sont automatiquement créés pour :
|
|
- `signatures(doc_id)` - Requêtes par document
|
|
- `signatures(user_sub)` - Requêtes par utilisateur
|
|
- `expected_signers(doc_id)` - Tracking complétion
|
|
- `oauth_sessions(session_id)` - Lookup sessions
|
|
|
|
### Connection Pooling
|
|
|
|
Le backend Go gère automatiquement le pooling de connexions :
|
|
- Max open connections : 25
|
|
- Max idle connections : 5
|
|
- Connection max lifetime : 5 minutes
|
|
|
|
### Vacuum & Analyze
|
|
|
|
PostgreSQL gère automatiquement via `autovacuum`. Pour forcer :
|
|
|
|
```sql
|
|
VACUUM ANALYZE signatures;
|
|
VACUUM ANALYZE documents;
|
|
```
|
|
|
|
## Monitoring
|
|
|
|
### Taille des tables
|
|
|
|
```sql
|
|
SELECT
|
|
schemaname,
|
|
tablename,
|
|
pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS size
|
|
FROM pg_tables
|
|
WHERE schemaname = 'public'
|
|
ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC;
|
|
```
|
|
|
|
### Statistiques
|
|
|
|
```sql
|
|
SELECT * FROM pg_stat_user_tables WHERE schemaname = 'public';
|
|
```
|
|
|
|
### Connexions actives
|
|
|
|
```sql
|
|
SELECT
|
|
datname,
|
|
usename,
|
|
application_name,
|
|
client_addr,
|
|
state,
|
|
query
|
|
FROM pg_stat_activity
|
|
WHERE datname = 'ackify';
|
|
```
|
|
|
|
## Sécurité
|
|
|
|
### En Production
|
|
|
|
- ✅ Utiliser SSL : `?sslmode=require` dans le DSN
|
|
- ✅ Mot de passe fort pour PostgreSQL
|
|
- ✅ Restreindre les connexions réseau
|
|
- ✅ Sauvegardes chiffrées
|
|
- ✅ Rotation régulière des secrets
|
|
|
|
### Configuration SSL
|
|
|
|
```bash
|
|
# Dans .env
|
|
ACKIFY_DB_DSN=postgres://user:pass@host:5432/ackify?sslmode=require
|
|
```
|
|
|
|
### Audit Trail
|
|
|
|
Toutes les opérations importantes sont tracées :
|
|
- `signatures.created_at` - Horodatage signature
|
|
- `expected_signers.added_by` - Qui a ajouté
|
|
- `reminder_logs.sent_by` - Qui a envoyé le rappel
|
|
- `checksum_verifications.verified_by` - Qui a vérifié
|
|
|
|
## Troubleshooting
|
|
|
|
### Migrations bloquées
|
|
|
|
```bash
|
|
# Vérifier le statut
|
|
docker compose logs ackify-migrate
|
|
|
|
# Forcer le rollback
|
|
docker compose exec ackify-ce /app/migrate down
|
|
docker compose exec ackify-ce /app/migrate up
|
|
```
|
|
|
|
### Contrainte UNIQUE violée
|
|
|
|
Erreur : `duplicate key value violates unique constraint`
|
|
|
|
**Cause** : L'utilisateur a déjà signé ce document.
|
|
|
|
**Solution** : C'est un comportement normal (une signature par user/doc).
|
|
|
|
### Connection refused
|
|
|
|
Vérifier que PostgreSQL est démarré :
|
|
|
|
```bash
|
|
docker compose ps ackify-db
|
|
docker compose logs ackify-db
|
|
```
|