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:
18
drizzle/0001_free_hiroim.sql
Normal file
18
drizzle/0001_free_hiroim.sql
Normal 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;
|
||||
575
drizzle/meta/0001_snapshot.json
Normal file
575
drizzle/meta/0001_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,13 @@
|
||||
"when": 1768462458852,
|
||||
"tag": "0000_burly_unus",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1768641501799,
|
||||
"tag": "0001_free_hiroim",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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()),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
214
src/backend/services/dcl.service.js
Normal file
214
src/backend/services/dcl.service.js
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user