From d1e4c39ad6d53f1fcfb5735c2950d1b69619d9e4 Mon Sep 17 00:00:00 2001 From: Joerg Date: Fri, 23 Jan 2026 09:57:45 +0100 Subject: [PATCH] feat: add last_seen tracking for users 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 --- src/backend/db/schema/index.js | 2 + src/backend/index.js | 9 +++ src/backend/migrations/add-last-seen.js | 86 ++++++++++++++++++++++ src/backend/services/admin.service.js | 6 +- src/backend/services/auth.service.js | 15 ++++ src/frontend/src/routes/admin/+page.svelte | 2 + 6 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 src/backend/migrations/add-last-seen.js diff --git a/src/backend/db/schema/index.js b/src/backend/db/schema/index.js index 670bcfd..7356c80 100644 --- a/src/backend/db/schema/index.js +++ b/src/backend/db/schema/index.js @@ -10,6 +10,7 @@ import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'; * @property {string|null} lotwPassword * @property {string|null} dclApiKey * @property {boolean} isAdmin + * @property {Date|null} lastSeen * @property {Date} createdAt * @property {Date} updatedAt */ @@ -23,6 +24,7 @@ export const users = sqliteTable('users', { lotwPassword: text('lotw_password'), // Encrypted dclApiKey: text('dcl_api_key'), // DCL API key for future use isAdmin: integer('is_admin', { mode: 'boolean' }).notNull().default(false), + lastSeen: integer('last_seen', { mode: 'timestamp' }), createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()), updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()), }); diff --git a/src/backend/index.js b/src/backend/index.js index 21406fa..0bda214 100644 --- a/src/backend/index.js +++ b/src/backend/index.js @@ -12,6 +12,7 @@ import { getUserById, updateLoTWCredentials, updateDCLCredentials, + updateLastSeen, } from './services/auth.service.js'; import { getSystemStats, @@ -207,6 +208,14 @@ const app = new Elysia() return { user: null }; } + // Update last_seen timestamp asynchronously (don't await) + updateLastSeen(payload.userId).catch((err) => { + // Silently fail - last_seen update failure shouldn't block requests + if (LOG_LEVEL === 'debug') { + logger.warn('Failed to update last_seen', { error: err.message }); + } + }); + // Check if this is an impersonation token const isImpersonation = !!payload.impersonatedBy; diff --git a/src/backend/migrations/add-last-seen.js b/src/backend/migrations/add-last-seen.js new file mode 100644 index 0000000..4d3ad5a --- /dev/null +++ b/src/backend/migrations/add-last-seen.js @@ -0,0 +1,86 @@ +/** + * Migration: Add last_seen column to users table + * + * This script adds the last_seen column to track when users last accessed the tool. + */ + +import Database from 'bun:sqlite'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +// ES module equivalent of __dirname +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const dbPath = join(__dirname, '../award.db'); +const sqlite = new Database(dbPath); + +async function migrate() { + console.log('Starting migration: Add last_seen column...'); + + try { + // Check if last_seen column already exists + const columnExists = sqlite.query(` + SELECT COUNT(*) as count + FROM pragma_table_info('users') + WHERE name='last_seen' + `).get(); + + if (columnExists.count > 0) { + console.log('Column last_seen already exists. Skipping...'); + } else { + // Add last_seen column + sqlite.exec(` + ALTER TABLE users + ADD COLUMN last_seen INTEGER + `); + + console.log('Added last_seen column to users table'); + } + + console.log('Migration complete! last_seen column added to database.'); + } catch (error) { + console.error('Migration failed:', error); + sqlite.close(); + process.exit(1); + } + + sqlite.close(); +} + +async function rollback() { + console.log('Starting rollback: Remove last_seen column...'); + + try { + // SQLite doesn't support DROP COLUMN directly before version 3.35.5 + // For older versions, we need to recreate the table + console.log('Note: SQLite does not support DROP COLUMN. Manual cleanup required.'); + console.log('To rollback: Recreate users table without last_seen column'); + + // For SQLite 3.35.5+, you can use: + // sqlite.exec(`ALTER TABLE users DROP COLUMN last_seen`); + + console.log('Rollback note issued.'); + } catch (error) { + console.error('Rollback failed:', error); + sqlite.close(); + process.exit(1); + } + + sqlite.close(); +} + +// Check if this is a rollback +const args = process.argv.slice(2); +if (args.includes('--rollback') || args.includes('-r')) { + rollback().then(() => { + console.log('Rollback script completed'); + process.exit(0); + }); +} else { + // Run migration + migrate().then(() => { + console.log('Migration script completed successfully'); + process.exit(0); + }); +} diff --git a/src/backend/services/admin.service.js b/src/backend/services/admin.service.js index 70d54c3..72f3d4c 100644 --- a/src/backend/services/admin.service.js +++ b/src/backend/services/admin.service.js @@ -127,6 +127,7 @@ export async function getUserStats() { 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)`, @@ -144,10 +145,13 @@ export async function getUserStats() { .groupBy(users.id) .orderBy(sql`COUNT(${qsos.id}) DESC`); - // Convert lastSync timestamps (seconds) to Date objects for JSON serialization + // 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 })); } diff --git a/src/backend/services/auth.service.js b/src/backend/services/auth.service.js index 07a5268..ee42183 100644 --- a/src/backend/services/auth.service.js +++ b/src/backend/services/auth.service.js @@ -204,6 +204,7 @@ export async function getAllUsers() { email: users.email, callsign: users.callsign, isAdmin: users.isAdmin, + lastSeen: users.lastSeen, createdAt: users.createdAt, updatedAt: users.updatedAt, }) @@ -236,3 +237,17 @@ export async function getUserByIdFull(userId) { return user || null; } + +/** + * Update user's last seen timestamp + * @param {number} userId - User ID + * @returns {Promise} + */ +export async function updateLastSeen(userId) { + await db + .update(users) + .set({ + lastSeen: new Date(), + }) + .where(eq(users.id, userId)); +} diff --git a/src/frontend/src/routes/admin/+page.svelte b/src/frontend/src/routes/admin/+page.svelte index 5f6b33d..b9cfe3d 100644 --- a/src/frontend/src/routes/admin/+page.svelte +++ b/src/frontend/src/routes/admin/+page.svelte @@ -336,6 +336,7 @@ DCL Conf. Total Conf. Last Sync + Last Seen Actions @@ -355,6 +356,7 @@ {user.dclConfirmed || 0} {user.totalConfirmed || 0} {formatDate(user.lastSync)} + {formatDate(user.lastSeen)}