From 47738c68a9bc3e2d44256f15affaac8ab43c07af Mon Sep 17 00:00:00 2001 From: Joerg Date: Sat, 17 Jan 2026 10:24:43 +0100 Subject: [PATCH] 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 --- drizzle/0001_free_hiroim.sql | 18 + drizzle/meta/0001_snapshot.json | 575 ++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/backend/db/schema/index.js | 14 + src/backend/index.js | 35 ++ src/backend/services/auth.service.js | 16 + src/backend/services/dcl.service.js | 214 +++++++ src/frontend/src/lib/api.js | 5 + src/frontend/src/routes/qsos/+page.svelte | 32 +- src/frontend/src/routes/settings/+page.svelte | 122 +++- 10 files changed, 1014 insertions(+), 24 deletions(-) create mode 100644 drizzle/0001_free_hiroim.sql create mode 100644 drizzle/meta/0001_snapshot.json create mode 100644 src/backend/services/dcl.service.js diff --git a/drizzle/0001_free_hiroim.sql b/drizzle/0001_free_hiroim.sql new file mode 100644 index 0000000..1cce766 --- /dev/null +++ b/drizzle/0001_free_hiroim.sql @@ -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; \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..c007bc4 --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 4f613b0..522bf20 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1768462458852, "tag": "0000_burly_unus", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1768641501799, + "tag": "0001_free_hiroim", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/backend/db/schema/index.js b/src/backend/db/schema/index.js index 52d773f..efc4584 100644 --- a/src/backend/db/schema/index.js +++ b/src/backend/db/schema/index.js @@ -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()), diff --git a/src/backend/index.js b/src/backend/index.js index 1e8ba83..266cce9 100644 --- a/src/backend/index.js +++ b/src/backend/index.js @@ -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) diff --git a/src/backend/services/auth.service.js b/src/backend/services/auth.service.js index 572d89e..51b8a28 100644 --- a/src/backend/services/auth.service.js +++ b/src/backend/services/auth.service.js @@ -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} + */ +export async function updateDCLCredentials(userId, dclApiKey) { + await db + .update(users) + .set({ + dclApiKey, + updatedAt: new Date(), + }) + .where(eq(users.id, userId)); +} diff --git a/src/backend/services/dcl.service.js b/src/backend/services/dcl.service.js new file mode 100644 index 0000000..cd4ef3c --- /dev/null +++ b/src/backend/services/dcl.service.js @@ -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 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} 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} 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; + } +} diff --git a/src/frontend/src/lib/api.js b/src/frontend/src/lib/api.js index e5a3ba2..4612dac 100644 --- a/src/frontend/src/lib/api.js +++ b/src/frontend/src/lib/api.js @@ -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 diff --git a/src/frontend/src/routes/qsos/+page.svelte b/src/frontend/src/routes/qsos/+page.svelte index 5472a81..a0aef53 100644 --- a/src/frontend/src/routes/qsos/+page.svelte +++ b/src/frontend/src/routes/qsos/+page.svelte @@ -401,6 +401,8 @@ Mode Entity Grid + My DOK + DOK Confirmed @@ -414,12 +416,24 @@ {qso.mode || '-'} {qso.entity || '-'} {qso.grid || '-'} + {qso.myDarcDok || '-'} + {qso.darcDok || '-'} - {#if qso.lotwQslRstatus === 'Y' && qso.lotwQslRdate} - - LoTW - {formatDate(qso.lotwQslRdate)} - + {#if (qso.lotwQslRstatus === 'Y' && qso.lotwQslRdate) || (qso.dclQslRstatus === 'Y' && qso.dclQslRdate)} +
+ {#if qso.lotwQslRstatus === 'Y' && qso.lotwQslRdate} +
+ LoTW + {formatDate(qso.lotwQslRdate)} +
+ {/if} + {#if qso.dclQslRstatus === 'Y' && qso.dclQslRdate} +
+ DCL + {formatDate(qso.dclQslRdate)} +
+ {/if} +
{: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; diff --git a/src/frontend/src/routes/settings/+page.svelte b/src/frontend/src/routes/settings/+page.svelte index 6bed5f2..d0b8785 100644 --- a/src/frontend/src/routes/settings/+page.svelte +++ b/src/frontend/src/routes/settings/+page.svelte @@ -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.

- {#if hasCredentials} + {#if hasLoTWCredentials}
Credentials configured - You can update them below if needed.
{/if} -
+ {#if error}
{error}
{/if} - {#if success} + {#if successLoTW}
LoTW credentials saved successfully!
@@ -138,8 +171,8 @@

-
@@ -157,6 +190,59 @@

+ +
+

DCL Credentials

+

+ Configure your DARC Community Logbook (DCL) API key for future sync functionality. + Note: DCL does not currently provide a download API. This is prepared for when they add one. +

+ + {#if hasDCLCredentials} +
+ API key configured - You can update it below if needed. +
+ {/if} + +
+ {#if successDCL} +
+ DCL API key saved successfully! +
+ {/if} + +
+ + +

+ Enter your DCL API key for future sync functionality +

+
+ + +
+ +
+

About DCL

+

+ 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. +

+

+ Status: Download API not yet available.{' '} + + Visit DCL website + +

+
+