feat: prepare database and UI for DCL integration

Add infrastructure for future DARC Community Logbook (DCL) integration:
- Database schema: Add dcl_api_key, my_darc_dok, darc_dok, dcl_qsl_rdate, dcl_qsl_rstatus fields
- Create DCL service stub with placeholder functions for when DCL provides API
- Backend API: Add /api/auth/dcl-credentials endpoint for API key management
- Frontend settings: Add DCL API key input with informational notice about API availability
- QSO table: Add My DOK and DOK columns, update confirmation column for multiple services

Note: DCL download API is not yet available. These changes prepare the application
for future implementation when DCL adds programmatic access.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-17 10:24:43 +01:00
parent 5db7f6b67f
commit 47738c68a9
10 changed files with 1014 additions and 24 deletions

View File

@@ -8,6 +8,7 @@ import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
* @property {string} callsign
* @property {string|null} lotwUsername
* @property {string|null} lotwPassword
* @property {string|null} dclApiKey
* @property {Date} createdAt
* @property {Date} updatedAt
*/
@@ -19,6 +20,7 @@ export const users = sqliteTable('users', {
callsign: text('callsign').notNull(),
lotwUsername: text('lotw_username'),
lotwPassword: text('lotw_password'), // Encrypted
dclApiKey: text('dcl_api_key'), // DCL API key for future use
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
});
@@ -45,8 +47,12 @@ export const users = sqliteTable('users', {
* @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
*/
@@ -79,10 +85,18 @@ export const qsos = sqliteTable('qsos', {
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()),

View File

@@ -7,6 +7,7 @@ import {
authenticateUser,
getUserById,
updateLoTWCredentials,
updateDCLCredentials,
} from './services/auth.service.js';
import {
getUserQSOs,
@@ -235,6 +236,40 @@ const app = new Elysia()
}
)
/**
* PUT /api/auth/dcl-credentials
* Update DCL credentials (requires authentication)
*/
.put(
'/api/auth/dcl-credentials',
async ({ user, body, set }) => {
if (!user) {
set.status = 401;
return { success: false, error: 'Unauthorized' };
}
try {
await updateDCLCredentials(user.id, body.dclApiKey);
return {
success: true,
message: 'DCL credentials updated successfully',
};
} catch (error) {
set.status = 500;
return {
success: false,
error: 'Failed to update DCL credentials',
};
}
},
{
body: t.Object({
dclApiKey: t.String(),
}),
}
)
/**
* POST /api/lotw/sync
* Queue a LoTW sync job (requires authentication)

View File

@@ -126,3 +126,19 @@ export async function updateLoTWCredentials(userId, lotwUsername, lotwPassword)
})
.where(eq(users.id, userId));
}
/**
* Update user's DCL API key
* @param {number} userId - User ID
* @param {string} dclApiKey - DCL API key
* @returns {Promise<void>}
*/
export async function updateDCLCredentials(userId, dclApiKey) {
await db
.update(users)
.set({
dclApiKey,
updatedAt: new Date(),
})
.where(eq(users.id, userId));
}

View File

@@ -0,0 +1,214 @@
import { db, logger } from '../config.js';
import { qsos } from '../db/schema/index.js';
import { max, sql, eq, and, desc } from 'drizzle-orm';
import { updateJobProgress } from './job-queue.service.js';
/**
* DCL (DARC Community Logbook) Service
*
* NOTE: DCL does not currently have a public API for downloading QSOs.
* This service is prepared as a stub for when DCL adds API support.
*
* When DCL provides an API, implement:
* - fetchQSOsFromDCL() - Download QSOs from DCL
* - syncQSOs() - Sync QSOs to database
* - getLastDCLQSLDate() - Get last QSL date for incremental sync
*
* DCL Information:
* - Website: https://dcl.darc.de/
* - ADIF Export: https://dcl.darc.de/dml/export_adif_form.php (manual only)
* - DOK fields: MY_DARC_DOK (user's DOK), DARC_DOK (partner's DOK)
*/
/**
* Fetch QSOs from DCL
*
* TODO: Implement when DCL provides a download API
* Expected implementation:
* - Use DCL API key for authentication
* - Fetch ADIF data with confirmations
* - Parse and return QSO records
*
* @param {string} dclApiKey - DCL API key
* @param {Date|null} sinceDate - Last sync date for incremental sync
* @returns {Promise<Array>} Array of parsed QSO records
*/
export async function fetchQSOsFromDCL(dclApiKey, sinceDate = null) {
logger.info('DCL sync not yet implemented - API endpoint not available', {
sinceDate: sinceDate?.toISOString(),
});
throw new Error('DCL download API is not yet available. DCL does not currently provide a public API for downloading QSOs. Use the manual ADIF export at https://dcl.darc.de/dml/export_adif_form.php');
/*
* FUTURE IMPLEMENTATION (when DCL provides API):
*
* const url = 'https://dcl.darc.de/api/...'; // TBA
*
* const params = new URLSearchParams({
* api_key: dclApiKey,
* format: 'adif',
* qsl: 'yes',
* });
*
* if (sinceDate) {
* const dateStr = sinceDate.toISOString().split('T')[0].replace(/-/g, '');
* params.append('qso_qslsince', dateStr);
* }
*
* const response = await fetch(`${url}?${params}`, {
* headers: {
* 'Accept': 'text/plain',
* },
* timeout: REQUEST_TIMEOUT,
* });
*
* if (!response.ok) {
* throw new Error(`DCL API error: ${response.status}`);
* }
*
* const adifData = await response.text();
* return parseADIF(adifData);
*/
}
/**
* Parse ADIF data from DCL
*
* TODO: Implement ADIF parser for DCL format
* Should handle DCL-specific fields:
* - MY_DARC_DOK
* - DARC_DOK
*
* @param {string} adifData - Raw ADIF data
* @returns {Array} Array of parsed QSO records
*/
function parseADIF(adifData) {
// TODO: Implement ADIF parser
// Should parse standard ADIF fields plus DCL-specific fields:
// - MY_DARC_DOK (user's own DOK)
// - DARC_DOK (QSO partner's DOK)
// - QSL_DATE (confirmation date from DCL)
return [];
}
/**
* Sync QSOs from DCL to database
*
* TODO: Implement when DCL provides API
*
* @param {number} userId - User ID
* @param {string} dclApiKey - DCL API key
* @param {Date|null} sinceDate - Last sync date
* @param {number|null} jobId - Job ID for progress tracking
* @returns {Promise<Object>} Sync results
*/
export async function syncQSOs(userId, dclApiKey, sinceDate = null, jobId = null) {
logger.info('DCL sync not yet implemented', { userId, sinceDate, jobId });
throw new Error('DCL download API is not yet available');
/*
* FUTURE IMPLEMENTATION:
*
* try {
* const adifQSOs = await fetchQSOsFromDCL(dclApiKey, sinceDate);
*
* let addedCount = 0;
* let updatedCount = 0;
* let errors = [];
*
* for (const adifQSO of adifQSOs) {
* try {
* // Map ADIF fields to database schema
* const qsoData = mapADIFToDB(adifQSO);
*
* // Check if QSO already exists
* const existing = await db.select()
* .from(qsos)
* .where(
* and(
* eq(qsos.userId, userId),
* eq(qsos.callsign, adifQSO.call),
* eq(qsos.qsoDate, adifQSO.qso_date),
* eq(qsos.timeOn, adifQSO.time_on)
* )
* )
* .limit(1);
*
* if (existing.length > 0) {
* // Update existing QSO with DCL confirmation
* await db.update(qsos)
* .set({
* dclQslRdate: adifQSO.qslrdate || null,
* dclQslRstatus: adifQSO.qslrdate ? 'Y' : 'N',
* darcDok: adifQSO.darc_dok || null,
* myDarcDok: adifQSO.my_darc_dok || null,
* })
* .where(eq(qsos.id, existing[0].id));
* updatedCount++;
* } else {
* // Insert new QSO
* await db.insert(qsos).values({
* userId,
* ...qsoData,
* dclQslRdate: adifQSO.qslrdate || null,
* dclQslRstatus: adifQSO.qslrdate ? 'Y' : 'N',
* });
* addedCount++;
* }
* } catch (err) {
* logger.error('Failed to process QSO', { error: err.message, qso: adifQSO });
* errors.push(err.message);
* }
* }
*
* const result = {
* success: true,
* total: adifQSOs.length,
* added: addedCount,
* updated: updatedCount,
* errors,
* };
*
* logger.info('DCL sync completed', { ...result, jobId });
* return result;
*
* } catch (error) {
* logger.error('DCL sync failed', { error: error.message, userId, jobId });
* return { success: false, error: error.message, total: 0, added: 0, updated: 0 };
* }
*/
}
/**
* Get last DCL QSL date for incremental sync
*
* TODO: Implement when DCL provides API
*
* @param {number} userId - User ID
* @returns {Promise<Date|null>} Last QSL date or null
*/
export async function getLastDCLQSLDate(userId) {
try {
const result = await db
.select({ maxDate: max(qsos.dclQslRdate) })
.from(qsos)
.where(eq(qsos.userId, userId));
if (result[0]?.maxDate) {
// Convert ADIF date format (YYYYMMDD) to Date
const dateStr = result[0].maxDate;
const year = dateStr.substring(0, 4);
const month = dateStr.substring(4, 6);
const day = dateStr.substring(6, 8);
return new Date(`${year}-${month}-${day}`);
}
return null;
} catch (error) {
logger.error('Failed to get last DCL QSL date', { error: error.message, userId });
return null;
}
}

View File

@@ -49,6 +49,11 @@ export const authAPI = {
method: 'PUT',
body: JSON.stringify(credentials),
}),
updateDCLCredentials: (credentials) => apiRequest('/auth/dcl-credentials', {
method: 'PUT',
body: JSON.stringify(credentials),
}),
};
// Awards API

View File

@@ -401,6 +401,8 @@
<th>Mode</th>
<th>Entity</th>
<th>Grid</th>
<th>My DOK</th>
<th>DOK</th>
<th>Confirmed</th>
</tr>
</thead>
@@ -414,12 +416,24 @@
<td>{qso.mode || '-'}</td>
<td>{qso.entity || '-'}</td>
<td>{qso.grid || '-'}</td>
<td>{qso.myDarcDok || '-'}</td>
<td>{qso.darcDok || '-'}</td>
<td>
{#if qso.lotwQslRstatus === 'Y' && qso.lotwQslRdate}
<span class="confirmation-info">
<span class="service-type">LoTW</span>
<span class="confirmation-date">{formatDate(qso.lotwQslRdate)}</span>
</span>
{#if (qso.lotwQslRstatus === 'Y' && qso.lotwQslRdate) || (qso.dclQslRstatus === 'Y' && qso.dclQslRdate)}
<div class="confirmation-list">
{#if qso.lotwQslRstatus === 'Y' && qso.lotwQslRdate}
<div class="confirmation-item">
<span class="service-type">LoTW</span>
<span class="confirmation-date">{formatDate(qso.lotwQslRdate)}</span>
</div>
{/if}
{#if qso.dclQslRstatus === 'Y' && qso.dclQslRdate}
<div class="confirmation-item">
<span class="service-type">DCL</span>
<span class="confirmation-date">{formatDate(qso.dclQslRdate)}</span>
</div>
{/if}
</div>
{:else}
-
{/if}
@@ -745,7 +759,13 @@
color: #856404;
}
.confirmation-info {
.confirmation-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.confirmation-item {
display: flex;
flex-direction: column;
gap: 0.25rem;

View File

@@ -6,11 +6,15 @@
let lotwUsername = '';
let lotwPassword = '';
let dclApiKey = '';
let loading = false;
let saving = false;
let savingLoTW = false;
let savingDCL = false;
let error = null;
let success = false;
let hasCredentials = false;
let successLoTW = false;
let successDCL = false;
let hasLoTWCredentials = false;
let hasDCLCredentials = false;
onMount(async () => {
// Load user profile to check if credentials exist
@@ -25,8 +29,10 @@
if (response.user) {
lotwUsername = response.user.lotwUsername || '';
lotwPassword = ''; // Never pre-fill password for security
hasCredentials = !!(response.user.lotwUsername && response.user.lotwPassword);
console.log('Has credentials:', hasCredentials);
hasLoTWCredentials = !!(response.user.lotwUsername && response.user.lotwPassword);
dclApiKey = response.user.dclApiKey || '';
hasDCLCredentials = !!response.user.dclApiKey;
console.log('Has LoTW credentials:', hasLoTWCredentials, 'Has DCL credentials:', hasDCLCredentials);
}
} catch (err) {
console.error('Failed to load profile:', err);
@@ -36,31 +42,58 @@
}
}
async function handleSave(e) {
async function handleSaveLoTW(e) {
e.preventDefault();
try {
saving = true;
savingLoTW = true;
error = null;
success = false;
successLoTW = false;
console.log('Saving credentials:', { lotwUsername, hasPassword: !!lotwPassword });
console.log('Saving LoTW credentials:', { lotwUsername, hasPassword: !!lotwPassword });
await authAPI.updateLoTWCredentials({
lotwUsername,
lotwPassword
});
console.log('Save successful!');
console.log('LoTW Save successful!');
// Reload profile to update hasCredentials flag
await loadProfile();
success = true;
successLoTW = true;
} catch (err) {
console.error('Save failed:', err);
console.error('LoTW Save failed:', err);
error = err.message;
} finally {
saving = false;
savingLoTW = false;
}
}
async function handleSaveDCL(e) {
e.preventDefault();
try {
savingDCL = true;
error = null;
successDCL = false;
console.log('Saving DCL credentials:', { hasApiKey: !!dclApiKey });
await authAPI.updateDCLCredentials({
dclApiKey
});
console.log('DCL Save successful!');
// Reload profile
await loadProfile();
successDCL = true;
} catch (err) {
console.error('DCL Save failed:', err);
error = err.message;
} finally {
savingDCL = false;
}
}
@@ -96,18 +129,18 @@
Your credentials are stored securely and used only to fetch your confirmed QSOs.
</p>
{#if hasCredentials}
{#if hasLoTWCredentials}
<div class="alert alert-info">
<strong>Credentials configured</strong> - You can update them below if needed.
</div>
{/if}
<form on:submit={handleSave} class="settings-form">
<form on:submit={handleSaveLoTW} class="settings-form">
{#if error}
<div class="alert alert-error">{error}</div>
{/if}
{#if success}
{#if successLoTW}
<div class="alert alert-success">
LoTW credentials saved successfully!
</div>
@@ -138,8 +171,8 @@
</p>
</div>
<button type="submit" class="btn btn-primary" disabled={saving}>
{saving ? 'Saving...' : 'Save Credentials'}
<button type="submit" class="btn btn-primary" disabled={savingLoTW}>
{savingLoTW ? 'Saving...' : 'Save LoTW Credentials'}
</button>
</form>
@@ -157,6 +190,59 @@
</p>
</div>
</div>
<div class="settings-section">
<h2>DCL Credentials</h2>
<p class="help-text">
Configure your DARC Community Logbook (DCL) API key for future sync functionality.
<strong>Note:</strong> DCL does not currently provide a download API. This is prepared for when they add one.
</p>
{#if hasDCLCredentials}
<div class="alert alert-info">
<strong>API key configured</strong> - You can update it below if needed.
</div>
{/if}
<form on:submit={handleSaveDCL} class="settings-form">
{#if successDCL}
<div class="alert alert-success">
DCL API key saved successfully!
</div>
{/if}
<div class="form-group">
<label for="dclApiKey">DCL API Key</label>
<input
id="dclApiKey"
type="password"
bind:value={dclApiKey}
placeholder="Your DCL API key"
/>
<p class="hint">
Enter your DCL API key for future sync functionality
</p>
</div>
<button type="submit" class="btn btn-primary" disabled={savingDCL}>
{savingDCL ? 'Saving...' : 'Save DCL API Key'}
</button>
</form>
<div class="info-box">
<h3>About DCL</h3>
<p>
DCL (DARC Community Logbook) is DARC's web-based logbook system for German amateur radio awards.
It includes DOK (DARC Ortsverband Kennung) fields for local club awards.
</p>
<p>
<strong>Status:</strong> Download API not yet available.{' '}
<a href="https://dcl.darc.de/" target="_blank" rel="noopener">
Visit DCL website
</a>
</p>
</div>
</div>
</div>
<style>