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

@@ -0,0 +1,18 @@
CREATE TABLE `sync_jobs` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`status` text NOT NULL,
`type` text NOT NULL,
`started_at` integer,
`completed_at` integer,
`result` text,
`error` text,
`created_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
ALTER TABLE `qsos` ADD `my_darc_dok` text;--> statement-breakpoint
ALTER TABLE `qsos` ADD `darc_dok` text;--> statement-breakpoint
ALTER TABLE `qsos` ADD `dcl_qsl_rdate` text;--> statement-breakpoint
ALTER TABLE `qsos` ADD `dcl_qsl_rstatus` text;--> statement-breakpoint
ALTER TABLE `users` ADD `dcl_api_key` text;

View File

@@ -0,0 +1,575 @@
{
"version": "6",
"dialect": "sqlite",
"id": "b5c00e60-2f3c-4c2b-a540-0be8d9e856e6",
"prevId": "1b1674e7-6e3e-4ca6-8d19-066f2947942c",
"tables": {
"award_progress": {
"name": "award_progress",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"award_id": {
"name": "award_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"worked_count": {
"name": "worked_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"confirmed_count": {
"name": "confirmed_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"total_required": {
"name": "total_required",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"worked_entities": {
"name": "worked_entities",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"confirmed_entities": {
"name": "confirmed_entities",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_calculated_at": {
"name": "last_calculated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_qso_sync_at": {
"name": "last_qso_sync_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"award_progress_user_id_users_id_fk": {
"name": "award_progress_user_id_users_id_fk",
"tableFrom": "award_progress",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"award_progress_award_id_awards_id_fk": {
"name": "award_progress_award_id_awards_id_fk",
"tableFrom": "award_progress",
"tableTo": "awards",
"columnsFrom": [
"award_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"awards": {
"name": "awards",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"definition": {
"name": "definition",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"qsos": {
"name": "qsos",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"callsign": {
"name": "callsign",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"qso_date": {
"name": "qso_date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_on": {
"name": "time_on",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"band": {
"name": "band",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"mode": {
"name": "mode",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"freq": {
"name": "freq",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"freq_rx": {
"name": "freq_rx",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"entity": {
"name": "entity",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"entity_id": {
"name": "entity_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"grid": {
"name": "grid",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"grid_source": {
"name": "grid_source",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"continent": {
"name": "continent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"cq_zone": {
"name": "cq_zone",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"itu_zone": {
"name": "itu_zone",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"state": {
"name": "state",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"county": {
"name": "county",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"sat_name": {
"name": "sat_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"sat_mode": {
"name": "sat_mode",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"my_darc_dok": {
"name": "my_darc_dok",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"darc_dok": {
"name": "darc_dok",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"lotw_qsl_rdate": {
"name": "lotw_qsl_rdate",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"lotw_qsl_rstatus": {
"name": "lotw_qsl_rstatus",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"dcl_qsl_rdate": {
"name": "dcl_qsl_rdate",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"dcl_qsl_rstatus": {
"name": "dcl_qsl_rstatus",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"lotw_synced_at": {
"name": "lotw_synced_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"qsos_user_id_users_id_fk": {
"name": "qsos_user_id_users_id_fk",
"tableFrom": "qsos",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sync_jobs": {
"name": "sync_jobs",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"started_at": {
"name": "started_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"result": {
"name": "result",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"error": {
"name": "error",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"sync_jobs_user_id_users_id_fk": {
"name": "sync_jobs_user_id_users_id_fk",
"tableFrom": "sync_jobs",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"callsign": {
"name": "callsign",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"lotw_username": {
"name": "lotw_username",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"lotw_password": {
"name": "lotw_password",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"dcl_api_key": {
"name": "dcl_api_key",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"users_email_unique": {
"name": "users_email_unique",
"columns": [
"email"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -8,6 +8,13 @@
"when": 1768462458852, "when": 1768462458852,
"tag": "0000_burly_unus", "tag": "0000_burly_unus",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1768641501799,
"tag": "0001_free_hiroim",
"breakpoints": true
} }
] ]
} }

View File

@@ -8,6 +8,7 @@ import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
* @property {string} callsign * @property {string} callsign
* @property {string|null} lotwUsername * @property {string|null} lotwUsername
* @property {string|null} lotwPassword * @property {string|null} lotwPassword
* @property {string|null} dclApiKey
* @property {Date} createdAt * @property {Date} createdAt
* @property {Date} updatedAt * @property {Date} updatedAt
*/ */
@@ -19,6 +20,7 @@ export const users = sqliteTable('users', {
callsign: text('callsign').notNull(), callsign: text('callsign').notNull(),
lotwUsername: text('lotw_username'), lotwUsername: text('lotw_username'),
lotwPassword: text('lotw_password'), // Encrypted 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()), 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()),
}); });
@@ -45,8 +47,12 @@ export const users = sqliteTable('users', {
* @property {string|null} county * @property {string|null} county
* @property {string|null} satName * @property {string|null} satName
* @property {string|null} satMode * @property {string|null} satMode
* @property {string|null} myDarcDok
* @property {string|null} darcDok
* @property {string|null} lotwQslRdate * @property {string|null} lotwQslRdate
* @property {string|null} lotwQslRstatus * @property {string|null} lotwQslRstatus
* @property {string|null} dclQslRdate
* @property {string|null} dclQslRstatus
* @property {Date|null} lotwSyncedAt * @property {Date|null} lotwSyncedAt
* @property {Date} createdAt * @property {Date} createdAt
*/ */
@@ -79,10 +85,18 @@ export const qsos = sqliteTable('qsos', {
satName: text('sat_name'), satName: text('sat_name'),
satMode: text('sat_mode'), 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 // LoTW confirmation
lotwQslRdate: text('lotw_qsl_rdate'), // Confirmation date lotwQslRdate: text('lotw_qsl_rdate'), // Confirmation date
lotwQslRstatus: text('lotw_qsl_rstatus'), // 'Y', 'N', '?' 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 // Cache metadata
lotwSyncedAt: integer('lotw_synced_at', { mode: 'timestamp' }), lotwSyncedAt: integer('lotw_synced_at', { mode: 'timestamp' }),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()), createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),

View File

@@ -7,6 +7,7 @@ import {
authenticateUser, authenticateUser,
getUserById, getUserById,
updateLoTWCredentials, updateLoTWCredentials,
updateDCLCredentials,
} from './services/auth.service.js'; } from './services/auth.service.js';
import { import {
getUserQSOs, 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 * POST /api/lotw/sync
* Queue a LoTW sync job (requires authentication) * 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)); .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', method: 'PUT',
body: JSON.stringify(credentials), body: JSON.stringify(credentials),
}), }),
updateDCLCredentials: (credentials) => apiRequest('/auth/dcl-credentials', {
method: 'PUT',
body: JSON.stringify(credentials),
}),
}; };
// Awards API // Awards API

View File

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

View File

@@ -6,11 +6,15 @@
let lotwUsername = ''; let lotwUsername = '';
let lotwPassword = ''; let lotwPassword = '';
let dclApiKey = '';
let loading = false; let loading = false;
let saving = false; let savingLoTW = false;
let savingDCL = false;
let error = null; let error = null;
let success = false; let successLoTW = false;
let hasCredentials = false; let successDCL = false;
let hasLoTWCredentials = false;
let hasDCLCredentials = false;
onMount(async () => { onMount(async () => {
// Load user profile to check if credentials exist // Load user profile to check if credentials exist
@@ -25,8 +29,10 @@
if (response.user) { if (response.user) {
lotwUsername = response.user.lotwUsername || ''; lotwUsername = response.user.lotwUsername || '';
lotwPassword = ''; // Never pre-fill password for security lotwPassword = ''; // Never pre-fill password for security
hasCredentials = !!(response.user.lotwUsername && response.user.lotwPassword); hasLoTWCredentials = !!(response.user.lotwUsername && response.user.lotwPassword);
console.log('Has credentials:', hasCredentials); dclApiKey = response.user.dclApiKey || '';
hasDCLCredentials = !!response.user.dclApiKey;
console.log('Has LoTW credentials:', hasLoTWCredentials, 'Has DCL credentials:', hasDCLCredentials);
} }
} catch (err) { } catch (err) {
console.error('Failed to load profile:', err); console.error('Failed to load profile:', err);
@@ -36,31 +42,58 @@
} }
} }
async function handleSave(e) { async function handleSaveLoTW(e) {
e.preventDefault(); e.preventDefault();
try { try {
saving = true; savingLoTW = true;
error = null; error = null;
success = false; successLoTW = false;
console.log('Saving credentials:', { lotwUsername, hasPassword: !!lotwPassword }); console.log('Saving LoTW credentials:', { lotwUsername, hasPassword: !!lotwPassword });
await authAPI.updateLoTWCredentials({ await authAPI.updateLoTWCredentials({
lotwUsername, lotwUsername,
lotwPassword lotwPassword
}); });
console.log('Save successful!'); console.log('LoTW Save successful!');
// Reload profile to update hasCredentials flag // Reload profile to update hasCredentials flag
await loadProfile(); await loadProfile();
success = true; successLoTW = true;
} catch (err) { } catch (err) {
console.error('Save failed:', err); console.error('LoTW Save failed:', err);
error = err.message; error = err.message;
} finally { } 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. Your credentials are stored securely and used only to fetch your confirmed QSOs.
</p> </p>
{#if hasCredentials} {#if hasLoTWCredentials}
<div class="alert alert-info"> <div class="alert alert-info">
<strong>Credentials configured</strong> - You can update them below if needed. <strong>Credentials configured</strong> - You can update them below if needed.
</div> </div>
{/if} {/if}
<form on:submit={handleSave} class="settings-form"> <form on:submit={handleSaveLoTW} class="settings-form">
{#if error} {#if error}
<div class="alert alert-error">{error}</div> <div class="alert alert-error">{error}</div>
{/if} {/if}
{#if success} {#if successLoTW}
<div class="alert alert-success"> <div class="alert alert-success">
LoTW credentials saved successfully! LoTW credentials saved successfully!
</div> </div>
@@ -138,8 +171,8 @@
</p> </p>
</div> </div>
<button type="submit" class="btn btn-primary" disabled={saving}> <button type="submit" class="btn btn-primary" disabled={savingLoTW}>
{saving ? 'Saving...' : 'Save Credentials'} {savingLoTW ? 'Saving...' : 'Save LoTW Credentials'}
</button> </button>
</form> </form>
@@ -157,6 +190,59 @@
</p> </p>
</div> </div>
</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> </div>
<style> <style>