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:
2026-01-15 11:01:10 +01:00
commit 8c26fc93e3
41 changed files with 3424 additions and 0 deletions

View 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();
}

View 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 };

View 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
View 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
View 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
View 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(),
}),
}
);
};

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