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 <noreply@anthropic.com>
This commit is contained in:
2026-01-23 09:57:45 +01:00
parent 24e0e3bfdb
commit d1e4c39ad6
6 changed files with 119 additions and 1 deletions

View File

@@ -10,6 +10,7 @@ import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
* @property {string|null} lotwPassword * @property {string|null} lotwPassword
* @property {string|null} dclApiKey * @property {string|null} dclApiKey
* @property {boolean} isAdmin * @property {boolean} isAdmin
* @property {Date|null} lastSeen
* @property {Date} createdAt * @property {Date} createdAt
* @property {Date} updatedAt * @property {Date} updatedAt
*/ */
@@ -23,6 +24,7 @@ export const users = sqliteTable('users', {
lotwPassword: text('lotw_password'), // Encrypted lotwPassword: text('lotw_password'), // Encrypted
dclApiKey: text('dcl_api_key'), // DCL API key for future use dclApiKey: text('dcl_api_key'), // DCL API key for future use
isAdmin: integer('is_admin', { mode: 'boolean' }).notNull().default(false), 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()), createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()), updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
}); });

View File

@@ -12,6 +12,7 @@ import {
getUserById, getUserById,
updateLoTWCredentials, updateLoTWCredentials,
updateDCLCredentials, updateDCLCredentials,
updateLastSeen,
} from './services/auth.service.js'; } from './services/auth.service.js';
import { import {
getSystemStats, getSystemStats,
@@ -207,6 +208,14 @@ const app = new Elysia()
return { user: null }; 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 // Check if this is an impersonation token
const isImpersonation = !!payload.impersonatedBy; const isImpersonation = !!payload.impersonatedBy;

View File

@@ -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);
});
}

View File

@@ -127,6 +127,7 @@ export async function getUserStats() {
email: users.email, email: users.email,
callsign: users.callsign, callsign: users.callsign,
isAdmin: users.isAdmin, isAdmin: users.isAdmin,
lastSeen: users.lastSeen,
qsoCount: sql`CAST(COUNT(${qsos.id}) AS INTEGER)`, qsoCount: sql`CAST(COUNT(${qsos.id}) AS INTEGER)`,
lotwConfirmed: sql`CAST(SUM(CASE WHEN ${qsos.lotwQslRstatus} = 'Y' THEN 1 ELSE 0 END) 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)`, 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) .groupBy(users.id)
.orderBy(sql`COUNT(${qsos.id}) DESC`); .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 => ({ return stats.map(stat => ({
...stat, ...stat,
lastSync: stat.lastSync ? new Date(stat.lastSync * 1000) : null, lastSync: stat.lastSync ? new Date(stat.lastSync * 1000) : null,
// lastSeen is already a Date object from Drizzle, don't convert
})); }));
} }

View File

@@ -204,6 +204,7 @@ export async function getAllUsers() {
email: users.email, email: users.email,
callsign: users.callsign, callsign: users.callsign,
isAdmin: users.isAdmin, isAdmin: users.isAdmin,
lastSeen: users.lastSeen,
createdAt: users.createdAt, createdAt: users.createdAt,
updatedAt: users.updatedAt, updatedAt: users.updatedAt,
}) })
@@ -236,3 +237,17 @@ export async function getUserByIdFull(userId) {
return user || null; return user || null;
} }
/**
* Update user's last seen timestamp
* @param {number} userId - User ID
* @returns {Promise<void>}
*/
export async function updateLastSeen(userId) {
await db
.update(users)
.set({
lastSeen: new Date(),
})
.where(eq(users.id, userId));
}

View File

@@ -336,6 +336,7 @@
<th>DCL Conf.</th> <th>DCL Conf.</th>
<th>Total Conf.</th> <th>Total Conf.</th>
<th>Last Sync</th> <th>Last Sync</th>
<th>Last Seen</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
@@ -355,6 +356,7 @@
<td>{user.dclConfirmed || 0}</td> <td>{user.dclConfirmed || 0}</td>
<td>{user.totalConfirmed || 0}</td> <td>{user.totalConfirmed || 0}</td>
<td>{formatDate(user.lastSync)}</td> <td>{formatDate(user.lastSync)}</td>
<td>{formatDate(user.lastSeen)}</td>
<td class="actions-cell"> <td class="actions-cell">
<button <button
class="action-button impersonate-btn" class="action-button impersonate-btn"