feat(auth): migrate user store from SQLite to PostgreSQL
Build & Push Docker Images / test-backend (push) Successful in 44s
Build & Push Docker Images / test-frontend (push) Successful in 53s
Build & Push Docker Images / build-backend (push) Failing after 50s
Build & Push Docker Images / build-frontend (push) Successful in 54s

Users were wiped on container recreation because they shared the
backend_data volume with ephemeral pharmacy/medicine data. Postgres
gets its own named volume (postgres_data) that survives deploys.

- Add postgres:16-alpine service + postgres_data volume to compose
- Add pg + connect-pg-simple deps
- userDbGet/userDbRun helpers: PG when PG_URL set, SQLite fallback
- Sessions use connect-pg-simple when pgPool available
- create-admin.js: PG-aware, SQLite fallback for local dev
- push_subscriptions.user_id: plain INTEGER (cross-DB FK dropped)
- Tests unchanged — no PG_URL in test env = SQLite fallback

Set PG_PASSWORD in server .env before next deploy.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Antoni Nuñez Romeu
2026-05-25 17:50:34 +02:00
parent 34733c121c
commit 31b3eb21b4
6 changed files with 319 additions and 96 deletions
+79 -29
View File
@@ -6,6 +6,8 @@ import path from 'path';
import { fileURLToPath } from 'url';
import session from 'express-session';
import connectSqlite3 from 'connect-sqlite3';
import connectPg from 'connect-pg-simple';
import pg from 'pg';
import bcrypt from 'bcrypt';
import rateLimit from 'express-rate-limit';
import webpush from 'web-push';
@@ -38,7 +40,12 @@ app.use(cors({
}));
app.use(express.json());
const SQLiteStore = connectSqlite3(session);
const PG_URL = process.env.PG_URL;
let pgPool = null;
if (PG_URL) {
const { Pool } = pg;
pgPool = new Pool({ connectionString: PG_URL });
}
const sessionConfig = {
secret: process.env.SESSION_SECRET || 'farma-finder-secret-key-change-in-production',
@@ -52,7 +59,13 @@ const sessionConfig = {
};
if (process.env.NODE_ENV !== 'test') {
sessionConfig.store = new SQLiteStore({ db: 'sessions.sqlite', dir: __dirname });
if (pgPool) {
const PgStore = connectPg(session);
sessionConfig.store = new PgStore({ pool: pgPool, createTableIfMissing: true });
} else {
const SQLiteStore = connectSqlite3(session);
sessionConfig.store = new SQLiteStore({ db: 'sessions.sqlite', dir: __dirname });
}
}
// Configure session
@@ -90,6 +103,28 @@ function dbRun(sql, params = []) {
const dbAll = promisify(db.all.bind(db));
const dbGet = promisify(db.get.bind(db));
// User DB helpers — route to PG when available, fallback to SQLite for tests
function toPositional(sql) {
let i = 0;
return sql.replace(/\?/g, () => `$${++i}`);
}
async function userDbGet(sql, params = []) {
if (pgPool) {
const res = await pgPool.query(toPositional(sql), params);
return res.rows[0] ?? null;
}
return dbGet(sql, params);
}
async function userDbRun(sql, params = []) {
if (pgPool) {
const res = await pgPool.query(toPositional(sql), params);
return { lastID: res.rows[0]?.id, changes: res.rowCount };
}
return dbRun(sql.replace(/\s+RETURNING\s+\w+\s*$/i, ''), params);
}
// Accept opening_hours as either an object or a JSON string from the client
// and return a TEXT value (or null) safe to persist.
function serializeOpeningHours(value) {
@@ -152,15 +187,30 @@ async function initDatabase() {
await dbRun(`CREATE INDEX IF NOT EXISTS idx_pharmacy_medicine ON pharmacy_medicines(medicine_nregistro)`);
// Create users table for authentication
await dbRun(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// Create users table — PG when available, SQLite fallback
if (pgPool) {
await pgPool.query(`
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
address TEXT,
latitude DOUBLE PRECISION,
longitude DOUBLE PRECISION,
created_at TIMESTAMPTZ DEFAULT NOW()
)
`);
} else {
await dbRun(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
}
await dbRun(`
CREATE TABLE IF NOT EXISTS push_subscriptions (
@@ -191,17 +241,17 @@ async function initDatabase() {
`);
await dbRun(`CREATE INDEX IF NOT EXISTS idx_push_subs_pharm ON push_subscriptions_pharmacy(medicine_nregistro, pharmacy_id)`);
// Add is_admin to users table if not yet present (migration for existing DBs)
try { await dbRun('ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0'); } catch {}
if (!pgPool) {
// SQLite-only migrations for existing deployments
try { await dbRun('ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0'); } catch {}
try { await dbRun('ALTER TABLE users ADD COLUMN address TEXT'); } catch {}
try { await dbRun('ALTER TABLE users ADD COLUMN latitude REAL'); } catch {}
try { await dbRun('ALTER TABLE users ADD COLUMN longitude REAL'); } catch {}
}
// Profile fields on users (migration for existing DBs)
try { await dbRun('ALTER TABLE users ADD COLUMN address TEXT'); } catch {}
try { await dbRun('ALTER TABLE users ADD COLUMN latitude REAL'); } catch {}
try { await dbRun('ALTER TABLE users ADD COLUMN longitude REAL'); } catch {}
// Add user_id to push tables if not yet present (migration for existing DBs)
try { await dbRun('ALTER TABLE push_subscriptions ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE SET NULL'); } catch {}
try { await dbRun('ALTER TABLE push_subscriptions_pharmacy ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE SET NULL'); } catch {}
// Add user_id to push tables (SQLite — no cross-DB FK)
try { await dbRun('ALTER TABLE push_subscriptions ADD COLUMN user_id INTEGER'); } catch {}
try { await dbRun('ALTER TABLE push_subscriptions_pharmacy ADD COLUMN user_id INTEGER'); } catch {}
console.log('Database initialized successfully');
} catch (error) {
@@ -323,7 +373,7 @@ app.post('/api/auth/login', loginLimiter, async (req, res) => {
}
// Find user by username
const user = await dbGet(
const user = await userDbGet(
'SELECT * FROM users WHERE username = ?',
[username.trim()]
);
@@ -375,14 +425,14 @@ app.post('/api/auth/register', registerLimiter, async (req, res) => {
return res.status(400).json({ error: 'Password must be at least 8 characters' });
}
const existing = await dbGet('SELECT id FROM users WHERE username = ?', [u]);
const existing = await userDbGet('SELECT id FROM users WHERE username = ?', [u]);
if (existing) {
return res.status(409).json({ error: 'Username already taken' });
}
const passwordHash = await bcrypt.hash(p, 10);
const result = await dbRun(
'INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, 0)',
const result = await userDbRun(
'INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, 0) RETURNING id',
[u, passwordHash]
);
@@ -422,7 +472,7 @@ app.post('/api/auth/logout', (req, res) => {
app.get('/api/auth/check', async (req, res) => {
try {
if (req.session && req.session.userId) {
const user = await dbGet(
const user = await userDbGet(
'SELECT id, username, is_admin, address, latitude, longitude FROM users WHERE id = ?',
[req.session.userId]
);
@@ -452,7 +502,7 @@ app.get('/api/auth/check', async (req, res) => {
// Current user's profile
app.get('/api/users/me', requireAuth, async (req, res) => {
try {
const user = await dbGet(
const user = await userDbGet(
'SELECT id, username, is_admin, address, latitude, longitude FROM users WHERE id = ?',
[req.session.userId]
);
@@ -495,12 +545,12 @@ app.put('/api/users/me', requireAuth, async (req, res) => {
}
}
await dbRun(
await userDbRun(
'UPDATE users SET address = ?, latitude = ?, longitude = ? WHERE id = ?',
[addr, lat, lon, req.session.userId]
);
const user = await dbGet(
const user = await userDbGet(
'SELECT id, username, is_admin, address, latitude, longitude FROM users WHERE id = ?',
[req.session.userId]
);