Files
award/src/backend/db/schema/index.js
Joerg ed433902d9 feat: add super-admin role with admin impersonation support
Add a new super-admin role that can impersonate other admins. Regular
admins retain all existing permissions but cannot impersonate other
admins or promote users to super-admin.

Backend changes:
- Add isSuperAdmin field to users table with default false
- Add isSuperAdmin() check function to auth service
- Update JWT tokens to include isSuperAdmin claim
- Allow super-admins to impersonate other admins
- Add security rules for super-admin role changes

Frontend changes:
- Display "Super Admin" badge with gradient styling
- Add "Super Admin" option to role change modal
- Enable impersonate button for super-admins targeting admins
- Add "Super Admins Only" filter option

Security rules:
- Only super-admins can promote/demote super-admins
- Regular admins cannot promote users to super-admin
- Super-admins cannot demote themselves
- Cannot demote the last super-admin

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 13:32:55 +01:00

266 lines
10 KiB
JavaScript

import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
/**
* @typedef {Object} User
* @property {number} id
* @property {string} email
* @property {string} passwordHash
* @property {string} callsign
* @property {string|null} lotwUsername
* @property {string|null} lotwPassword
* @property {string|null} dclApiKey
* @property {boolean} isAdmin
* @property {boolean} isSuperAdmin
* @property {Date|null} lastSeen
* @property {Date} createdAt
* @property {Date} updatedAt
*/
export const users = sqliteTable('users', {
id: integer('id').primaryKey({ autoIncrement: true }),
email: text('email').notNull().unique(),
passwordHash: text('password_hash').notNull(),
callsign: text('callsign').notNull(),
lotwUsername: text('lotw_username'),
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),
isSuperAdmin: integer('is_super_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()),
});
/**
* @typedef {Object} QSO
* @property {number} id
* @property {number} userId
* @property {string} callsign
* @property {string} qsoDate
* @property {string} timeOn
* @property {string|null} band
* @property {string|null} mode
* @property {number|null} freq
* @property {number|null} freqRx
* @property {string|null} entity
* @property {number|null} entityId
* @property {string|null} grid
* @property {string|null} gridSource
* @property {string|null} continent
* @property {number|null} cqZone
* @property {number|null} ituZone
* @property {string|null} state
* @property {string|null} county
* @property {string|null} satName
* @property {string|null} satMode
* @property {string|null} myDarcDok
* @property {string|null} darcDok
* @property {string|null} lotwQslRdate
* @property {string|null} lotwQslRstatus
* @property {string|null} dclQslRdate
* @property {string|null} dclQslRstatus
* @property {Date|null} lotwSyncedAt
* @property {Date} createdAt
*/
export const qsos = sqliteTable('qsos', {
id: integer('id').primaryKey({ autoIncrement: true }),
userId: integer('user_id').notNull().references(() => users.id),
// QSO fields
callsign: text('callsign').notNull(),
qsoDate: text('qso_date').notNull(), // ADIF format: YYYYMMDD
timeOn: text('time_on').notNull(), // HHMMSS
band: text('band'), // 160m, 80m, 40m, etc.
mode: text('mode'), // CW, SSB, FT8, etc.
freq: integer('freq'), // Frequency in Hz
freqRx: integer('freq_rx'), // RX frequency (satellite)
// Entity/location fields
entity: text('entity'), // DXCC entity name
entityId: integer('entity_id'), // DXCC entity number
grid: text('grid'), // Maidenhead grid square
gridSource: text('grid_source'), // LOTW, USER, CALC
continent: text('continent'), // NA, SA, EU, AF, AS, OC, AN
cqZone: integer('cq_zone'),
ituZone: integer('itu_zone'),
state: text('state'), // US state, CA province, etc.
county: text('county'),
// Satellite fields
satName: text('sat_name'),
satMode: text('sat_mode'),
// DARC DOK fields (DARC Ortsverband Kennung - German local club identifier)
myDarcDok: text('my_darc_dok'), // User's own DOK (e.g., 'F03', 'P30')
darcDok: text('darc_dok'), // QSO partner's DOK
// LoTW confirmation
lotwQslRdate: text('lotw_qsl_rdate'), // Confirmation date
lotwQslRstatus: text('lotw_qsl_rstatus'), // 'Y', 'N', '?'
// DCL confirmation (DARC Community Logbook)
dclQslRdate: text('dcl_qsl_rdate'), // Confirmation date
dclQslRstatus: text('dcl_qsl_rstatus'), // 'Y', 'N', '?'
// Cache metadata
lotwSyncedAt: integer('lotw_synced_at', { mode: 'timestamp' }),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
});
/**
* @typedef {Object} Award
* @property {string} id
* @property {string} name
* @property {string|null} description
* @property {string} definition
* @property {boolean} isActive
* @property {Date} createdAt
*/
export const awards = sqliteTable('awards', {
id: text('id').primaryKey(), // 'dxcc', 'was', 'vucc'
name: text('name').notNull(),
description: text('description'),
definition: text('definition').notNull(), // JSON rule definition
isActive: integer('is_active', { mode: 'boolean' }).notNull().default(true),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
});
/**
* @typedef {Object} AwardProgress
* @property {number} id
* @property {number} userId
* @property {string} awardId
* @property {number} workedCount
* @property {number} confirmedCount
* @property {number} totalRequired
* @property {string|null} workedEntities
* @property {string|null} confirmedEntities
* @property {Date|null} lastCalculatedAt
* @property {Date|null} lastQsoSyncAt
* @property {Date} updatedAt
*/
export const awardProgress = sqliteTable('award_progress', {
id: integer('id').primaryKey({ autoIncrement: true }),
userId: integer('user_id').notNull().references(() => users.id),
awardId: text('award_id').notNull().references(() => awards.id),
// Calculated progress
workedCount: integer('worked_count').notNull().default(0),
confirmedCount: integer('confirmed_count').notNull().default(0),
totalRequired: integer('total_required').notNull(),
// Detailed breakdown (JSON)
workedEntities: text('worked_entities'), // JSON array
confirmedEntities: text('confirmed_entities'), // JSON array
// Cache control
lastCalculatedAt: integer('last_calculated_at', { mode: 'timestamp' }),
lastQsoSyncAt: integer('last_qso_sync_at', { mode: 'timestamp' }),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
});
/**
* @typedef {Object} SyncJob
* @property {number} id
* @property {number} userId
* @property {string} status
* @property {string} type
* @property {Date|null} startedAt
* @property {Date|null} completedAt
* @property {string|null} result
* @property {string|null} error
* @property {Date} createdAt
*/
export const syncJobs = sqliteTable('sync_jobs', {
id: integer('id').primaryKey({ autoIncrement: true }),
userId: integer('user_id').notNull().references(() => users.id),
status: text('status').notNull(), // pending, running, completed, failed
type: text('type').notNull(), // lotw_sync, etc.
startedAt: integer('started_at', { mode: 'timestamp' }),
completedAt: integer('completed_at', { mode: 'timestamp' }),
result: text('result'), // JSON string
error: text('error'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
});
/**
* @typedef {Object} QSOChange
* @property {number} id
* @property {number} jobId
* @property {number|null} qsoId
* @property {string} changeType - 'added' or 'updated'
* @property {string|null} beforeData - JSON snapshot before change (for updates)
* @property {string|null} afterData - JSON snapshot after change
* @property {Date} createdAt
*/
export const qsoChanges = sqliteTable('qso_changes', {
id: integer('id').primaryKey({ autoIncrement: true }),
jobId: integer('job_id').notNull().references(() => syncJobs.id),
qsoId: integer('qso_id').references(() => qsos.id), // null for added QSOs until created
changeType: text('change_type').notNull(), // 'added' or 'updated'
beforeData: text('before_data'), // JSON snapshot before change
afterData: text('after_data'), // JSON snapshot after change
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
});
/**
* @typedef {Object} AdminAction
* @property {number} id
* @property {number} adminId
* @property {string} actionType
* @property {number|null} targetUserId
* @property {string|null} details
* @property {Date} createdAt
*/
export const adminActions = sqliteTable('admin_actions', {
id: integer('id').primaryKey({ autoIncrement: true }),
adminId: integer('admin_id').notNull().references(() => users.id),
actionType: text('action_type').notNull(), // 'impersonate_start', 'impersonate_stop', 'role_change', 'user_delete', etc.
targetUserId: integer('target_user_id').references(() => users.id),
details: text('details'), // JSON with additional context
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
});
/**
* @typedef {Object} AutoSyncSettings
* @property {number} userId
* @property {boolean} lotwEnabled
* @property {number} lotwIntervalHours
* @property {Date|null} lotwLastSyncAt
* @property {Date|null} lotwNextSyncAt
* @property {boolean} dclEnabled
* @property {number} dclIntervalHours
* @property {Date|null} dclLastSyncAt
* @property {Date|null} dclNextSyncAt
* @property {Date} createdAt
* @property {Date} updatedAt
*/
export const autoSyncSettings = sqliteTable('auto_sync_settings', {
userId: integer('user_id').primaryKey().references(() => users.id),
// LoTW auto-sync settings
lotwEnabled: integer('lotw_enabled', { mode: 'boolean' }).notNull().default(false),
lotwIntervalHours: integer('lotw_interval_hours').notNull().default(24),
lotwLastSyncAt: integer('lotw_last_sync_at', { mode: 'timestamp' }),
lotwNextSyncAt: integer('lotw_next_sync_at', { mode: 'timestamp' }),
// DCL auto-sync settings
dclEnabled: integer('dcl_enabled', { mode: 'boolean' }).notNull().default(false),
dclIntervalHours: integer('dcl_interval_hours').notNull().default(24),
dclLastSyncAt: integer('dcl_last_sync_at', { mode: 'timestamp' }),
dclNextSyncAt: integer('dcl_next_sync_at', { mode: 'timestamp' }),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
});
// Export all schemas
export const schema = { users, qsos, awards, awardProgress, syncJobs, qsoChanges, adminActions, autoSyncSettings };