import { eq, sql, desc } from 'drizzle-orm'; import { db, sqlite, logger } from '../config.js'; import { users, qsos, syncJobs, adminActions, awardProgress, qsoChanges } from '../db/schema/index.js'; import { getUserByIdFull, isAdmin } from './auth.service.js'; /** * Log an admin action for audit trail * @param {number} adminId - Admin user ID * @param {string} actionType - Type of action (e.g., 'impersonate_start', 'role_change') * @param {number|null} targetUserId - Target user ID (if applicable) * @param {Object} details - Additional details (will be JSON stringified) * @returns {Promise} Created admin action record */ export async function logAdminAction(adminId, actionType, targetUserId = null, details = {}) { const [action] = await db .insert(adminActions) .values({ adminId, actionType, targetUserId, details: JSON.stringify(details), }) .returning(); return action; } /** * Get admin actions log * @param {number} adminId - Admin user ID (optional, if null returns all actions) * @param {Object} options - Query options * @param {number} options.limit - Number of records to return * @param {number} options.offset - Number of records to skip * @returns {Promise} Array of admin actions */ export async function getAdminActions(adminId = null, { limit = 50, offset = 0 } = {}) { // Use raw SQL for the self-join (admin users and target users from same users table) // Using bun:sqlite prepared statements for raw SQL let query = ` SELECT aa.id as id, aa.admin_id as adminId, admin_user.email as adminEmail, admin_user.callsign as adminCallsign, aa.action_type as actionType, aa.target_user_id as targetUserId, target_user.email as targetEmail, target_user.callsign as targetCallsign, aa.details as details, aa.created_at as createdAt FROM admin_actions aa LEFT JOIN users admin_user ON admin_user.id = aa.admin_id LEFT JOIN users target_user ON target_user.id = aa.target_user_id `; const params = []; if (adminId !== null) { query += ` WHERE aa.admin_id = ?`; params.push(adminId); } query += ` ORDER BY aa.created_at DESC LIMIT ? OFFSET ?`; params.push(limit, offset); return sqlite.prepare(query).all(...params); } /** * Get system-wide statistics * @returns {Promise} System statistics */ export async function getSystemStats() { const [ userStats, qsoStats, syncJobStats, adminStats, ] = await Promise.all([ // User statistics db.select({ totalUsers: sql`CAST(COUNT(*) AS INTEGER)`, adminUsers: sql`CAST(SUM(CASE WHEN is_admin = 1 THEN 1 ELSE 0 END) AS INTEGER)`, regularUsers: sql`CAST(SUM(CASE WHEN is_admin = 0 THEN 1 ELSE 0 END) AS INTEGER)`, }).from(users), // QSO statistics db.select({ totalQSOs: sql`CAST(COUNT(*) AS INTEGER)`, uniqueCallsigns: sql`CAST(COUNT(DISTINCT callsign) AS INTEGER)`, uniqueEntities: sql`CAST(COUNT(DISTINCT entity_id) AS INTEGER)`, lotwConfirmed: sql`CAST(SUM(CASE WHEN lotw_qsl_rstatus = 'Y' THEN 1 ELSE 0 END) AS INTEGER)`, dclConfirmed: sql`CAST(SUM(CASE WHEN dcl_qsl_rstatus = 'Y' THEN 1 ELSE 0 END) AS INTEGER)`, }).from(qsos), // Sync job statistics db.select({ totalJobs: sql`CAST(COUNT(*) AS INTEGER)`, lotwJobs: sql`CAST(SUM(CASE WHEN type = 'lotw_sync' THEN 1 ELSE 0 END) AS INTEGER)`, dclJobs: sql`CAST(SUM(CASE WHEN type = 'dcl_sync' THEN 1 ELSE 0 END) AS INTEGER)`, completedJobs: sql`CAST(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS INTEGER)`, failedJobs: sql`CAST(SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS INTEGER)`, }).from(syncJobs), // Admin action statistics db.select({ totalAdminActions: sql`CAST(COUNT(*) AS INTEGER)`, impersonations: sql`CAST(SUM(CASE WHEN action_type LIKE 'impersonate%' THEN 1 ELSE 0 END) AS INTEGER)`, }).from(adminActions), ]); return { users: userStats[0], qsos: qsoStats[0], syncJobs: syncJobStats[0], adminActions: adminStats[0], }; } /** * Get per-user statistics (for admin overview) * @returns {Promise} Array of user statistics */ export async function getUserStats() { const stats = await db .select({ id: users.id, email: users.email, callsign: users.callsign, isAdmin: users.isAdmin, qsoCount: sql`CAST(COUNT(${qsos.id}) AS INTEGER)`, lotwConfirmed: sql`CAST(SUM(CASE WHEN ${qsos.lotwQslRstatus} = 'Y' THEN 1 ELSE 0 END) AS INTEGER)`, dclConfirmed: sql`CAST(SUM(CASE WHEN ${qsos.dclQslRstatus} = 'Y' THEN 1 ELSE 0 END) AS INTEGER)`, totalConfirmed: sql`CAST(SUM(CASE WHEN ${qsos.lotwQslRstatus} = 'Y' OR ${qsos.dclQslRstatus} = 'Y' THEN 1 ELSE 0 END) AS INTEGER)`, lastSync: sql`( SELECT MAX(${syncJobs.completedAt}) FROM ${syncJobs} WHERE ${syncJobs.userId} = ${users.id} AND ${syncJobs.status} = 'completed' )`.mapWith(Number), createdAt: users.createdAt, }) .from(users) .leftJoin(qsos, eq(users.id, qsos.userId)) .groupBy(users.id) .orderBy(sql`COUNT(${qsos.id}) DESC`); // Convert lastSync timestamps (seconds) to Date objects for JSON serialization return stats.map(stat => ({ ...stat, lastSync: stat.lastSync ? new Date(stat.lastSync * 1000) : null, })); } /** * Impersonate a user * @param {number} adminId - Admin user ID * @param {number} targetUserId - Target user ID to impersonate * @returns {Promise} Target user object * @throws {Error} If not admin or trying to impersonate another admin */ export async function impersonateUser(adminId, targetUserId) { // Verify the requester is an admin const requesterIsAdmin = await isAdmin(adminId); if (!requesterIsAdmin) { throw new Error('Only admins can impersonate users'); } // Get target user const targetUser = await getUserByIdFull(targetUserId); if (!targetUser) { throw new Error('Target user not found'); } // Check if target is also an admin (prevent admin impersonation) if (targetUser.isAdmin) { throw new Error('Cannot impersonate another admin user'); } // Log impersonation action await logAdminAction(adminId, 'impersonate_start', targetUserId, { targetEmail: targetUser.email, targetCallsign: targetUser.callsign, }); return targetUser; } /** * Verify impersonation token is valid * @param {Object} impersonationToken - JWT token payload containing impersonation data * @returns {Promise} Verification result with target user data */ export async function verifyImpersonation(impersonationToken) { const { adminId, targetUserId, exp } = impersonationToken; // Check if token is expired if (Date.now() > exp * 1000) { throw new Error('Impersonation token has expired'); } // Verify admin still exists and is admin const adminUser = await getUserByIdFull(adminId); if (!adminUser || !adminUser.isAdmin) { throw new Error('Invalid impersonation: Admin no longer exists or is not admin'); } // Get target user const targetUser = await getUserByIdFull(targetUserId); if (!targetUser) { throw new Error('Target user not found'); } // Return target user with admin metadata for frontend display return { ...targetUser, impersonating: { adminId, adminEmail: adminUser.email, adminCallsign: adminUser.callsign, }, }; } /** * Stop impersonating a user * @param {number} adminId - Admin user ID * @param {number} targetUserId - Target user ID being impersonated * @returns {Promise} */ export async function stopImpersonation(adminId, targetUserId) { await logAdminAction(adminId, 'impersonate_stop', targetUserId, { message: 'Impersonation session ended', }); } /** * Get impersonation status for an admin * @param {number} adminId - Admin user ID * @param {Object} options - Query options * @param {number} options.limit - Number of recent impersonations to return * @returns {Promise} Array of recent impersonation actions */ export async function getImpersonationStatus(adminId, { limit = 10 } = {}) { // Use raw SQL for the self-join to avoid Drizzle alias issues // Using bun:sqlite prepared statements for raw SQL const query = ` SELECT aa.id as id, aa.action_type as actionType, aa.target_user_id as targetUserId, u.email as targetEmail, u.callsign as targetCallsign, aa.details as details, aa.created_at as createdAt FROM admin_actions aa LEFT JOIN users u ON u.id = aa.target_user_id WHERE aa.admin_id = ? AND aa.action_type LIKE 'impersonate%' ORDER BY aa.created_at DESC LIMIT ? `; return sqlite.prepare(query).all(adminId, limit); } /** * Update user admin status (admin operation) * @param {number} adminId - Admin user ID making the change * @param {number} targetUserId - User ID to update * @param {boolean} newIsAdmin - New admin flag * @returns {Promise} * @throws {Error} If not admin or would remove last admin */ export async function changeUserRole(adminId, targetUserId, newIsAdmin) { // Verify the requester is an admin const requesterIsAdmin = await isAdmin(adminId); if (!requesterIsAdmin) { throw new Error('Only admins can change user admin status'); } // Get target user const targetUser = await getUserByIdFull(targetUserId); if (!targetUser) { throw new Error('Target user not found'); } // If demoting from admin, check if this would remove the last admin if (targetUser.isAdmin && !newIsAdmin) { const adminCount = await db .select({ count: sql`CAST(COUNT(*) AS INTEGER)` }) .from(users) .where(eq(users.isAdmin, 1)); if (adminCount[0].count === 1) { throw new Error('Cannot demote the last admin user'); } } // Update admin status await db .update(users) .set({ isAdmin: newIsAdmin ? 1 : 0, updatedAt: new Date(), }) .where(eq(users.id, targetUserId)); // Log action await logAdminAction(adminId, 'role_change', targetUserId, { oldIsAdmin: targetUser.isAdmin, newIsAdmin: newIsAdmin, }); } /** * Delete user (admin operation) * @param {number} adminId - Admin user ID making the change * @param {number} targetUserId - User ID to delete * @returns {Promise} * @throws {Error} If not admin, trying to delete self, or trying to delete another admin */ export async function deleteUser(adminId, targetUserId) { // Verify the requester is an admin const requesterIsAdmin = await isAdmin(adminId); if (!requesterIsAdmin) { throw new Error('Only admins can delete users'); } // Get target user const targetUser = await getUserByIdFull(targetUserId); if (!targetUser) { throw new Error('Target user not found'); } // Prevent deleting self if (adminId === targetUserId) { throw new Error('Cannot delete your own account'); } // Prevent deleting other admins if (targetUser.isAdmin) { throw new Error('Cannot delete admin users'); } // Get stats for logging const [qsoStats] = await db .select({ count: sql`CAST(COUNT(*) AS INTEGER)` }) .from(qsos) .where(eq(qsos.userId, targetUserId)); // Delete all related records using Drizzle // Delete in correct order to satisfy foreign key constraints logger.info('Attempting to delete user', { userId: targetUserId, adminId }); try { // 1. Delete qso_changes (references qso_id -> qsos and job_id -> sync_jobs) // First get user's QSO IDs, then delete qso_changes referencing those QSOs const userQSOs = await db.select({ id: qsos.id }).from(qsos).where(eq(qsos.userId, targetUserId)); const userQSOIds = userQSOs.map(q => q.id); if (userQSOIds.length > 0) { // Use raw SQL to delete qso_changes sqlite.exec( `DELETE FROM qso_changes WHERE qso_id IN (${userQSOIds.join(',')})` ); } // 2. Delete award_progress await db.delete(awardProgress).where(eq(awardProgress.userId, targetUserId)); // 3. Delete sync_jobs await db.delete(syncJobs).where(eq(syncJobs.userId, targetUserId)); // 4. Delete qsos await db.delete(qsos).where(eq(qsos.userId, targetUserId)); // 5. Delete admin actions where user is target await db.delete(adminActions).where(eq(adminActions.targetUserId, targetUserId)); // 6. Delete user await db.delete(users).where(eq(users.id, targetUserId)); // Log action await logAdminAction(adminId, 'user_delete', targetUserId, { email: targetUser.email, callsign: targetUser.callsign, qsoCountDeleted: qsoStats.count, }); logger.info('User deleted successfully', { userId: targetUserId, adminId }); } catch (error) { logger.error('Failed to delete user', { error: error.message, userId: targetUserId }); throw error; } // Log action await logAdminAction(adminId, 'user_delete', targetUserId, { email: targetUser.email, callsign: targetUser.callsign, qsoCountDeleted: qsoStats.count, }); }