'use strict'; const path = require('path'); const Database = require('better-sqlite3'); const DB_PATH = process.env.DB_PATH || path.join(__dirname, 'data', 'datahub.db'); const db = new Database(DB_PATH); db.pragma('journal_mode = WAL'); db.pragma('foreign_keys = ON'); function init() { db.exec(` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, email TEXT UNIQUE NOT NULL CHECK (length(trim(email)) > 0), password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'user', -- 'admin' | 'user' mfa_enabled INTEGER NOT NULL DEFAULT 0, mfa_secret TEXT, avatar TEXT, -- stored filename of the user's picture avatar_mime TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS folders ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, parent_id INTEGER REFERENCES folders(id) ON DELETE CASCADE, -- Date the material in this folder was recorded (YYYY-MM-DD). -- "Legal Validity" is derived from it: recorded date + 30 days. recorded_date TEXT, created_by INTEGER REFERENCES users(id), created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS files ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, -- display name stored_name TEXT NOT NULL, -- name on disk size INTEGER NOT NULL DEFAULT 0, mime TEXT, folder_id INTEGER REFERENCES folders(id) ON DELETE CASCADE, uploaded_by INTEGER REFERENCES users(id), created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS access_requests ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id), target_type TEXT NOT NULL, -- 'file' | 'folder' target_id INTEGER NOT NULL, target_name TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', -- pending | approved | denied decided_by INTEGER REFERENCES users(id), -- How long the approval lasts ('24h' | '15d' | '30d' | 'forever') -- and when it runs out. NULL expiry means it never expires. access_duration TEXT, access_expires_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), decided_at TEXT ); CREATE TABLE IF NOT EXISTS email_templates ( key TEXT PRIMARY KEY, -- 'request_to_admin' | 'approval_to_user' | 'denial_to_user' subject TEXT NOT NULL, body_html TEXT NOT NULL, updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS brand_images ( id INTEGER PRIMARY KEY AUTOINCREMENT, label TEXT NOT NULL, stored_name TEXT NOT NULL, mime TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, ts TEXT NOT NULL DEFAULT (datetime('now')), actor TEXT, -- username or 'system' actor_id INTEGER, action TEXT NOT NULL, -- e.g. FILE_UPLOAD page TEXT, -- 'Data Management' | 'View Data' | ... detail TEXT, ip TEXT ); CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT ); -- Entries shown on the "Version Control" page (managed from Data Management) CREATE TABLE IF NOT EXISTS version_entries ( id INTEGER PRIMARY KEY AUTOINCREMENT, version TEXT NOT NULL, title TEXT, released_on TEXT, notes TEXT, sort_order INTEGER NOT NULL DEFAULT 0, created_by INTEGER REFERENCES users(id), created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT ); -- Entries shown on the "Legislation" page (managed from Data Management) CREATE TABLE IF NOT EXISTS legislation_entries ( id INTEGER PRIMARY KEY AUTOINCREMENT, reference TEXT, title TEXT NOT NULL, effective_date TEXT, summary TEXT, link_url TEXT, sort_order INTEGER NOT NULL DEFAULT 0, created_by INTEGER REFERENCES users(id), created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT ); -- Every message the system sends (and any inbound message that is -- recorded), independent of the general audit log. CREATE TABLE IF NOT EXISTS mail_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, ts TEXT NOT NULL DEFAULT (datetime('now')), direction TEXT NOT NULL DEFAULT 'sent', -- 'sent' | 'received' status TEXT NOT NULL, -- 'delivered' | 'queued' | 'failed' from_addr TEXT, to_addr TEXT, subject TEXT, body_preview TEXT, attachments TEXT, error TEXT, context TEXT, actor TEXT ); `); } /** * Add columns introduced after a database was first created. * SQLite's ALTER TABLE ADD COLUMN is safe and non-destructive: existing rows * simply get NULL for the new column. */ function migrate() { const folderCols = db.prepare('PRAGMA table_info(folders)').all().map((c) => c.name); if (!folderCols.includes('recorded_date')) { db.exec('ALTER TABLE folders ADD COLUMN recorded_date TEXT'); console.log('[ISDSS] Added folders.recorded_date (existing folders have no date yet).'); } const userCols = db.prepare('PRAGMA table_info(users)').all().map((c) => c.name); if (!userCols.includes('avatar')) { db.exec('ALTER TABLE users ADD COLUMN avatar TEXT'); db.exec('ALTER TABLE users ADD COLUMN avatar_mime TEXT'); console.log('[ISDSS] Added users.avatar (existing users have no picture yet).'); } const reqCols = db.prepare('PRAGMA table_info(access_requests)').all().map((c) => c.name); if (!reqCols.includes('access_duration')) { db.exec("ALTER TABLE access_requests ADD COLUMN access_duration TEXT"); // Approvals granted before this release had no expiry, so they are // permanent. Marking them explicitly keeps existing access working. db.exec("UPDATE access_requests SET access_duration = 'forever' WHERE status = 'approved'"); console.log('[ISDSS] Added access_requests.access_duration (existing approvals kept as permanent).'); } if (!reqCols.includes('access_expires_at')) { db.exec('ALTER TABLE access_requests ADD COLUMN access_expires_at TEXT'); console.log('[ISDSS] Added access_requests.access_expires_at.'); } } /** * Accounts with no email address cannot receive approval notifications. * New databases block this outright (CHECK constraint above); databases created * by earlier releases keep their original schema, so report any offenders at * startup instead of failing silently. */ function reportMissingEmails() { try { const rows = db.prepare( "SELECT username FROM users WHERE email IS NULL OR length(trim(email)) = 0" ).all(); if (rows.length) { console.warn( `[ISDSS] ${rows.length} account(s) have no email address: ` + `${rows.map((r) => r.username).join(', ')}. ` + 'Email is now mandatory — set one in Data Management → Users.' ); } return rows.length; } catch (_) { return 0; } } module.exports = { db, init, migrate, DB_PATH, reportMissingEmails };