fix: enable debug logging and improve DCL sync observability

- Fix logger bug where debug level (0) was treated as falsy
  - Change `||` to `??` in config.js to properly handle log level 0
  - Debug logs now work correctly when LOG_LEVEL=debug

- Add server startup logging
  - Log port, environment, and log level on server start
  - Helps verify configuration is loaded correctly

- Add DCL API request debug logging
  - Log full API request parameters when LOG_LEVEL=debug
  - API key is redacted (shows first/last 4 chars only)
  - Helps troubleshoot DCL sync issues

- Update CLAUDE.md documentation
  - Add Logging section with log levels and configuration
  - Document debug logging feature for DCL service
  - Add this fix to Recent Commits section

Note: .env file added locally with LOG_LEVEL=debug (not committed)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-18 07:02:52 +01:00
parent 27d2ef14ef
commit 223461f536
8 changed files with 424 additions and 116 deletions

View File

@@ -17,7 +17,7 @@ export const LOG_LEVEL = process.env.LOG_LEVEL || (isDevelopment ? 'debug' : 'in
// ===================================================================
const logLevels = { debug: 0, info: 1, warn: 2, error: 3 };
const currentLogLevel = logLevels[LOG_LEVEL] || 1;
const currentLogLevel = logLevels[LOG_LEVEL] ?? 1;
function log(level, message, data) {
if (logLevels[level] < currentLogLevel) return;

View File

@@ -1,7 +1,7 @@
import { Elysia, t } from 'elysia';
import { cors } from '@elysiajs/cors';
import { jwt } from '@elysiajs/jwt';
import { JWT_SECRET, logger } from './config.js';
import { JWT_SECRET, logger, LOG_LEVEL } from './config.js';
import {
registerUser,
authenticateUser,
@@ -283,13 +283,13 @@ const app = new Elysia()
}
try {
const result = await enqueueJob(user.id);
const result = await enqueueJob(user.id, 'lotw_sync');
if (!result.success && result.existingJob) {
return {
success: true,
jobId: result.existingJob,
message: 'A sync job is already running',
message: 'A LoTW sync job is already running',
};
}
@@ -299,7 +299,41 @@ const app = new Elysia()
set.status = 500;
return {
success: false,
error: `Failed to queue sync job: ${error.message}`,
error: `Failed to queue LoTW sync job: ${error.message}`,
};
}
})
/**
* POST /api/dcl/sync
* Queue a DCL sync job (requires authentication)
* Returns immediately with job ID
*/
.post('/api/dcl/sync', async ({ user, set }) => {
if (!user) {
logger.warn('/api/dcl/sync: Unauthorized access attempt');
set.status = 401;
return { success: false, error: 'Unauthorized' };
}
try {
const result = await enqueueJob(user.id, 'dcl_sync');
if (!result.success && result.existingJob) {
return {
success: true,
jobId: result.existingJob,
message: 'A DCL sync job is already running',
};
}
return result;
} catch (error) {
logger.error('Error in /api/dcl/sync', { error: error.message });
set.status = 500;
return {
success: false,
error: `Failed to queue DCL sync job: ${error.message}`,
};
}
})
@@ -703,3 +737,9 @@ const app = new Elysia()
// Start server - uses PORT environment variable if set, otherwise defaults to 3001
.listen(process.env.PORT || 3001);
logger.info('Server started', {
port: process.env.PORT || 3001,
nodeEnv: process.env.NODE_ENV || 'unknown',
logLevel: LOG_LEVEL,
});

View File

@@ -58,6 +58,17 @@ export async function fetchQSOsFromDCL(dclApiKey, sinceDate = null) {
requestBody.qsl_since = dateStr;
}
// Debug log request parameters (redact API key)
logger.debug('DCL API request parameters', {
url: DCL_API_URL,
method: 'POST',
key: dclApiKey ? `${dclApiKey.substring(0, 4)}...${dclApiKey.substring(dclApiKey.length - 4)}` : null,
limit: requestBody.limit,
qsl_since: requestBody.qsl_since,
qso_since: requestBody.qso_since,
cnf_only: requestBody.cnf_only,
});
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT);

View File

@@ -19,20 +19,21 @@ export const JobStatus = {
const activeJobs = new Map();
/**
* Enqueue a new LoTW sync job
* Enqueue a new sync job
* @param {number} userId - User ID
* @param {string} jobType - Type of job ('lotw_sync' or 'dcl_sync')
* @returns {Promise<Object>} Job object with ID
*/
export async function enqueueJob(userId) {
logger.debug('Enqueueing LoTW sync job', { userId });
export async function enqueueJob(userId, jobType = 'lotw_sync') {
logger.debug('Enqueueing sync job', { userId, jobType });
// Check for existing active job
const existingJob = await getUserActiveJob(userId);
// Check for existing active job of the same type
const existingJob = await getUserActiveJob(userId, jobType);
if (existingJob) {
logger.debug('Existing active job found', { jobId: existingJob.id });
logger.debug('Existing active job found', { jobId: existingJob.id, jobType });
return {
success: false,
error: 'A LoTW sync job is already running or pending for this user',
error: `A ${jobType} job is already running or pending for this user`,
existingJob: existingJob.id,
};
}
@@ -42,16 +43,16 @@ export async function enqueueJob(userId) {
.insert(syncJobs)
.values({
userId,
type: 'lotw_sync',
type: jobType,
status: JobStatus.PENDING,
createdAt: new Date(),
})
.returning();
logger.info('Job created', { jobId: job.id, userId });
logger.info('Job created', { jobId: job.id, userId, jobType });
// Start processing asynchronously (don't await)
processJobAsync(job.id, userId).catch((error) => {
processJobAsync(job.id, userId, jobType).catch((error) => {
logger.error(`Job processing error`, { jobId: job.id, error: error.message });
});
@@ -68,15 +69,14 @@ export async function enqueueJob(userId) {
}
/**
* Process a LoTW sync job asynchronously
* Process a sync job asynchronously
* @param {number} jobId - Job ID
* @param {number} userId - User ID
* @param {string} jobType - Type of job ('lotw_sync' or 'dcl_sync')
*/
async function processJobAsync(jobId, userId) {
async function processJobAsync(jobId, userId, jobType) {
const jobPromise = (async () => {
try {
// Import dynamically to avoid circular dependency
const { syncQSOs } = await import('./lotw.service.js');
const { getUserById } = await import('./auth.service.js');
// Update status to running
@@ -85,37 +85,72 @@ async function processJobAsync(jobId, userId) {
startedAt: new Date(),
});
// Get user credentials
const user = await getUserById(userId);
if (!user || !user.lotwUsername || !user.lotwPassword) {
await updateJob(jobId, {
status: JobStatus.FAILED,
completedAt: new Date(),
error: 'LoTW credentials not configured',
let result;
if (jobType === 'dcl_sync') {
// Get user credentials
const user = await getUserById(userId);
if (!user || !user.dclApiKey) {
await updateJob(jobId, {
status: JobStatus.FAILED,
completedAt: new Date(),
error: 'DCL credentials not configured',
});
return null;
}
// Get last QSL date for incremental sync
const { getLastDCLQSLDate, syncQSOs: syncDCLQSOs } = await import('./dcl.service.js');
const lastQSLDate = await getLastDCLQSLDate(userId);
const sinceDate = lastQSLDate || new Date('2000-01-01');
if (lastQSLDate) {
logger.info(`Job ${jobId}: DCL incremental sync`, { since: sinceDate.toISOString().split('T')[0] });
} else {
logger.info(`Job ${jobId}: DCL full sync`);
}
// Update job progress
await updateJobProgress(jobId, {
message: 'Fetching QSOs from DCL...',
step: 'fetch',
});
return null;
}
// Get last QSL date for incremental sync
const { getLastLoTWQSLDate } = await import('./lotw.service.js');
const lastQSLDate = await getLastLoTWQSLDate(userId);
const sinceDate = lastQSLDate || new Date('2000-01-01');
if (lastQSLDate) {
logger.info(`Job ${jobId}: Incremental sync`, { since: sinceDate.toISOString().split('T')[0] });
// Execute the sync
result = await syncDCLQSOs(userId, user.dclApiKey, sinceDate, jobId);
} else {
logger.info(`Job ${jobId}: Full sync`);
// LoTW sync (default)
const user = await getUserById(userId);
if (!user || !user.lotwUsername || !user.lotwPassword) {
await updateJob(jobId, {
status: JobStatus.FAILED,
completedAt: new Date(),
error: 'LoTW credentials not configured',
});
return null;
}
// Get last QSL date for incremental sync
const { getLastLoTWQSLDate, syncQSOs } = await import('./lotw.service.js');
const lastQSLDate = await getLastLoTWQSLDate(userId);
const sinceDate = lastQSLDate || new Date('2000-01-01');
if (lastQSLDate) {
logger.info(`Job ${jobId}: LoTW incremental sync`, { since: sinceDate.toISOString().split('T')[0] });
} else {
logger.info(`Job ${jobId}: LoTW full sync`);
}
// Update job progress
await updateJobProgress(jobId, {
message: 'Fetching QSOs from LoTW...',
step: 'fetch',
});
// Execute the sync
result = await syncQSOs(userId, user.lotwUsername, user.lotwPassword, sinceDate, jobId);
}
// Update job progress
await updateJobProgress(jobId, {
message: 'Fetching QSOs from LoTW...',
step: 'fetch',
});
// Execute the sync
const result = await syncQSOs(userId, user.lotwUsername, user.lotwPassword, sinceDate, jobId);
// Update job as completed
await updateJob(jobId, {
status: JobStatus.COMPLETED,
@@ -197,9 +232,10 @@ export async function getJobStatus(jobId) {
/**
* Get user's active job (pending or running)
* @param {number} userId - User ID
* @param {string} jobType - Optional job type filter
* @returns {Promise<Object|null>} Active job or null
*/
export async function getUserActiveJob(userId) {
export async function getUserActiveJob(userId, jobType = null) {
const conditions = [
eq(syncJobs.userId, userId),
or(
@@ -208,6 +244,10 @@ export async function getUserActiveJob(userId) {
),
];
if (jobType) {
conditions.push(eq(syncJobs.type, jobType));
}
const [job] = await db
.select()
.from(syncJobs)

View File

@@ -74,6 +74,8 @@ export const qsosAPI = {
syncFromLoTW: () => apiRequest('/lotw/sync', { method: 'POST' }),
syncFromDCL: () => apiRequest('/dcl/sync', { method: 'POST' }),
deleteAll: () => apiRequest('/qsos/all', { method: 'DELETE' }),
};

View File

@@ -13,11 +13,20 @@
let pageSize = 100;
let pagination = null;
// Job polling state
let syncJobId = null;
let syncStatus = null;
let syncProgress = null;
let pollingInterval = null;
// Job polling state - LoTW
let lotwSyncJobId = null;
let lotwSyncStatus = null;
let lotwSyncProgress = null;
let lotwPollingInterval = null;
// Job polling state - DCL
let dclSyncJobId = null;
let dclSyncStatus = null;
let dclSyncProgress = null;
let dclPollingInterval = null;
// Sync result
let syncResult = null;
// Delete confirmation state
let showDeleteConfirm = false;
@@ -34,14 +43,17 @@
if (!$auth.user) return;
await loadQSOs();
await loadStats();
// Check for active job on mount
await checkActiveJob();
// Check for active jobs on mount
await checkActiveJobs();
});
// Clean up polling interval on unmount
// Clean up polling intervals on unmount
onDestroy(() => {
if (pollingInterval) {
clearInterval(pollingInterval);
if (lotwPollingInterval) {
clearInterval(lotwPollingInterval);
}
if (dclPollingInterval) {
clearInterval(dclPollingInterval);
}
});
@@ -76,98 +88,169 @@
}
}
async function checkActiveJob() {
async function checkActiveJobs() {
try {
const response = await jobsAPI.getActive();
if (response.job) {
syncJobId = response.job.id;
syncStatus = response.job.status;
// Start polling if job is running
if (syncStatus === 'running' || syncStatus === 'pending') {
startPolling(response.job.id);
const job = response.job;
if (job.type === 'lotw_sync') {
lotwSyncJobId = job.id;
lotwSyncStatus = job.status;
if (lotwSyncStatus === 'running' || lotwSyncStatus === 'pending') {
startLoTWPolling(job.id);
}
} else if (job.type === 'dcl_sync') {
dclSyncJobId = job.id;
dclSyncStatus = job.status;
if (dclSyncStatus === 'running' || dclSyncStatus === 'pending') {
startDCLPolling(job.id);
}
}
}
} catch (err) {
console.error('Failed to check active job:', err);
console.error('Failed to check active jobs:', err);
}
}
async function startPolling(jobId) {
syncJobId = jobId;
syncStatus = 'running';
async function startLoTWPolling(jobId) {
lotwSyncJobId = jobId;
lotwSyncStatus = 'running';
// Clear any existing interval
if (pollingInterval) {
clearInterval(pollingInterval);
if (lotwPollingInterval) {
clearInterval(lotwPollingInterval);
}
// Poll every 2 seconds
pollingInterval = setInterval(async () => {
lotwPollingInterval = setInterval(async () => {
try {
const response = await jobsAPI.getStatus(jobId);
const job = response.job;
syncStatus = job.status;
syncProgress = job.result?.progress ? job.result : null;
lotwSyncStatus = job.status;
lotwSyncProgress = job.result?.progress ? job.result : null;
if (job.status === 'completed') {
clearInterval(pollingInterval);
pollingInterval = null;
syncJobId = null;
syncProgress = null;
syncStatus = null;
clearInterval(lotwPollingInterval);
lotwPollingInterval = null;
lotwSyncJobId = null;
lotwSyncProgress = null;
lotwSyncStatus = null;
// Reload QSOs and stats
await loadQSOs();
await loadStats();
// Show success message
syncResult = {
success: true,
source: 'LoTW',
...job.result,
};
} else if (job.status === 'failed') {
clearInterval(pollingInterval);
pollingInterval = null;
syncJobId = null;
syncProgress = null;
syncStatus = null;
clearInterval(lotwPollingInterval);
lotwPollingInterval = null;
lotwSyncJobId = null;
lotwSyncProgress = null;
lotwSyncStatus = null;
// Show error message
syncResult = {
success: false,
error: job.error || 'Sync failed',
source: 'LoTW',
error: job.error || 'LoTW sync failed',
};
}
} catch (err) {
console.error('Failed to poll job status:', err);
// Don't stop polling on error, might be temporary
console.error('Failed to poll LoTW job status:', err);
}
}, 2000);
}
async function handleSync() {
async function startDCLPolling(jobId) {
dclSyncJobId = jobId;
dclSyncStatus = 'running';
if (dclPollingInterval) {
clearInterval(dclPollingInterval);
}
dclPollingInterval = setInterval(async () => {
try {
const response = await jobsAPI.getStatus(jobId);
const job = response.job;
dclSyncStatus = job.status;
dclSyncProgress = job.result?.progress ? job.result : null;
if (job.status === 'completed') {
clearInterval(dclPollingInterval);
dclPollingInterval = null;
dclSyncJobId = null;
dclSyncProgress = null;
dclSyncStatus = null;
await loadQSOs();
await loadStats();
syncResult = {
success: true,
source: 'DCL',
...job.result,
};
} else if (job.status === 'failed') {
clearInterval(dclPollingInterval);
dclPollingInterval = null;
dclSyncJobId = null;
dclSyncProgress = null;
dclSyncStatus = null;
syncResult = {
success: false,
source: 'DCL',
error: job.error || 'DCL sync failed',
};
}
} catch (err) {
console.error('Failed to poll DCL job status:', err);
}
}, 2000);
}
async function handleLoTWSync() {
try {
const response = await qsosAPI.syncFromLoTW();
if (response.jobId) {
// Job was queued successfully
startPolling(response.jobId);
startLoTWPolling(response.jobId);
} else if (response.existingJob) {
// There's already an active job
startPolling(response.existingJob);
startLoTWPolling(response.existingJob);
} else {
throw new Error(response.error || 'Failed to queue sync job');
throw new Error(response.error || 'Failed to queue LoTW sync job');
}
} catch (err) {
syncResult = {
success: false,
source: 'LoTW',
error: err.message,
};
}
}
let syncResult = null;
async function handleDCLSync() {
try {
const response = await qsosAPI.syncFromDCL();
if (response.jobId) {
startDCLPolling(response.jobId);
} else if (response.existingJob) {
startDCLPolling(response.existingJob);
} else {
throw new Error(response.error || 'Failed to queue DCL sync job');
}
} catch (err) {
syncResult = {
success: false,
source: 'DCL',
error: err.message,
};
}
}
async function applyFilters() {
currentPage = 1;
@@ -263,31 +346,52 @@
<button
class="btn btn-danger"
on:click={() => showDeleteConfirm = true}
disabled={syncStatus === 'running' || syncStatus === 'pending' || deleting}
disabled={lotwSyncStatus === 'running' || lotwSyncStatus === 'pending' || dclSyncStatus === 'running' || dclSyncStatus === 'pending' || deleting}
>
Clear All QSOs
</button>
{/if}
<button
class="btn btn-primary"
on:click={handleSync}
disabled={syncStatus === 'running' || syncStatus === 'pending' || deleting}
class="btn btn-primary lotw-btn"
on:click={handleLoTWSync}
disabled={lotwSyncStatus === 'running' || lotwSyncStatus === 'pending' || deleting}
>
{#if syncStatus === 'running' || syncStatus === 'pending'}
Syncing...
{#if lotwSyncStatus === 'running' || lotwSyncStatus === 'pending'}
LoTW Syncing...
{:else}
Sync from LoTW
{/if}
</button>
<button
class="btn btn-primary dcl-btn"
on:click={handleDCLSync}
disabled={dclSyncStatus === 'running' || dclSyncStatus === 'pending' || deleting}
>
{#if dclSyncStatus === 'running' || dclSyncStatus === 'pending'}
DCL Syncing...
{:else}
Sync from DCL
{/if}
</button>
</div>
</div>
{#if syncProgress}
{#if lotwSyncProgress}
<div class="alert alert-info">
<h3>Syncing from LoTW...</h3>
<p>{syncProgress.message || 'Processing...'}</p>
{#if syncProgress.total}
<p>Progress: {syncProgress.processed || 0} / {syncProgress.total}</p>
<p>{lotwSyncProgress.message || 'Processing...'}</p>
{#if lotwSyncProgress.total}
<p>Progress: {lotwSyncProgress.processed || 0} / {lotwSyncProgress.total}</p>
{/if}
</div>
{/if}
{#if dclSyncProgress}
<div class="alert alert-info">
<h3>Syncing from DCL...</h3>
<p>{dclSyncProgress.message || 'Processing...'}</p>
{#if dclSyncProgress.total}
<p>Progress: {dclSyncProgress.processed || 0} / {dclSyncProgress.total}</p>
{/if}
</div>
{/if}
@@ -602,6 +706,23 @@
.header-buttons {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.lotw-btn {
background-color: #4a90e2;
}
.lotw-btn:hover:not(:disabled) {
background-color: #357abd;
}
.dcl-btn {
background-color: #e67e22;
}
.dcl-btn:hover:not(:disabled) {
background-color: #d35400;
}
.stats-grid {

View File

@@ -194,8 +194,8 @@
<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.
Configure your DARC Community Logbook (DCL) API key to sync your QSOs.
Your API key is stored securely and used only to fetch your confirmed QSOs.
</p>
{#if hasDCLCredentials}
@@ -220,7 +220,7 @@
placeholder="Your DCL API key"
/>
<p class="hint">
Enter your DCL API key for future sync functionality
Enter your DCL API key to sync QSOs
</p>
</div>
@@ -233,10 +233,10 @@
<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.
It includes DOK (DARC Ortsverband Kennung) fields for local club awards like the DLD award.
</p>
<p>
<strong>Status:</strong> Download API not yet available.{' '}
Once configured, you can sync your QSOs from DCL on the QSO Log page.
<a href="https://dcl.darc.de/" target="_blank" rel="noopener">
Visit DCL website
</a>