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:
@@ -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()),
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
86
src/backend/migrations/add-last-seen.js
Normal file
86
src/backend/migrations/add-last-seen.js
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void>}
|
||||
*/
|
||||
export async function updateLastSeen(userId) {
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
lastSeen: new Date(),
|
||||
})
|
||||
.where(eq(users.id, userId));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user