Initial commit: Ham Radio Award Portal
Features implemented: - User authentication (register/login) with JWT - SQLite database with Drizzle ORM - SvelteKit frontend with authentication flow - ElysiaJS backend with CORS enabled - Award definition JSON schemas (DXCC, WAS, VUCC, SAT) - Responsive dashboard with user profile Tech stack: - Backend: ElysiaJS, Drizzle ORM, SQLite, JWT - Frontend: SvelteKit, Svelte stores - Runtime: Bun - Language: JavaScript Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
23
src/backend/config/database.js
Normal file
23
src/backend/config/database.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import Database from 'bun:sqlite';
|
||||
import { drizzle } from 'drizzle-orm/bun-sqlite';
|
||||
import * as schema from '../db/schema/index.js';
|
||||
|
||||
// Create SQLite database connection
|
||||
const sqlite = new Database('./award.db');
|
||||
|
||||
// Enable foreign keys
|
||||
sqlite.exec('PRAGMA foreign_keys = ON');
|
||||
|
||||
// Create Drizzle instance
|
||||
export const db = drizzle({
|
||||
client: sqlite,
|
||||
schema,
|
||||
});
|
||||
|
||||
/**
|
||||
* Close database connection
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function closeDatabase() {
|
||||
sqlite.close();
|
||||
}
|
||||
9
src/backend/config/jwt.js
Normal file
9
src/backend/config/jwt.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { jwt } from '@elysiajs/jwt';
|
||||
|
||||
/**
|
||||
* JWT secret key - should be in environment variables in production
|
||||
* For development, we use a default value
|
||||
*/
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
|
||||
|
||||
export { JWT_SECRET };
|
||||
146
src/backend/db/schema/index.js
Normal file
146
src/backend/db/schema/index.js
Normal file
@@ -0,0 +1,146 @@
|
||||
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 {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
|
||||
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} lotwQslRdate
|
||||
* @property {string|null} lotwQslRstatus
|
||||
* @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'),
|
||||
|
||||
// LoTW confirmation
|
||||
lotwQslRdate: text('lotw_qsl_rdate'), // Confirmation date
|
||||
lotwQslRstatus: text('lotw_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()),
|
||||
});
|
||||
|
||||
// Export all schemas
|
||||
export const schema = { users, qsos, awards, awardProgress };
|
||||
216
src/backend/index.js
Normal file
216
src/backend/index.js
Normal file
@@ -0,0 +1,216 @@
|
||||
import { Elysia, t } from 'elysia';
|
||||
import { cors } from '@elysiajs/cors';
|
||||
import { jwt } from '@elysiajs/jwt';
|
||||
import { JWT_SECRET } from './config/jwt.js';
|
||||
import {
|
||||
registerUser,
|
||||
authenticateUser,
|
||||
getUserById,
|
||||
updateLoTWCredentials,
|
||||
} from './services/auth.service.js';
|
||||
|
||||
/**
|
||||
* Main backend application
|
||||
* Serves API routes
|
||||
*/
|
||||
const app = new Elysia()
|
||||
// Enable CORS for frontend communication
|
||||
.use(cors({
|
||||
origin: true, // Allow all origins in development
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
}))
|
||||
|
||||
// JWT plugin
|
||||
.use(jwt({
|
||||
name: 'jwt',
|
||||
secret: JWT_SECRET,
|
||||
}))
|
||||
|
||||
// Authentication: derive user from JWT token
|
||||
.derive(async ({ jwt, headers }) => {
|
||||
const authHeader = headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return { user: null };
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
try {
|
||||
const payload = await jwt.verify(token);
|
||||
if (!payload) {
|
||||
return { user: null };
|
||||
}
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: payload.userId,
|
||||
email: payload.email,
|
||||
callsign: payload.callsign,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return { user: null };
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/auth/register
|
||||
* Register a new user
|
||||
*/
|
||||
.post(
|
||||
'/api/auth/register',
|
||||
async ({ body, jwt, set }) => {
|
||||
try {
|
||||
// Create user
|
||||
const user = await registerUser(body);
|
||||
|
||||
// Generate JWT token
|
||||
const token = await jwt.sign({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
callsign: user.callsign,
|
||||
});
|
||||
|
||||
set.status = 201;
|
||||
return {
|
||||
success: true,
|
||||
token,
|
||||
user,
|
||||
};
|
||||
} catch (error) {
|
||||
set.status = 400;
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
email: t.String({
|
||||
format: 'email',
|
||||
error: 'Invalid email address',
|
||||
}),
|
||||
password: t.String({
|
||||
minLength: 8,
|
||||
error: 'Password must be at least 8 characters',
|
||||
}),
|
||||
callsign: t.String({
|
||||
minLength: 3,
|
||||
maxLength: 10,
|
||||
error: 'Callsign must be 3-10 characters',
|
||||
}),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /api/auth/login
|
||||
* Authenticate user and return JWT token
|
||||
*/
|
||||
.post(
|
||||
'/api/auth/login',
|
||||
async ({ body, jwt, set }) => {
|
||||
try {
|
||||
// Authenticate user
|
||||
const user = await authenticateUser(body.email, body.password);
|
||||
|
||||
// Generate JWT token
|
||||
const token = await jwt.sign({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
callsign: user.callsign,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
token,
|
||||
user,
|
||||
};
|
||||
} catch (error) {
|
||||
set.status = 401;
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid email or password',
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
email: t.String({ format: 'email' }),
|
||||
password: t.String(),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* GET /api/auth/me
|
||||
* Get current user profile (requires authentication)
|
||||
*/
|
||||
.get('/api/auth/me', async ({ user, set }) => {
|
||||
if (!user) {
|
||||
set.status = 401;
|
||||
return { success: false, error: 'Unauthorized' };
|
||||
}
|
||||
|
||||
// Get full user data from database
|
||||
const userData = await getUserById(user.id);
|
||||
if (!userData) {
|
||||
set.status = 404;
|
||||
return { success: false, error: 'User not found' };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: userData,
|
||||
};
|
||||
})
|
||||
|
||||
/**
|
||||
* PUT /api/auth/lotw-credentials
|
||||
* Update LoTW credentials (requires authentication)
|
||||
*/
|
||||
.put(
|
||||
'/api/auth/lotw-credentials',
|
||||
async ({ user, body, set }) => {
|
||||
if (!user) {
|
||||
set.status = 401;
|
||||
return { success: false, error: 'Unauthorized' };
|
||||
}
|
||||
|
||||
try {
|
||||
await updateLoTWCredentials(user.id, body.lotwUsername, body.lotwPassword);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'LoTW credentials updated successfully',
|
||||
};
|
||||
} catch (error) {
|
||||
set.status = 500;
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to update LoTW credentials',
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
lotwUsername: t.String(),
|
||||
lotwPassword: t.String(),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
// Health check endpoint
|
||||
.get('/api/health', () => ({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
}))
|
||||
|
||||
// Start server
|
||||
.listen(3001);
|
||||
|
||||
console.log(`🦊 Backend server running at http://localhost:${app.server?.port}`);
|
||||
console.log(`📡 API endpoints available at http://localhost:${app.server?.port}/api`);
|
||||
|
||||
export default app;
|
||||
26
src/backend/init-db.js
Normal file
26
src/backend/init-db.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import Database from 'bun:sqlite';
|
||||
import { drizzle } from 'drizzle-orm/bun-sqlite';
|
||||
import * as schema from './db/schema/index.js';
|
||||
|
||||
const sqlite = new Database('./award.db');
|
||||
const db = drizzle({
|
||||
client: sqlite,
|
||||
schema,
|
||||
});
|
||||
|
||||
console.log('Creating database tables...');
|
||||
|
||||
// Use drizzle-kit to push the schema
|
||||
// Since we don't have migrations, let's use the push command
|
||||
const { execSync } = await import('child_process');
|
||||
|
||||
try {
|
||||
execSync('bun drizzle-kit push', {
|
||||
cwd: '/Users/joergdorgeist/Dev/award',
|
||||
stdio: 'inherit'
|
||||
});
|
||||
console.log('✓ Database initialized successfully!');
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize database:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
166
src/backend/routes/auth.js
Normal file
166
src/backend/routes/auth.js
Normal file
@@ -0,0 +1,166 @@
|
||||
import { t } from 'elysia';
|
||||
import {
|
||||
registerUser,
|
||||
authenticateUser,
|
||||
getUserById,
|
||||
updateLoTWCredentials,
|
||||
} from '../services/auth.service.js';
|
||||
|
||||
/**
|
||||
* Authentication routes
|
||||
* Provides endpoints for user registration, login, and profile management
|
||||
* These routes will be added to the main app which already has authMiddleware
|
||||
*/
|
||||
export const authRoutes = (app) => {
|
||||
console.error('authRoutes function called with app');
|
||||
return app
|
||||
/**
|
||||
* POST /api/auth/register
|
||||
* Register a new user
|
||||
*/
|
||||
.post(
|
||||
'/api/auth/register',
|
||||
async ({ body, jwt, set }) => {
|
||||
try {
|
||||
// Create user
|
||||
const user = await registerUser(body);
|
||||
|
||||
// Generate JWT token
|
||||
const token = await jwt.sign({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
callsign: user.callsign,
|
||||
});
|
||||
|
||||
set.status = 201;
|
||||
return {
|
||||
success: true,
|
||||
token,
|
||||
user,
|
||||
};
|
||||
} catch (error) {
|
||||
set.status = 400;
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
email: t.String({
|
||||
format: 'email',
|
||||
error: 'Invalid email address',
|
||||
}),
|
||||
password: t.String({
|
||||
minLength: 8,
|
||||
error: 'Password must be at least 8 characters',
|
||||
}),
|
||||
callsign: t.String({
|
||||
minLength: 3,
|
||||
maxLength: 10,
|
||||
error: 'Callsign must be 3-10 characters',
|
||||
}),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /api/auth/login
|
||||
* Authenticate user and return JWT token
|
||||
*/
|
||||
.post(
|
||||
'/api/auth/login',
|
||||
async ({ body, jwt, set }) => {
|
||||
try {
|
||||
// Authenticate user
|
||||
const user = await authenticateUser(body.email, body.password);
|
||||
|
||||
// Generate JWT token
|
||||
const token = await jwt.sign({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
callsign: user.callsign,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
token,
|
||||
user,
|
||||
};
|
||||
} catch (error) {
|
||||
set.status = 401;
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid email or password',
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
email: t.String({ format: 'email' }),
|
||||
password: t.String(),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* GET /api/auth/me
|
||||
* Get current user profile (requires authentication)
|
||||
*/
|
||||
.get('/api/auth/me', async ({ user, set }) => {
|
||||
console.error('/me endpoint called, user:', user);
|
||||
if (!user) {
|
||||
console.error('No user in context - returning 401');
|
||||
set.status = 401;
|
||||
return { success: false, error: 'Unauthorized' };
|
||||
}
|
||||
|
||||
// Get full user data from database
|
||||
const userData = await getUserById(user.id);
|
||||
if (!userData) {
|
||||
set.status = 404;
|
||||
return { success: false, error: 'User not found' };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: userData,
|
||||
};
|
||||
})
|
||||
|
||||
/**
|
||||
* PUT /api/auth/lotw-credentials
|
||||
* Update LoTW credentials (requires authentication)
|
||||
*/
|
||||
.put(
|
||||
'/api/auth/lotw-credentials',
|
||||
async ({ user, body, set }) => {
|
||||
if (!user) {
|
||||
set.status = 401;
|
||||
return { success: false, error: 'Unauthorized' };
|
||||
}
|
||||
|
||||
try {
|
||||
await updateLoTWCredentials(user.id, body.lotwUsername, body.lotwPassword);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'LoTW credentials updated successfully',
|
||||
};
|
||||
} catch (error) {
|
||||
set.status = 500;
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to update LoTW credentials',
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
lotwUsername: t.String(),
|
||||
lotwPassword: t.String(),
|
||||
}),
|
||||
}
|
||||
);
|
||||
};
|
||||
132
src/backend/services/auth.service.js
Normal file
132
src/backend/services/auth.service.js
Normal file
@@ -0,0 +1,132 @@
|
||||
import bcrypt from 'bcrypt';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from '../config/database.js';
|
||||
import { users } from '../db/schema/index.js';
|
||||
|
||||
const SALT_ROUNDS = 10;
|
||||
|
||||
/**
|
||||
* Hash a password using bcrypt
|
||||
* @param {string} password - Plain text password
|
||||
* @returns {Promise<string>} Hashed password
|
||||
*/
|
||||
export async function hashPassword(password) {
|
||||
return bcrypt.hash(password, SALT_ROUNDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a password against a hash
|
||||
* @param {string} password - Plain text password
|
||||
* @param {string} hash - Hashed password
|
||||
* @returns {Promise<boolean>} True if password matches
|
||||
*/
|
||||
export async function verifyPassword(password, hash) {
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new user
|
||||
* @param {Object} userData - User registration data
|
||||
* @param {string} userData.email - User email
|
||||
* @param {string} userData.password - Plain text password
|
||||
* @param {string} userData.callsign - Ham radio callsign
|
||||
* @returns {Promise<Object>} Created user object (without password)
|
||||
* @throws {Error} If email already exists
|
||||
*/
|
||||
export async function registerUser({ email, password, callsign }) {
|
||||
// Check if user already exists
|
||||
const existingUser = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, email))
|
||||
.get();
|
||||
|
||||
if (existingUser) {
|
||||
throw new Error('Email already registered');
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
// Create user
|
||||
const newUser = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
email,
|
||||
passwordHash,
|
||||
callsign: callsign.toUpperCase(),
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Return user without password hash
|
||||
const { passwordHash: _, ...userWithoutPassword } = newUser[0];
|
||||
return userWithoutPassword;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate user with email and password
|
||||
* @param {string} email - User email
|
||||
* @param {string} password - Plain text password
|
||||
* @returns {Promise<Object>} User object (without password) if authenticated
|
||||
* @throws {Error} If credentials are invalid
|
||||
*/
|
||||
export async function authenticateUser(email, password) {
|
||||
// Find user by email
|
||||
const user = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, email))
|
||||
.get();
|
||||
|
||||
if (!user) {
|
||||
throw new Error('Invalid email or password');
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isValid = await verifyPassword(password, user.passwordHash);
|
||||
if (!isValid) {
|
||||
throw new Error('Invalid email or password');
|
||||
}
|
||||
|
||||
// Return user without password hash
|
||||
const { passwordHash: _, ...userWithoutPassword } = user;
|
||||
return userWithoutPassword;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user by ID
|
||||
* @param {number} userId - User ID
|
||||
* @returns {Promise<Object|null>} User object (without password) or null
|
||||
*/
|
||||
export async function getUserById(userId) {
|
||||
const user = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.get();
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const { passwordHash: _, ...userWithoutPassword } = user;
|
||||
return userWithoutPassword;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user's LoTW credentials (encrypted)
|
||||
* @param {number} userId - User ID
|
||||
* @param {string} lotwUsername - LoTW username
|
||||
* @param {string} lotwPassword - LoTW password (will be encrypted)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function updateLoTWCredentials(userId, lotwUsername, lotwPassword) {
|
||||
// Simple encryption for storage (in production, use a proper encryption library)
|
||||
// For now, we'll store as-is but marked for encryption
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
lotwUsername,
|
||||
lotwPassword, // TODO: Encrypt before storing
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(users.id, userId));
|
||||
}
|
||||
Reference in New Issue
Block a user