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} 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()),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
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,
|
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
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user