/** * Database Migrations * * Handles database schema creation and migrations for authentication and authorization system. */ import { logger } from '../logger.js'; import type { DatabaseAdapter } from './interface.js'; import { createDatabaseAdapter } from './factory.js'; // @ts-ignore - bcrypt doesn't have proper ESM types import bcrypt from 'bcrypt'; const SALT_ROUNDS = 10; export interface Migration { name: string; up: (db: DatabaseAdapter) => Promise; down?: (db: DatabaseAdapter) => Promise; } const isPostgres = (): boolean => { return process.env.DATABASE_TYPE === 'postgres' || process.env.DATABASE_TYPE === 'postgresql'; }; const getTimestamp = (): string => { return new Date().toISOString(); }; /** * Create users table */ async function createUsersTable(db: DatabaseAdapter): Promise { const isPg = isPostgres(); const schema = isPg ? ` CREATE TABLE IF NOT EXISTS users ( id SERIAL PRIMARY KEY, email TEXT UNIQUE NOT NULL, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, display_name TEXT, is_active BOOLEAN DEFAULT true, email_verified BOOLEAN DEFAULT false, email_verification_token TEXT, password_reset_token TEXT, password_reset_expires TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, last_login TEXT ); CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); CREATE INDEX IF NOT EXISTS idx_users_email_verification_token ON users(email_verification_token); CREATE INDEX IF NOT EXISTS idx_users_password_reset_token ON users(password_reset_token); ` : ` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT UNIQUE NOT NULL, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, display_name TEXT, is_active INTEGER DEFAULT 1, email_verified INTEGER DEFAULT 0, email_verification_token TEXT, password_reset_token TEXT, password_reset_expires TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, last_login TEXT ); CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); CREATE INDEX IF NOT EXISTS idx_users_email_verification_token ON users(email_verification_token); CREATE INDEX IF NOT EXISTS idx_users_password_reset_token ON users(password_reset_token); `; await db.exec(schema); } /** * Create roles table */ async function createRolesTable(db: DatabaseAdapter): Promise { const isPg = isPostgres(); const schema = isPg ? ` CREATE TABLE IF NOT EXISTS roles ( id SERIAL PRIMARY KEY, name TEXT UNIQUE NOT NULL, description TEXT, is_system_role BOOLEAN DEFAULT false, created_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_roles_name ON roles(name); ` : ` CREATE TABLE IF NOT EXISTS roles ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL, description TEXT, is_system_role INTEGER DEFAULT 0, created_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_roles_name ON roles(name); `; await db.exec(schema); } /** * Create permissions table */ async function createPermissionsTable(db: DatabaseAdapter): Promise { const isPg = isPostgres(); const schema = isPg ? ` CREATE TABLE IF NOT EXISTS permissions ( id SERIAL PRIMARY KEY, name TEXT UNIQUE NOT NULL, description TEXT, resource TEXT ); CREATE INDEX IF NOT EXISTS idx_permissions_name ON permissions(name); CREATE INDEX IF NOT EXISTS idx_permissions_resource ON permissions(resource); ` : ` CREATE TABLE IF NOT EXISTS permissions ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL, description TEXT, resource TEXT ); CREATE INDEX IF NOT EXISTS idx_permissions_name ON permissions(name); CREATE INDEX IF NOT EXISTS idx_permissions_resource ON permissions(resource); `; await db.exec(schema); } /** * Create role_permissions junction table */ async function createRolePermissionsTable(db: DatabaseAdapter): Promise { const isPg = isPostgres(); const schema = isPg ? ` CREATE TABLE IF NOT EXISTS role_permissions ( role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE, permission_id INTEGER NOT NULL REFERENCES permissions(id) ON DELETE CASCADE, PRIMARY KEY (role_id, permission_id) ); CREATE INDEX IF NOT EXISTS idx_role_permissions_role_id ON role_permissions(role_id); CREATE INDEX IF NOT EXISTS idx_role_permissions_permission_id ON role_permissions(permission_id); ` : ` CREATE TABLE IF NOT EXISTS role_permissions ( role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE, permission_id INTEGER NOT NULL REFERENCES permissions(id) ON DELETE CASCADE, PRIMARY KEY (role_id, permission_id) ); CREATE INDEX IF NOT EXISTS idx_role_permissions_role_id ON role_permissions(role_id); CREATE INDEX IF NOT EXISTS idx_role_permissions_permission_id ON role_permissions(permission_id); `; await db.exec(schema); } /** * Create user_roles junction table */ async function createUserRolesTable(db: DatabaseAdapter): Promise { const isPg = isPostgres(); const schema = isPg ? ` CREATE TABLE IF NOT EXISTS user_roles ( user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE, assigned_at TEXT NOT NULL, PRIMARY KEY (user_id, role_id) ); CREATE INDEX IF NOT EXISTS idx_user_roles_user_id ON user_roles(user_id); CREATE INDEX IF NOT EXISTS idx_user_roles_role_id ON user_roles(role_id); ` : ` CREATE TABLE IF NOT EXISTS user_roles ( user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE, assigned_at TEXT NOT NULL, PRIMARY KEY (user_id, role_id) ); CREATE INDEX IF NOT EXISTS idx_user_roles_user_id ON user_roles(user_id); CREATE INDEX IF NOT EXISTS idx_user_roles_role_id ON user_roles(role_id); `; await db.exec(schema); } /** * Create user_settings table */ async function createUserSettingsTable(db: DatabaseAdapter): Promise { const isPg = isPostgres(); const schema = isPg ? ` CREATE TABLE IF NOT EXISTS user_settings ( user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, jira_pat TEXT, jira_pat_encrypted BOOLEAN DEFAULT true, ai_enabled BOOLEAN DEFAULT false, ai_provider TEXT, ai_api_key TEXT, web_search_enabled BOOLEAN DEFAULT false, tavily_api_key TEXT, updated_at TEXT NOT NULL ); ` : ` CREATE TABLE IF NOT EXISTS user_settings ( user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, jira_pat TEXT, jira_pat_encrypted INTEGER DEFAULT 1, ai_enabled INTEGER DEFAULT 0, ai_provider TEXT, ai_api_key TEXT, web_search_enabled INTEGER DEFAULT 0, tavily_api_key TEXT, updated_at TEXT NOT NULL ); `; await db.exec(schema); } /** * Create sessions table */ async function createSessionsTable(db: DatabaseAdapter): Promise { const isPg = isPostgres(); const schema = isPg ? ` CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, auth_method TEXT NOT NULL, access_token TEXT, refresh_token TEXT, expires_at TEXT NOT NULL, created_at TEXT NOT NULL, ip_address TEXT, user_agent TEXT ); CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); CREATE INDEX IF NOT EXISTS idx_sessions_auth_method ON sessions(auth_method); ` : ` CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, auth_method TEXT NOT NULL, access_token TEXT, refresh_token TEXT, expires_at TEXT NOT NULL, created_at TEXT NOT NULL, ip_address TEXT, user_agent TEXT ); CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); CREATE INDEX IF NOT EXISTS idx_sessions_auth_method ON sessions(auth_method); `; await db.exec(schema); } /** * Create email_tokens table */ async function createEmailTokensTable(db: DatabaseAdapter): Promise { const isPg = isPostgres(); const schema = isPg ? ` CREATE TABLE IF NOT EXISTS email_tokens ( id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, token TEXT UNIQUE NOT NULL, type TEXT NOT NULL, expires_at TEXT NOT NULL, used BOOLEAN DEFAULT false, created_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_email_tokens_token ON email_tokens(token); CREATE INDEX IF NOT EXISTS idx_email_tokens_user_id ON email_tokens(user_id); CREATE INDEX IF NOT EXISTS idx_email_tokens_type ON email_tokens(type); CREATE INDEX IF NOT EXISTS idx_email_tokens_expires_at ON email_tokens(expires_at); ` : ` CREATE TABLE IF NOT EXISTS email_tokens ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, token TEXT UNIQUE NOT NULL, type TEXT NOT NULL, expires_at TEXT NOT NULL, used INTEGER DEFAULT 0, created_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_email_tokens_token ON email_tokens(token); CREATE INDEX IF NOT EXISTS idx_email_tokens_user_id ON email_tokens(user_id); CREATE INDEX IF NOT EXISTS idx_email_tokens_type ON email_tokens(type); CREATE INDEX IF NOT EXISTS idx_email_tokens_expires_at ON email_tokens(expires_at); `; await db.exec(schema); } /** * Seed initial data */ async function seedInitialData(db: DatabaseAdapter): Promise { const isPg = isPostgres(); const now = getTimestamp(); // Check if roles already exist const existingRoles = await db.query('SELECT COUNT(*) as count FROM roles'); const roleCount = isPg ? (existingRoles[0] as any).count : (existingRoles[0] as any).count; // If roles exist, we still need to check if admin user exists // (roles might exist but admin user might not) const rolesExist = parseInt(roleCount) > 0; if (rolesExist) { logger.info('Roles already exist, checking if admin user needs to be created...'); } // Get existing role IDs if roles already exist const roleIds: Record = {}; if (!rolesExist) { // Insert default permissions const permissions = [ { name: 'search', description: 'Access search features', resource: 'search' }, { name: 'view_reports', description: 'View reports and dashboards', resource: 'reports' }, { name: 'edit_applications', description: 'Edit application components', resource: 'applications' }, { name: 'manage_users', description: 'Manage users and their roles', resource: 'users' }, { name: 'manage_roles', description: 'Manage roles and permissions', resource: 'roles' }, { name: 'manage_settings', description: 'Manage application settings', resource: 'settings' }, ]; for (const perm of permissions) { await db.execute( 'INSERT INTO permissions (name, description, resource) VALUES (?, ?, ?)', [perm.name, perm.description, perm.resource] ); } // Insert default roles const roles = [ { name: 'administrator', description: 'Full system access', isSystem: true }, { name: 'user', description: 'Basic user access', isSystem: true }, ]; for (const role of roles) { const isSystem = isPg ? role.isSystem : (role.isSystem ? 1 : 0); await db.execute( 'INSERT INTO roles (name, description, is_system_role, created_at) VALUES (?, ?, ?, ?)', [role.name, role.description, isSystem, now] ); // Get the inserted role ID const insertedRole = await db.queryOne<{ id: number }>( 'SELECT id FROM roles WHERE name = ?', [role.name] ); if (insertedRole) { roleIds[role.name] = insertedRole.id; } } // Assign all permissions to administrator role const allPermissions = await db.query<{ id: number }>('SELECT id FROM permissions'); for (const perm of allPermissions) { await db.execute( 'INSERT INTO role_permissions (role_id, permission_id) VALUES (?, ?)', [roleIds['administrator'], perm.id] ); } // Assign basic permissions to user role (search and view_reports) const searchPerm = await db.queryOne<{ id: number }>( 'SELECT id FROM permissions WHERE name = ?', ['search'] ); const viewReportsPerm = await db.queryOne<{ id: number }>( 'SELECT id FROM permissions WHERE name = ?', ['view_reports'] ); if (searchPerm) { await db.execute( 'INSERT INTO role_permissions (role_id, permission_id) VALUES (?, ?)', [roleIds['user'], searchPerm.id] ); } if (viewReportsPerm) { await db.execute( 'INSERT INTO role_permissions (role_id, permission_id) VALUES (?, ?)', [roleIds['user'], viewReportsPerm.id] ); } } else { // Roles exist - get their IDs const adminRole = await db.queryOne<{ id: number }>( 'SELECT id FROM roles WHERE name = ?', ['administrator'] ); if (adminRole) { roleIds['administrator'] = adminRole.id; } } // Create initial admin user if ADMIN_EMAIL and ADMIN_PASSWORD are set const adminEmail = process.env.ADMIN_EMAIL; const adminPassword = process.env.ADMIN_PASSWORD; const adminUsername = process.env.ADMIN_USERNAME || 'admin'; if (adminEmail && adminPassword) { // Check if admin user already exists const existingUser = await db.queryOne<{ id: number }>( 'SELECT id FROM users WHERE email = ? OR username = ?', [adminEmail, adminUsername] ); if (existingUser) { // User exists - check if they have admin role const hasAdminRole = await db.queryOne<{ role_id: number }>( 'SELECT role_id FROM user_roles WHERE user_id = ? AND role_id = ?', [existingUser.id, roleIds['administrator']] ); if (!hasAdminRole && roleIds['administrator']) { // Add admin role if missing await db.execute( 'INSERT INTO user_roles (user_id, role_id, assigned_at) VALUES (?, ?, ?)', [existingUser.id, roleIds['administrator'], now] ); logger.info(`Administrator role assigned to existing user: ${adminEmail}`); } else { logger.info(`Administrator user already exists: ${adminEmail}`); } } else { // Create new admin user const passwordHash = await bcrypt.hash(adminPassword, SALT_ROUNDS); const displayName = process.env.ADMIN_DISPLAY_NAME || 'Administrator'; await db.execute( 'INSERT INTO users (email, username, password_hash, display_name, is_active, email_verified, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [adminEmail, adminUsername, passwordHash, displayName, isPg ? true : 1, isPg ? true : 1, now, now] ); const adminUser = await db.queryOne<{ id: number }>( 'SELECT id FROM users WHERE email = ?', [adminEmail] ); if (adminUser && roleIds['administrator']) { await db.execute( 'INSERT INTO user_roles (user_id, role_id, assigned_at) VALUES (?, ?, ?)', [adminUser.id, roleIds['administrator'], now] ); logger.info(`Initial administrator user created: ${adminEmail}`); } } } else { logger.warn('ADMIN_EMAIL and ADMIN_PASSWORD not set - skipping initial admin user creation'); } logger.info('Initial data seeded successfully'); } /** * Main migration function */ export async function runMigrations(): Promise { const db = createDatabaseAdapter(); try { logger.info('Running database migrations...'); await createUsersTable(db); await createRolesTable(db); await createPermissionsTable(db); await createRolePermissionsTable(db); await createUserRolesTable(db); await createUserSettingsTable(db); await createSessionsTable(db); await createEmailTokensTable(db); await seedInitialData(db); logger.info('Database migrations completed successfully'); } catch (error) { logger.error('Migration failed:', error); throw error; } finally { await db.close(); } } // Singleton cache for auth database adapter let authDatabaseAdapter: DatabaseAdapter | null = null; /** * Get database adapter for auth operations * Uses a singleton pattern to avoid creating multiple adapters. * The adapter is configured to not close on close() calls, as it should * remain open for the application lifetime. */ export function getAuthDatabase(): DatabaseAdapter { if (!authDatabaseAdapter) { // Create adapter with allowClose=false so it won't be closed after operations authDatabaseAdapter = createDatabaseAdapter(undefined, undefined, false); } return authDatabaseAdapter; }