Adds last_seen field to track when users last accessed the tool: - Add lastSeen column to users table schema (nullable timestamp) - Create migration to add last_seen column to existing databases - Add updateLastSeen() function to auth.service.js - Update auth derive middleware to update last_seen on each authenticated request (async, non-blocking) - Add lastSeen to admin getUserStats() query for display in admin users table - Add "Last Seen" column to admin users table in frontend Co-Authored-By: Claude <noreply@anthropic.com>
407 lines
13 KiB
JavaScript
407 lines
13 KiB
JavaScript
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<Object>} 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>} 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<Object>} 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>} 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,
|
|
lastSeen: users.lastSeen,
|
|
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 timestamps (seconds) to Date objects for JSON serialization
|
|
// Note: lastSeen from Drizzle is already a Date object (timestamp mode)
|
|
// lastSync is raw SQL returning seconds, needs conversion
|
|
return stats.map(stat => ({
|
|
...stat,
|
|
lastSync: stat.lastSync ? new Date(stat.lastSync * 1000) : null,
|
|
// lastSeen is already a Date object from Drizzle, don't convert
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Impersonate a user
|
|
* @param {number} adminId - Admin user ID
|
|
* @param {number} targetUserId - Target user ID to impersonate
|
|
* @returns {Promise<Object>} 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<Object>} 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<void>}
|
|
*/
|
|
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>} 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<void>}
|
|
* @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<void>}
|
|
* @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,
|
|
});
|
|
}
|