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
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:
+79
-29
@@ -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]
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user