From e04e7fab1acf85a2c8f97872eb46cd2b63062cd9 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Sun, 14 Dec 2025 11:01:36 -0600 Subject: [PATCH 1/2] many updates --- install-database.sql | 5 + migrations/add_name_to_lotw_credentials.sql | 13 + package-lock.json | 133 ++++++++ package.json | 1 + src/app/api/dxpeditions/route.ts | 232 +++++++++----- src/app/api/install/database/route.ts | 14 + src/app/api/lotw/certificate/route.ts | 29 +- .../api/lotw/certificate/set-active/route.ts | 74 +++++ src/app/api/lotw/download-contact/route.ts | 207 ++++++++++++ src/app/api/lotw/download/route.ts | 65 +++- src/app/api/lotw/upload-contact/route.ts | 133 ++++++++ .../migrate/lotw-credentials-name/route.ts | 56 ++++ src/app/api/stations/[id]/route.ts | 6 + src/app/api/stats/advanced/route.ts | 4 +- src/app/dashboard/page.tsx | 4 + src/app/dxpeditions/page.tsx | 25 +- src/app/profile/page.tsx | 300 +----------------- src/app/search/page.tsx | 6 +- src/app/stations/[id]/edit/page.tsx | 263 ++++++++++++++- src/components/DXpeditionWidget.tsx | 10 +- src/components/LotwSyncIndicator.tsx | 175 ++++++++-- src/lib/lotw.ts | 34 +- src/models/Station.ts | 5 + 23 files changed, 1317 insertions(+), 477 deletions(-) create mode 100644 migrations/add_name_to_lotw_credentials.sql create mode 100644 src/app/api/lotw/certificate/set-active/route.ts create mode 100644 src/app/api/lotw/download-contact/route.ts create mode 100644 src/app/api/lotw/upload-contact/route.ts create mode 100644 src/app/api/migrate/lotw-credentials-name/route.ts diff --git a/install-database.sql b/install-database.sql index 0094dd8..0fe35e8 100644 --- a/install-database.sql +++ b/install-database.sql @@ -412,9 +412,11 @@ CREATE TRIGGER update_qsl_images_updated_at FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); -- Create LoTW credentials table for certificate management +-- Create LoTW credentials table CREATE TABLE lotw_credentials ( id SERIAL PRIMARY KEY, station_id INTEGER NOT NULL REFERENCES stations(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, callsign VARCHAR(50) NOT NULL, p12_cert BYTEA NOT NULL, cert_created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, @@ -424,6 +426,9 @@ CREATE TABLE lotw_credentials ( updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); +-- Create index for active certificates lookup +CREATE INDEX idx_lotw_credentials_station_active ON lotw_credentials(station_id, is_active); + -- Create LoTW upload logs table CREATE TABLE lotw_upload_logs ( id SERIAL PRIMARY KEY, diff --git a/migrations/add_name_to_lotw_credentials.sql b/migrations/add_name_to_lotw_credentials.sql new file mode 100644 index 0000000..7654616 --- /dev/null +++ b/migrations/add_name_to_lotw_credentials.sql @@ -0,0 +1,13 @@ +-- Migration: Add name column to lotw_credentials table +-- This migration adds the 'name' column to the lotw_credentials table + +-- Add name column (allow NULL initially for existing records) +ALTER TABLE lotw_credentials ADD COLUMN IF NOT EXISTS name VARCHAR(255); + +-- Update existing records with a default name +UPDATE lotw_credentials +SET name = 'old K1AF Certificate ' || id +WHERE name IS NULL; + +-- Make the column NOT NULL after populating existing records +ALTER TABLE lotw_credentials ALTER COLUMN name SET NOT NULL; diff --git a/package-lock.json b/package-lock.json index 616e9d6..e3f21af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "lucide-react": "^0.539.0", "next": "15.4.7", "next-auth": "^4.24.11", + "node-html-parser": "^7.0.1", "pg": "^8.11.3", "react": "^19.1.1", "react-dom": "^19.1.1", @@ -3970,6 +3971,12 @@ "bcrypt": "bin/bcrypt" } }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -4226,6 +4233,34 @@ "node": ">= 8" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -4510,6 +4545,61 @@ "node": ">=0.10.0" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -4555,6 +4645,18 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-abstract": { "version": "1.24.0", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", @@ -5654,6 +5756,15 @@ "node": ">= 0.4" } }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -6982,6 +7093,28 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-html-parser": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-7.0.1.tgz", + "integrity": "sha512-KGtmPY2kS0thCWGK0VuPyOS+pBKhhe8gXztzA2ilAOhbUbxa9homF1bOyKvhGzMLXUoRds9IOmr/v5lr/lqNmA==", + "license": "MIT", + "dependencies": { + "css-select": "^5.1.0", + "he": "1.2.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/oauth": { "version": "0.9.15", "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", diff --git a/package.json b/package.json index 1e34168..24238fb 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "lucide-react": "^0.539.0", "next": "15.4.7", "next-auth": "^4.24.11", + "node-html-parser": "^7.0.1", "pg": "^8.11.3", "react": "^19.1.1", "react-dom": "^19.1.1", diff --git a/src/app/api/dxpeditions/route.ts b/src/app/api/dxpeditions/route.ts index 1b7b637..0f5fff0 100644 --- a/src/app/api/dxpeditions/route.ts +++ b/src/app/api/dxpeditions/route.ts @@ -1,6 +1,9 @@ import { NextRequest, NextResponse } from 'next/server'; import { verifyToken } from '@/lib/auth'; +// Force this route to use Node.js runtime instead of Edge +export const runtime = 'nodejs'; + interface DXpedition { callsign: string; dxcc: string; @@ -18,6 +21,68 @@ let cachedData: DXpedition[] | null = null; let cacheTimestamp: number = 0; const CACHE_DURATION = 6 * 60 * 60 * 1000; // 6 hours +function parseDate(dateStr: string): string { + // NG3K uses format like "2025 Nov01" or "2025 Nov 01" + try { + const cleaned = dateStr.trim().replace(/-/g, ' '); + const parts = cleaned.split(/\s+/); + + const monthMap: { [key: string]: string } = { + 'Jan': '01', 'Feb': '02', 'Mar': '03', 'Apr': '04', + 'May': '05', 'Jun': '06', 'Jul': '07', 'Aug': '08', + 'Sep': '09', 'Oct': '10', 'Nov': '11', 'Dec': '12' + }; + + if (parts.length >= 2) { + // Check if first part is a 4-digit year (new format: "2025 Nov01") + if (parts[0].length === 4 && !isNaN(Number(parts[0]))) { + const year = parts[0]; + const month = parts[1]; + const day = parts.length > 2 ? parts[2].padStart(2, '0') : parts[1].slice(3).padStart(2, '0'); + const monthNum = monthMap[month.slice(0, 3)] || '01'; + return `${year}-${monthNum}-${day}`; + } + // Old format: "29 Nov 25" or "29 Nov 2025" + else if (parts.length >= 3) { + const day = parts[0].padStart(2, '0'); + const month = parts[1]; + const year = parts[2].length === 2 ? `20${parts[2]}` : parts[2]; + const monthNum = monthMap[month] || '01'; + return `${year}-${monthNum}-${day}`; + } + } + } catch (e) { + console.error('Error parsing date:', dateStr, e); + } + return new Date().toISOString().split('T')[0]; +} + +function extractBandsAndModes(info: string): { bands?: string; modes?: string } { + const result: { bands?: string; modes?: string } = {}; + + // Extract bands (patterns like "160-10m", "80-10m", "40-6m", etc.) + const bandMatch = info.match(/\b(\d{1,3}[-–]\d{1,2}m|\d{1,3}m[-–]\d{1,2}m)\b/i); + if (bandMatch) { + result.bands = bandMatch[1]; + } + + // Extract modes (CW, SSB, FT8, FT4, RTTY, etc.) + const modePatterns = ['CW', 'SSB', 'FT8', 'FT4', 'RTTY', 'PSK', 'SSTV', 'FM', 'DIGITAL']; + const foundModes: string[] = []; + + for (const mode of modePatterns) { + if (new RegExp(`\\b${mode}\\b`, 'i').test(info)) { + foundModes.push(mode); + } + } + + if (foundModes.length > 0) { + result.modes = foundModes.join(', '); + } + + return result; +} + async function fetchDXpeditionData(): Promise { try { // Check cache first @@ -26,99 +91,96 @@ async function fetchDXpeditionData(): Promise { return cachedData; } - // Since ng3k.com doesn't have an API, we'll create some sample data - // In a real implementation, you could scrape their page or use another data source - const currentDate = new Date(); - const sampleDXpeditions: DXpedition[] = [ - { - callsign: 'VP8STI', - dxcc: 'South Sandwich Islands', - startDate: '2025-08-15', - endDate: '2025-08-28', - bands: '160-10m', - modes: 'CW, SSB, FT8', - qslVia: 'M0OXO', - info: 'Major DXpedition to VP8', - status: 'upcoming' - }, - { - callsign: 'FT4TA', - dxcc: 'Tromelin Island', - startDate: '2025-07-20', - endDate: '2025-07-30', - bands: '80-10m', - modes: 'CW, SSB, FT8, FT4', - qslVia: 'F6ARC', - info: 'Tromelin Island DXpedition', - status: 'active' - }, - { - callsign: 'E51AND', - dxcc: 'South Cook Islands', - startDate: '2025-09-10', - endDate: '2025-09-25', - bands: '160-6m', - modes: 'CW, SSB, FT8', - qslVia: 'JA1XGI', - info: 'Rarotonga operation', - status: 'upcoming' - }, - { - callsign: 'J28AA', - dxcc: 'Djibouti', - startDate: '2025-08-01', - endDate: '2025-08-14', - bands: '40-10m', - modes: 'CW, SSB, FT8', - qslVia: 'IK2DUW', - info: 'Multi-operator expedition', - status: 'upcoming' - }, - { - callsign: 'T32AZ', - dxcc: 'East Kiribati', - startDate: '2025-10-05', - endDate: '2025-10-20', - bands: '80-10m', - modes: 'CW, SSB, RTTY, FT8', - qslVia: 'JA1XGI', - info: 'Christmas Island DXpedition', - status: 'upcoming' - }, - { - callsign: '3Y0J', - dxcc: 'Bouvet Island', - startDate: '2025-01-15', - endDate: '2025-01-20', - bands: '80-10m', - modes: 'CW, SSB, FT8', - qslVia: 'LA2XPA', - info: 'Bouvet Island expedition (completed)', - status: 'completed' - } - ]; - - // Dynamically determine status based on current date - const updatedDXpeditions = sampleDXpeditions.map(dx => { - const startDate = new Date(dx.startDate); - const endDate = new Date(dx.endDate); - - if (currentDate >= startDate && currentDate <= endDate) { - return { ...dx, status: 'active' as const }; - } else if (currentDate > endDate) { - return { ...dx, status: 'completed' as const }; - } else { - return { ...dx, status: 'upcoming' as const }; + // Fetch data from NG3K + // Data source: https://ng3k.com/misc/adxo.html + const response = await fetch('https://ng3k.com/misc/adxo.html', { + headers: { + 'User-Agent': 'NextLog DXpedition Widget (https://github.com/yourusername/nextlog)' } }); + if (!response.ok) { + throw new Error(`Failed to fetch NG3K data: ${response.status}`); + } + + const html = await response.text(); + + // Use node-html-parser for better Next.js compatibility + const { parse } = await import('node-html-parser'); + const root = parse(html); + const dxpeditions: DXpedition[] = []; + const currentDate = new Date(); + + // Find the table with DXpedition data + const rows = root.querySelectorAll('table tr'); + + for (const row of rows) { + const cells = row.querySelectorAll('td'); + if (cells.length >= 6) { + try { + const startDateText = cells[0].text.trim(); + const endDateText = cells[1].text.trim(); + const dxcc = cells[2].text.trim(); + const callsignRaw = cells[3].text.trim(); + const qslVia = cells[4].text.trim(); + const info = cells[5].text.trim(); // Info column is the 6th column (index 5) + + // Extract callsign (remove [spots] if present) + const callsign = callsignRaw.replace(/\s*\[spots\]\s*/g, '').trim(); + + // Skip rows without valid data or header rows + if (!startDateText || !callsign || startDateText.includes('Start') || startDateText.includes('Date')) { + continue; + } + + const startDate = parseDate(startDateText); + const endDate = parseDate(endDateText); + + // Extract bands and modes from info + const { bands, modes } = extractBandsAndModes(info); + + // Determine status + const start = new Date(startDate); + const end = new Date(endDate); + let status: 'upcoming' | 'active' | 'completed'; + + if (currentDate >= start && currentDate <= end) { + status = 'active'; + } else if (currentDate > end) { + status = 'completed'; + } else { + status = 'upcoming'; + } + + dxpeditions.push({ + callsign, + dxcc, + startDate, + endDate, + bands, + modes, + qslVia: qslVia || undefined, + info: info || undefined, + status + }); + } catch (e) { + console.error('Error parsing row:', e); + } + } + } + // Update cache - cachedData = updatedDXpeditions; + cachedData = dxpeditions; cacheTimestamp = Date.now(); - return updatedDXpeditions; + return dxpeditions; } catch (error) { console.error('Error fetching DXpedition data:', error); + // Return cached data if available, even if stale + if (cachedData) { + console.log('Using stale cache due to fetch error'); + return cachedData; + } return []; } } diff --git a/src/app/api/install/database/route.ts b/src/app/api/install/database/route.ts index 0bba4d4..a82a6bc 100644 --- a/src/app/api/install/database/route.ts +++ b/src/app/api/install/database/route.ts @@ -243,6 +243,19 @@ export async function POST() { updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE (contact_id, image_type) ); + + CREATE TABLE IF NOT EXISTS lotw_credentials ( + id SERIAL PRIMARY KEY, + station_id INTEGER NOT NULL REFERENCES stations(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + callsign VARCHAR(50) NOT NULL, + p12_cert BYTEA NOT NULL, + cert_created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + cert_expires_at TIMESTAMP, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); -- Create indexes for QRZ QSL fields CREATE INDEX IF NOT EXISTS idx_contacts_qrz_qsl_sent ON contacts(qrz_qsl_sent); @@ -265,6 +278,7 @@ export async function POST() { CREATE INDEX IF NOT EXISTS idx_audit_log_action ON admin_audit_log(action); CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON admin_audit_log(created_at DESC); CREATE INDEX IF NOT EXISTS idx_audit_log_target ON admin_audit_log(target_type, target_id); + CREATE INDEX IF NOT EXISTS idx_lotw_credentials_station_active ON lotw_credentials(station_id, is_active); -- Create trigger function for updated_at CREATE OR REPLACE FUNCTION update_updated_at_column() diff --git a/src/app/api/lotw/certificate/route.ts b/src/app/api/lotw/certificate/route.ts index 71d110a..54e4f1e 100644 --- a/src/app/api/lotw/certificate/route.ts +++ b/src/app/api/lotw/certificate/route.ts @@ -16,10 +16,11 @@ export async function POST(request: NextRequest) { const file = formData.get('p12_file') as File; const stationId = formData.get('station_id') as string; const callsign = formData.get('callsign') as string; + const certName = formData.get('cert_name') as string; - if (!file || !stationId || !callsign) { - return NextResponse.json({ - error: 'Missing required fields: p12_file, station_id, callsign' + if (!file || !stationId || !callsign || !certName) { + return NextResponse.json({ + error: 'Missing required fields: p12_file, station_id, callsign, cert_name' }, { status: 400 }); } @@ -81,13 +82,13 @@ export async function POST(request: NextRequest) { ); } - // Store new certificate + // Store new certificate in lotw_credentials table const insertResult = await query( - `INSERT INTO lotw_credentials - (station_id, callsign, p12_cert, cert_created_at, is_active) - VALUES ($1, $2, $3, NOW(), true) + `INSERT INTO lotw_credentials + (station_id, name, callsign, p12_cert, cert_created_at, is_active) + VALUES ($1, $2, $3, $4, NOW(), true) RETURNING id, cert_created_at`, - [parseInt(stationId), callsign.toUpperCase(), fileBuffer] + [parseInt(stationId), certName.trim(), callsign.toUpperCase(), fileBuffer] ); const newCredential = insertResult.rows[0]; @@ -142,9 +143,9 @@ export async function GET(request: NextRequest) { // Get certificate info (without the actual certificate data) const certResult = await query( - `SELECT id, callsign, cert_created_at, cert_expires_at, is_active, created_at - FROM lotw_credentials - WHERE station_id = $1 + `SELECT id, name, callsign, cert_created_at, cert_expires_at, is_active, created_at + FROM lotw_credentials + WHERE station_id = $1 ORDER BY created_at DESC`, [parseInt(stationId)] ); @@ -181,7 +182,7 @@ export async function DELETE(request: NextRequest) { // Verify credential belongs to user's station const certResult = await query( - `SELECT lc.id, lc.station_id + `SELECT lc.id, lc.station_id FROM lotw_credentials lc JOIN stations s ON lc.station_id = s.id WHERE lc.id = $1 AND s.user_id = $2`, @@ -189,8 +190,8 @@ export async function DELETE(request: NextRequest) { ); if (certResult.rows.length === 0) { - return NextResponse.json({ - error: 'Certificate not found or access denied' + return NextResponse.json({ + error: 'Certificate not found or access denied' }, { status: 404 }); } diff --git a/src/app/api/lotw/certificate/set-active/route.ts b/src/app/api/lotw/certificate/set-active/route.ts new file mode 100644 index 0000000..c439d12 --- /dev/null +++ b/src/app/api/lotw/certificate/set-active/route.ts @@ -0,0 +1,74 @@ +// Set Active LoTW Certificate API endpoint + +import { NextRequest, NextResponse } from 'next/server'; +import { verifyToken } from '@/lib/auth'; +import { query } from '@/lib/db'; + +export async function POST(request: NextRequest) { + try { + const user = await verifyToken(request); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json(); + const { station_id, certificate_id } = body; + + if (!station_id || !certificate_id) { + return NextResponse.json({ + error: 'Missing required fields: station_id, certificate_id' + }, { status: 400 }); + } + + // Verify station belongs to user + const stationResult = await query( + 'SELECT id FROM stations WHERE id = $1 AND user_id = $2', + [parseInt(station_id), parseInt(user.userId)] + ); + + if (stationResult.rows.length === 0) { + return NextResponse.json({ + error: 'Station not found or access denied' + }, { status: 404 }); + } + + // Verify certificate belongs to this station + const certResult = await query( + 'SELECT id FROM lotw_credentials WHERE id = $1 AND station_id = $2', + [parseInt(certificate_id), parseInt(station_id)] + ); + + if (certResult.rows.length === 0) { + return NextResponse.json({ + error: 'Certificate not found for this station' + }, { status: 404 }); + } + + // Deactivate all certificates for this station + await query( + 'UPDATE lotw_credentials SET is_active = false WHERE station_id = $1', + [parseInt(station_id)] + ); + + // Activate the selected certificate + await query( + 'UPDATE lotw_credentials SET is_active = true WHERE id = $1', + [parseInt(certificate_id)] + ); + + return NextResponse.json({ + success: true, + message: 'Active certificate updated successfully' + }); + + } catch (error) { + console.error('Set active certificate error:', error); + return NextResponse.json( + { + success: false, + error: 'Internal server error' + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/lotw/download-contact/route.ts b/src/app/api/lotw/download-contact/route.ts new file mode 100644 index 0000000..272d0e4 --- /dev/null +++ b/src/app/api/lotw/download-contact/route.ts @@ -0,0 +1,207 @@ +// LoTW Single Contact Download/Confirmation API endpoint + +import { NextRequest, NextResponse } from 'next/server'; +import { verifyToken } from '@/lib/auth'; +import { query } from '@/lib/db'; +import { parseLoTWAdif, matchLoTWConfirmations, buildLoTWDownloadUrl, decryptString } from '@/lib/lotw'; +import { ContactWithLoTW } from '@/types/lotw'; + +export async function POST(request: NextRequest) { + try { + const user = await verifyToken(request); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json(); + const { contact_id } = body; + + if (!contact_id) { + return NextResponse.json({ + error: 'contact_id is required' + }, { status: 400 }); + } + + // Get the contact and verify ownership + const contactResult = await query( + `SELECT c.*, s.callsign as station_callsign, s.id as station_id, + s.lotw_username, s.lotw_password, u.third_party_services + FROM contacts c + JOIN stations s ON c.station_id = s.id + JOIN users u ON s.user_id = u.id + WHERE c.id = $1 AND c.user_id = $2`, + [contact_id, parseInt(user.userId)] + ); + + if (contactResult.rows.length === 0) { + return NextResponse.json({ + error: 'Contact not found or access denied' + }, { status: 404 }); + } + + const contact: ContactWithLoTW & { + lotw_username?: string; + lotw_password?: string; + third_party_services?: { lotw?: { username: string; password: string } }; + station_callsign: string; + station_id: number; + } = contactResult.rows[0]; + + // Check if already confirmed + if (contact.qsl_lotw === true || contact.lotw_qsl_rcvd === 'Y') { + return NextResponse.json({ + success: false, + error: 'Contact already confirmed via LoTW' + }, { status: 400 }); + } + + // Get LoTW credentials from station or user third_party_services + let lotwUsername = contact.lotw_username; + let lotwPassword = contact.lotw_password; + let credentialSource = 'none'; + + console.log(`[LoTW Download] Checking credentials for contact ${contact_id}, station ${contact.station_id} (${contact.station_callsign})`); + console.log(`[LoTW Download] Station credentials present: username=${!!lotwUsername}, password=${!!lotwPassword}`); + + // Try user's third_party_services if station doesn't have credentials + if (!lotwUsername || !lotwPassword) { + const thirdPartyServices = contact.third_party_services; + console.log(`[LoTW Download] Checking user-level credentials: third_party_services=${!!thirdPartyServices}, lotw field=${!!thirdPartyServices?.lotw}`); + + if (thirdPartyServices?.lotw) { + lotwUsername = thirdPartyServices.lotw.username; + try { + lotwPassword = decryptString(thirdPartyServices.lotw.password); + credentialSource = 'user'; + console.log(`[LoTW Download] Using user-level credentials, username present: ${!!lotwUsername}`); + } catch (error) { + console.error('[LoTW Download] Failed to decrypt user password:', error); + lotwPassword = undefined; + } + } + } else if (lotwPassword) { + // Decrypt station password if it exists + try { + lotwPassword = decryptString(lotwPassword); + credentialSource = 'station'; + console.log(`[LoTW Download] Using station-level credentials for station ${contact.station_id}`); + } catch (error) { + console.error('[LoTW Download] Failed to decrypt station password:', error); + lotwPassword = undefined; + } + } + + if (!lotwUsername || !lotwPassword) { + console.error(`[LoTW Download] No valid credentials found. Username: ${!!lotwUsername}, Password: ${!!lotwPassword}, Source attempted: ${credentialSource}`); + + const errorMessage = credentialSource === 'none' + ? 'LoTW credentials not configured. Please configure LoTW credentials in Settings > LoTW Integration (either at User level for all stations, or Station level for this specific station).' + : 'LoTW credentials are incomplete or invalid. Please reconfigure them in Settings > LoTW Integration.'; + + return NextResponse.json({ + error: errorMessage, + debug: { + station_id: contact.station_id, + station_callsign: contact.station_callsign, + credential_source: credentialSource, + has_username: !!lotwUsername, + has_password: !!lotwPassword + } + }, { status: 400 }); + } + + console.log(`[LoTW Download] Credentials validated from source: ${credentialSource}`); + + // Create a date range around the contact (±1 day to be safe) + const contactDate = new Date(contact.datetime); + const dateFrom = new Date(contactDate); + dateFrom.setDate(dateFrom.getDate() - 1); + const dateTo = new Date(contactDate); + dateTo.setDate(dateTo.getDate() + 1); + + const dateFromStr = dateFrom.toISOString().split('T')[0]; + const dateToStr = dateTo.toISOString().split('T')[0]; + + // Build LoTW download URL with date range + const downloadUrl = buildLoTWDownloadUrl(lotwUsername, lotwPassword, dateFromStr, dateToStr); + + // Download confirmations from LoTW + let adifContent: string; + try { + const downloadResponse = await fetch(downloadUrl, { + method: 'GET', + headers: { + 'User-Agent': 'Nextlog/1.0.0', + }, + }); + + if (!downloadResponse.ok) { + throw new Error(`LoTW download failed: ${downloadResponse.status} ${downloadResponse.statusText}`); + } + + adifContent = await downloadResponse.text(); + + // Check if the response indicates an authentication error + if (adifContent.includes('Invalid login') || adifContent.includes('Login failed')) { + throw new Error('Invalid LoTW credentials'); + } + + } catch (downloadError) { + console.error('LoTW download error:', downloadError); + return NextResponse.json({ + success: false, + error: `Download from LoTW failed: ${downloadError instanceof Error ? downloadError.message : 'Unknown error'}` + }, { status: 500 }); + } + + // Parse ADIF confirmations + const confirmations = parseLoTWAdif(adifContent); + + if (confirmations.length === 0) { + return NextResponse.json({ + success: false, + error: 'No confirmations found in LoTW for this contact' + }); + } + + // Match confirmations with this specific contact + const matches = matchLoTWConfirmations(confirmations, [contact]); + + if (matches.length === 0) { + return NextResponse.json({ + success: false, + error: 'No matching confirmation found in LoTW for this contact' + }); + } + + const match = matches[0]; + + // Update the contact with confirmation + await query( + `UPDATE contacts + SET qsl_lotw = true, + qsl_lotw_date = NOW()::date, + lotw_qsl_rcvd = 'Y', + lotw_match_status = $1, + updated_at = NOW() + WHERE id = $2`, + [match.matchStatus, contact_id] + ); + + return NextResponse.json({ + success: true, + message: 'Contact confirmed via LoTW', + match_status: match.matchStatus + }); + + } catch (error) { + console.error('LoTW single contact download error:', error); + return NextResponse.json( + { + success: false, + error: 'Internal server error' + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/lotw/download/route.ts b/src/app/api/lotw/download/route.ts index 1ee9c53..26fb96a 100644 --- a/src/app/api/lotw/download/route.ts +++ b/src/app/api/lotw/download/route.ts @@ -11,7 +11,7 @@ export async function POST(request: NextRequest) { // Check if this is a cron job request const isCronJob = request.headers.get('X-Cron-Job') === 'true'; let user = null; - + if (isCronJob) { // For cron jobs, we'll get the user from the station_id user = null; // Will be set later @@ -26,8 +26,8 @@ export async function POST(request: NextRequest) { const { station_id, date_from, date_to, download_method = 'manual' } = body; if (!station_id) { - return NextResponse.json({ - error: 'station_id is required' + return NextResponse.json({ + error: 'station_id is required' }, { status: 400 }); } @@ -59,36 +59,71 @@ export async function POST(request: NextRequest) { } if (stationResult.rows.length === 0) { - return NextResponse.json({ - error: 'Station not found or access denied' + return NextResponse.json({ + error: 'Station not found or access denied' }, { status: 404 }); } const station = stationResult.rows[0]; const userId = isCronJob ? station.user_id : parseInt(user!.userId); - + // Get LoTW credentials from station or user third_party_services let lotwUsername = station.lotw_username; let lotwPassword = station.lotw_password; + let credentialSource = 'none'; + + console.log(`[LoTW Download] Checking credentials for station ${station_id} (${station.callsign})`); + console.log(`[LoTW Download] Station credentials present: username=${!!lotwUsername}, password=${!!lotwPassword}`); // Try user's third_party_services if station doesn't have credentials if (!lotwUsername || !lotwPassword) { const thirdPartyServices = station.third_party_services; + console.log(`[LoTW Download] Checking user-level credentials: third_party_services=${!!thirdPartyServices}, lotw field=${!!thirdPartyServices?.lotw}`); + if (thirdPartyServices?.lotw) { lotwUsername = thirdPartyServices.lotw.username; - lotwPassword = decryptString(thirdPartyServices.lotw.password); + try { + lotwPassword = decryptString(thirdPartyServices.lotw.password); + credentialSource = 'user'; + console.log(`[LoTW Download] Using user-level credentials, username present: ${!!lotwUsername}`); + } catch (error) { + console.error('[LoTW Download] Failed to decrypt user password:', error); + lotwPassword = undefined; + } } } else if (lotwPassword) { // Decrypt station password if it exists - lotwPassword = decryptString(lotwPassword); + try { + lotwPassword = decryptString(lotwPassword); + credentialSource = 'station'; + console.log(`[LoTW Download] Using station-level credentials for station ${station_id}`); + } catch (error) { + console.error('[LoTW Download] Failed to decrypt station password:', error); + lotwPassword = undefined; + } } if (!lotwUsername || !lotwPassword) { - return NextResponse.json({ - error: 'LoTW credentials not configured for this station. Please configure username and password in station settings.' + console.error(`[LoTW Download] No valid credentials found. Username: ${!!lotwUsername}, Password: ${!!lotwPassword}, Source attempted: ${credentialSource}`); + + const errorMessage = credentialSource === 'none' + ? 'LoTW credentials not configured. Please configure LoTW credentials in Settings > LoTW Integration (either at User level for all stations, or Station level for this specific station).' + : 'LoTW credentials are incomplete or invalid. Please reconfigure them in Settings > LoTW Integration.'; + + return NextResponse.json({ + error: errorMessage, + debug: { + station_id: station_id, + station_callsign: station.callsign, + credential_source: credentialSource, + has_username: !!lotwUsername, + has_password: !!lotwPassword + } }, { status: 400 }); } + console.log(`[LoTW Download] Credentials validated from source: ${credentialSource}`); + // Create download log entry const downloadLogResult = await query( `INSERT INTO lotw_download_logs @@ -133,7 +168,7 @@ export async function POST(request: NextRequest) { } catch (downloadError) { console.error('LoTW download error:', downloadError); - + await query( `UPDATE lotw_download_logs SET status = 'failed', completed_at = NOW(), @@ -142,7 +177,7 @@ export async function POST(request: NextRequest) { [`Download from LoTW failed: ${downloadError instanceof Error ? downloadError.message : 'Unknown error'}`, downloadLogId] ); - return NextResponse.json({ + return NextResponse.json({ success: false, download_log_id: downloadLogId, error_message: `Download from LoTW failed: ${downloadError instanceof Error ? downloadError.message : 'Unknown error'}` @@ -217,7 +252,7 @@ export async function POST(request: NextRequest) { WHERE id = $2`, [match.matchStatus, match.contact.id] ); - + matchedCount++; unmatchedCount--; } @@ -244,7 +279,7 @@ export async function POST(request: NextRequest) { } catch (error) { console.error('Download processing error:', error); - + // Update log with error await query( `UPDATE lotw_download_logs @@ -254,7 +289,7 @@ export async function POST(request: NextRequest) { [error instanceof Error ? error.message : 'Unknown error', downloadLogId] ); - return NextResponse.json({ + return NextResponse.json({ success: false, download_log_id: downloadLogId, error_message: error instanceof Error ? error.message : 'Unknown error' diff --git a/src/app/api/lotw/upload-contact/route.ts b/src/app/api/lotw/upload-contact/route.ts new file mode 100644 index 0000000..04d783f --- /dev/null +++ b/src/app/api/lotw/upload-contact/route.ts @@ -0,0 +1,133 @@ +// LoTW Single Contact Upload API endpoint + +import { NextRequest, NextResponse } from 'next/server'; +import { verifyToken } from '@/lib/auth'; +import { query } from '@/lib/db'; +import { generateAdifForLoTW, signAdifWithCertificate } from '@/lib/lotw'; +import { ContactWithLoTW } from '@/types/lotw'; + +export async function POST(request: NextRequest) { + try { + const user = await verifyToken(request); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json(); + const { contact_id } = body; + + if (!contact_id) { + return NextResponse.json({ + error: 'contact_id is required' + }, { status: 400 }); + } + + // Get the contact and verify ownership + const contactResult = await query( + `SELECT c.*, s.callsign as station_callsign, s.id as station_id + FROM contacts c + JOIN stations s ON c.station_id = s.id + WHERE c.id = $1 AND c.user_id = $2`, + [contact_id, parseInt(user.userId)] + ); + + if (contactResult.rows.length === 0) { + return NextResponse.json({ + error: 'Contact not found or access denied' + }, { status: 404 }); + } + + const contact: ContactWithLoTW & { station_callsign: string; station_id: number } = contactResult.rows[0]; + + // Check if already uploaded + if (contact.lotw_qsl_sent === 'Y') { + return NextResponse.json({ + success: false, + error: 'Contact already uploaded to LoTW' + }, { status: 400 }); + } + + // Get active LoTW certificate for this station + const certResult = await query( + 'SELECT id, p12_cert FROM lotw_credentials WHERE station_id = $1 AND is_active = true ORDER BY created_at DESC LIMIT 1', + [contact.station_id] + ); + + if (certResult.rows.length === 0) { + return NextResponse.json({ + error: 'No active LoTW certificate found for this station. Please upload a certificate first.' + }, { status: 400 }); + } + + const certificate = certResult.rows[0]; + + // Generate ADIF content for single contact + const adifContent = generateAdifForLoTW([contact], contact.station_callsign); + + // Sign ADIF file with certificate + let signedContent: string; + try { + signedContent = await signAdifWithCertificate( + adifContent, + certificate.p12_cert, + contact.station_callsign + ); + } catch (signError) { + console.error('ADIF signing error:', signError); + return NextResponse.json({ + success: false, + error: `Failed to sign ADIF file: ${signError instanceof Error ? signError.message : 'Unknown error'}` + }, { status: 500 }); + } + + // Upload to LoTW + let lotwResponse = ''; + try { + const uploadResponse = await fetch('https://lotw.arrl.org/lotwuser/upload', { + method: 'POST', + headers: { + 'Content-Type': 'application/octet-stream', + 'Content-Disposition': `attachment; filename="${contact.station_callsign}.tq8"`, + }, + body: signedContent, + }); + + lotwResponse = await uploadResponse.text(); + + if (!uploadResponse.ok) { + throw new Error(`LoTW upload failed: ${uploadResponse.status} ${lotwResponse}`); + } + + } catch (uploadError) { + console.error('LoTW upload error:', uploadError); + return NextResponse.json({ + success: false, + error: `Upload to LoTW failed: ${uploadError instanceof Error ? uploadError.message : 'Unknown error'}` + }, { status: 500 }); + } + + // Mark contact as uploaded to LoTW + await query( + `UPDATE contacts + SET lotw_qsl_sent = 'Y', updated_at = NOW() + WHERE id = $1`, + [contact_id] + ); + + return NextResponse.json({ + success: true, + message: 'Contact uploaded to LoTW successfully', + lotw_response: lotwResponse + }); + + } catch (error) { + console.error('LoTW single contact upload error:', error); + return NextResponse.json( + { + success: false, + error: 'Internal server error' + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/migrate/lotw-credentials-name/route.ts b/src/app/api/migrate/lotw-credentials-name/route.ts new file mode 100644 index 0000000..13b8e5d --- /dev/null +++ b/src/app/api/migrate/lotw-credentials-name/route.ts @@ -0,0 +1,56 @@ +// Migration API: Add name column to lotw_credentials table +import { NextResponse } from 'next/server'; +import { query } from '@/lib/db'; + +export async function POST() { + try { + // Check if name column already exists + const columnCheck = await query(` + SELECT column_name + FROM information_schema.columns + WHERE table_name = 'lotw_credentials' + AND column_name = 'name' + `); + + if (columnCheck.rows.length > 0) { + return NextResponse.json({ + success: true, + message: 'Migration already applied - name column already exists' + }); + } + + // Add name column (allow NULL initially for existing records) + await query(` + ALTER TABLE lotw_credentials + ADD COLUMN name VARCHAR(255) + `); + + // Update existing records with a default name + await query(` + UPDATE lotw_credentials + SET name = 'Certificate ' || id + WHERE name IS NULL + `); + + // Make the column NOT NULL after populating existing records + await query(` + ALTER TABLE lotw_credentials + ALTER COLUMN name SET NOT NULL + `); + + return NextResponse.json({ + success: true, + message: 'Successfully added name column to lotw_credentials table' + }); + + } catch (error) { + console.error('Migration error:', error); + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Migration failed' + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/stations/[id]/route.ts b/src/app/api/stations/[id]/route.ts index b1665e6..8dc66e0 100644 --- a/src/app/api/stations/[id]/route.ts +++ b/src/app/api/stations/[id]/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { Station } from '@/models/Station'; import { verifyToken } from '@/lib/auth'; +import { encryptString } from '@/lib/lotw'; export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { try { @@ -95,6 +96,11 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ ); } + // Encrypt LoTW password if provided + if (data.lotw_password) { + data.lotw_password = encryptString(data.lotw_password); + } + const station = await Station.update(parseInt(id), data); if (!station) { diff --git a/src/app/api/stats/advanced/route.ts b/src/app/api/stats/advanced/route.ts index 95a15ee..2c8b383 100644 --- a/src/app/api/stats/advanced/route.ts +++ b/src/app/api/stats/advanced/route.ts @@ -132,7 +132,7 @@ async function getGeographicAnalytics(whereClause: string, params: unknown[]) { de.continent, COUNT(*) as qsos FROM contacts c - LEFT JOIN dxcc_entities de ON c.dxcc_entity_id = de.id + LEFT JOIN dxcc_entities de ON c.dxcc = de.adif ${whereClause} GROUP BY de.name, de.continent ORDER BY qsos DESC @@ -145,7 +145,7 @@ async function getGeographicAnalytics(whereClause: string, params: unknown[]) { COALESCE(de.continent, 'Unknown') as continent, COUNT(*) as qsos FROM contacts c - LEFT JOIN dxcc_entities de ON c.dxcc_entity_id = de.id + LEFT JOIN dxcc_entities de ON c.dxcc = de.adif ${whereClause} GROUP BY de.continent ORDER BY qsos DESC diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index cce65f5..1b413b7 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -19,6 +19,7 @@ import { useUser } from '@/contexts/UserContext'; interface Contact { id: number; + station_id?: number; callsign: string; frequency: number; mode: string; @@ -334,6 +335,9 @@ export default function DashboardPage() { qslLotw={contact.qsl_lotw} qslLotwDate={contact.qsl_lotw_date} lotwMatchStatus={contact.lotw_match_status} + contactId={contact.id} + stationId={contact.station_id} + onStatusChange={() => fetchContacts()} size="sm" /> diff --git a/src/app/dxpeditions/page.tsx b/src/app/dxpeditions/page.tsx index 4573604..421af89 100644 --- a/src/app/dxpeditions/page.tsx +++ b/src/app/dxpeditions/page.tsx @@ -35,7 +35,7 @@ export default function DXpeditionsPage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [activeTab, setActiveTab] = useState('all'); - const { user } = useUser(); + const { user, loading: userLoading } = useUser(); const router = useRouter(); const fetchDXpeditions = useCallback(async () => { @@ -43,12 +43,12 @@ export default function DXpeditionsPage() { setLoading(true); setError(''); const response = await fetch('/api/dxpeditions?limit=0&status=all'); - + if (response.status === 401) { router.push('/login'); return; } - + if (response.ok) { const fetchedData = await response.json(); setData(fetchedData); @@ -63,12 +63,15 @@ export default function DXpeditionsPage() { }, [router]); useEffect(() => { - if (!user && !loading) { + if (userLoading) return; + + if (!user) { router.push('/login'); return; } + fetchDXpeditions(); - }, [user, router, loading, fetchDXpeditions]); + }, [user, userLoading, router, fetchDXpeditions]); const filterDXpeditions = (status: string) => { if (!data) return []; @@ -273,7 +276,17 @@ export default function DXpeditionsPage() { DXpedition List - Detailed information about current and upcoming DX operations + Detailed information about current and upcoming DX operations. + Data provided by{' '} + + NG3K + + . diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx index 84fb131..0175194 100644 --- a/src/app/profile/page.tsx +++ b/src/app/profile/page.tsx @@ -8,7 +8,7 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Switch } from '@/components/ui/switch'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { ArrowLeft, Loader2, User, Key, MapPin, Settings, Radio, Upload, CheckCircle, Eye, EyeOff } from 'lucide-react'; +import { ArrowLeft, Loader2, User, Key, MapPin, Settings, CheckCircle } from 'lucide-react'; import Navbar from '@/components/Navbar'; import ThemeToggle from '@/components/ThemeToggle'; @@ -31,22 +31,14 @@ export default function ProfilePage() { grid_locator: '', qrz_username: '', qrz_password: '', - qrz_auto_sync: false, - lotw_username: '', - lotw_password: '' + qrz_auto_sync: false }); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [error, setError] = useState(''); const [success, setSuccess] = useState(''); - const [showLotwPassword, setShowLotwPassword] = useState(false); - const [testingLotw, setTestingLotw] = useState(false); const [validatingQrz, setValidatingQrz] = useState(false); const [qrzValidationResult, setQrzValidationResult] = useState<{valid?: boolean; error?: string} | null>(null); - const [certFile, setCertFile] = useState(null); - const [uploadingCert, setUploadingCert] = useState(false); - const [stations, setStations] = useState<{id: number; callsign: string; station_name: string; is_default: boolean}[]>([]); - const [selectedStation, setSelectedStation] = useState(''); const router = useRouter(); const fetchUser = useCallback(async () => { @@ -66,9 +58,7 @@ export default function ProfilePage() { grid_locator: data.user.grid_locator || '', qrz_username: data.user.qrz_username || '', qrz_password: data.user.qrz_password || '', - qrz_auto_sync: data.user.qrz_auto_sync || false, - lotw_username: data.user.third_party_services?.lotw?.username || '', - lotw_password: '' + qrz_auto_sync: data.user.qrz_auto_sync || false }); } else { setError(data.error || 'Failed to fetch user profile'); @@ -80,28 +70,9 @@ export default function ProfilePage() { } }, [router]); - const fetchStations = useCallback(async () => { - try { - const response = await fetch('/api/stations'); - if (response.ok) { - const data = await response.json(); - setStations(data.stations || []); - - // Set default station if none selected - if (!selectedStation && data.stations?.length > 0) { - const defaultStation = data.stations.find((s: {is_default: boolean}) => s.is_default) || data.stations[0]; - setSelectedStation(defaultStation.id.toString()); - } - } - } catch (error) { - console.error('Failed to load stations:', error); - } - }, [selectedStation]); - useEffect(() => { fetchUser(); - fetchStations(); - }, [fetchUser, fetchStations]); + }, [fetchUser]); const handleChange = (e: React.ChangeEvent) => { const { name, value } = e.target; @@ -178,125 +149,6 @@ export default function ProfilePage() { } }; - const testLotwCredentials = async () => { - if (!formData.lotw_username || !formData.lotw_password) { - setError('Both LoTW username and password are required for testing'); - return; - } - - try { - setTestingLotw(true); - setError(''); - setSuccess(''); - - const response = await fetch('/api/lotw/credentials', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - username: formData.lotw_username, - password: formData.lotw_password - }) - }); - - const data = await response.json(); - - if (response.ok) { - if (data.valid) { - setSuccess('LoTW credentials are valid!'); - } else { - setError('Invalid LoTW credentials. Please check your username and password.'); - } - } else { - setError(data.error || 'Failed to test credentials'); - } - - } catch (error) { - console.error('Test credentials error:', error); - setError('Failed to test credentials'); - } finally { - setTestingLotw(false); - } - }; - - const saveLotwCredentials = async () => { - if (!formData.lotw_username || !formData.lotw_password) { - setError('Both LoTW username and password are required'); - return; - } - - try { - setSaving(true); - setError(''); - setSuccess(''); - - const response = await fetch('/api/lotw/credentials', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - username: formData.lotw_username, - password: formData.lotw_password - }) - }); - - const data = await response.json(); - - if (response.ok) { - setSuccess('LoTW credentials saved successfully!'); - setFormData(prev => ({ ...prev, lotw_password: '' })); // Clear password field - setTimeout(() => setSuccess(''), 3000); - } else { - setError(data.error || 'Failed to save LoTW credentials'); - } - - } catch (error) { - console.error('Save LoTW credentials error:', error); - setError('Failed to save LoTW credentials'); - } finally { - setSaving(false); - } - }; - - const handleCertificateUpload = async () => { - if (!certFile || !selectedStation || !formData.callsign) { - setError('Please select a station, enter your callsign, and choose a certificate file'); - return; - } - - try { - setUploadingCert(true); - setError(''); - setSuccess(''); - - const certFormData = new FormData(); - certFormData.append('p12_file', certFile); - certFormData.append('station_id', selectedStation); - certFormData.append('callsign', formData.callsign); - - const response = await fetch('/api/lotw/certificate', { - method: 'POST', - body: certFormData - }); - - const data = await response.json(); - - if (response.ok) { - setSuccess('LoTW certificate uploaded successfully!'); - setCertFile(null); - // Reset file input - const fileInput = document.getElementById('cert-file') as HTMLInputElement; - if (fileInput) fileInput.value = ''; - setTimeout(() => setSuccess(''), 3000); - } else { - setError(data.error || 'Certificate upload failed'); - } - - } catch (error) { - console.error('Certificate upload error:', error); - setError('Certificate upload failed'); - } finally { - setUploadingCert(false); - } - }; if (loading) { return ( @@ -492,150 +344,6 @@ export default function ProfilePage() { - {/* LoTW Configuration */} -
-
- -

Logbook of The World (LoTW)

-
-
- {/* LoTW Credentials */} -
-
-
- - -
-
- -
- - -
-
-
- -
- - - -
-
- - {/* Certificate Upload */} - {stations.length > 0 && ( -
-

Upload LoTW Certificate

-
-
-
- - -
-
- - setCertFile(e.target.files?.[0] || null)} - /> -
-
- - -
-
- )} - -
-

- LoTW Integration: -

-
    -
  • Enter your ARRL LoTW website username and password
  • -
  • Upload your .p12 certificate file for each station to enable uploads
  • -
  • Use the LoTW sync page to upload QSOs and download confirmations
  • -
  • Your credentials are encrypted and stored securely
  • -
-
-
-
{error && (
diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index 4c1ee15..25d2222 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -24,6 +24,7 @@ import { useUser } from '@/contexts/UserContext'; interface Contact { id: number; + station_id?: number; callsign: string; frequency: number; mode: string; @@ -1116,13 +1117,16 @@ export default function SearchPage() { handleContactClick(contact)} className="cursor-pointer"> {contact.qth || '-'} - handleContactClick(contact)} className="cursor-pointer"> + e.stopPropagation()}> performSearch(filters, pagination.page)} size="sm" /> diff --git a/src/app/stations/[id]/edit/page.tsx b/src/app/stations/[id]/edit/page.tsx index 21aebd0..6656051 100644 --- a/src/app/stations/[id]/edit/page.tsx +++ b/src/app/stations/[id]/edit/page.tsx @@ -9,7 +9,7 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { Combobox } from '@/components/ui/combobox'; -import { ArrowLeft, Save, Radio, Loader2, CheckCircle, AlertCircle } from 'lucide-react'; +import { ArrowLeft, Save, Radio, Loader2, CheckCircle, AlertCircle, Eye, EyeOff, Upload } from 'lucide-react'; import Navbar from '@/components/Navbar'; import ApiKeyManager from '@/components/stations/ApiKeyManager'; @@ -40,6 +40,8 @@ interface Station { is_default: boolean; qrz_api_key?: string; club_callsign?: string; + lotw_username?: string; + lotw_password?: string; } interface DxccEntity { @@ -84,6 +86,8 @@ interface StationFormData { is_default: boolean; qrz_api_key: string; club_callsign: string; + lotw_username: string; + lotw_password: string; } export default function EditStationPage({ params }: { params: Promise<{ id: string }> }) { @@ -118,6 +122,8 @@ export default function EditStationPage({ params }: { params: Promise<{ id: stri is_default: false, qrz_api_key: '', club_callsign: '', + lotw_username: '', + lotw_password: '', }); const [loading, setLoading] = useState(true); @@ -125,6 +131,21 @@ export default function EditStationPage({ params }: { params: Promise<{ id: stri const [error, setError] = useState(''); const [validatingQrz, setValidatingQrz] = useState(false); const [qrzValidation, setQrzValidation] = useState<{ valid: boolean; message: string } | null>(null); + const [showLotwPassword, setShowLotwPassword] = useState(false); + // const [testingLotw, setTestingLotw] = useState(false); // Unused - for future LoTW validation + const [certFile, setCertFile] = useState(null); + const [certName, setCertName] = useState(''); + const [uploadingCert, setUploadingCert] = useState(false); + const [certificates, setCertificates] = useState>([]); + const [selectedCertId, setSelectedCertId] = useState(null); + const [settingActive, setSettingActive] = useState(false); + const [success, setSuccess] = useState(''); const router = useRouter(); useEffect(() => { @@ -137,6 +158,7 @@ export default function EditStationPage({ params }: { params: Promise<{ id: stri if (stationId) { fetchStation(); fetchDxccEntities(); + fetchCertificates(); } }, [stationId]); // eslint-disable-line react-hooks/exhaustive-deps @@ -186,6 +208,8 @@ export default function EditStationPage({ params }: { params: Promise<{ id: stri is_default: data.is_default ?? false, qrz_api_key: data.qrz_api_key || '', club_callsign: data.club_callsign || '', + lotw_username: data.lotw_username || '', + lotw_password: '', }); // Set initial DXCC and state selections @@ -229,6 +253,24 @@ export default function EditStationPage({ params }: { params: Promise<{ id: stri } }; + const fetchCertificates = async () => { + try { + const response = await fetch(`/api/lotw/certificate?station_id=${stationId}`); + if (response.ok) { + const data = await response.json(); + setCertificates(data.certificates || []); + + // Set the active certificate as selected + const activeCert = data.certificates?.find((cert: { is_active: boolean }) => cert.is_active); + if (activeCert) { + setSelectedCertId(activeCert.id); + } + } + } catch (error) { + console.error('Error fetching certificates:', error); + } + }; + const handleInputChange = (field: keyof StationFormData, value: string | boolean) => { setFormData(prev => ({ ...prev, [field]: value })); // Clear QRZ validation when API key changes @@ -237,6 +279,36 @@ export default function EditStationPage({ params }: { params: Promise<{ id: stri } }; + const handleSetActiveCertificate = async (certId: number) => { + if (certId === selectedCertId) return; // Already active + + try { + setSettingActive(true); + setError(''); + + // Deactivate all certificates for this station + await fetch(`/api/lotw/certificate/set-active`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + station_id: parseInt(stationId), + certificate_id: certId + }) + }); + + // Refresh certificates list + await fetchCertificates(); + setSuccess('Active certificate updated successfully!'); + setTimeout(() => setSuccess(''), 3000); + + } catch (error) { + console.error('Error setting active certificate:', error); + setError('Failed to set active certificate'); + } finally { + setSettingActive(false); + } + }; + const handleValidateQrzApiKey = async () => { if (!formData.qrz_api_key.trim()) { setQrzValidation({ valid: false, message: 'Please enter a QRZ API key first' }); @@ -322,7 +394,7 @@ export default function EditStationPage({ params }: { params: Promise<{ id: stri 'operator_name', 'qth_name', 'street_address', 'city', 'county', 'state_province', 'postal_code', 'country', 'grid_locator', 'rig_info', 'antenna_info', 'station_equipment', 'qrz_api_key', - 'club_callsign' + 'club_callsign', 'lotw_username', 'lotw_password' ]; optionalFields.forEach(field => { @@ -440,6 +512,12 @@ export default function EditStationPage({ params }: { params: Promise<{ id: stri
)} + {success && ( +
+ {success} +
+ )} +
{/* Same form structure as new station form, but with pre-filled data */} @@ -794,6 +872,187 @@ export default function EditStationPage({ params }: { params: Promise<{ id: stri + {/* LoTW Integration */} + + + LoTW Integration + + Logbook of The World credentials and certificate for this station + + + +
+
+ + handleInputChange('lotw_username', e.target.value)} + placeholder="Your LoTW username" + /> +
+
+ +
+ handleInputChange('lotw_password', e.target.value)} + placeholder={station?.lotw_password ? '••••••••' : 'Your LoTW password'} + /> + +
+

+ Leave blank to keep existing password +

+
+
+ + {/* Existing Certificates */} + {certificates.length > 0 && ( +
+

Active Certificate

+
+ + +

+ {certificates.length} certificate{certificates.length !== 1 ? 's' : ''} uploaded. + The selected certificate will be used for LoTW uploads. +

+
+
+ )} + + {/* Certificate Upload */} +
+

Upload New Certificate

+
+
+ + setCertName(e.target.value)} + placeholder="e.g., Main LoTW Cert, Backup Cert" + /> +

+ Give this certificate a name to identify it later +

+
+ +
+ + setCertFile(e.target.files?.[0] || null)} + /> +
+ + +
+
+ +
+

+ LoTW Integration +

+
    +
  • Enter your ARRL LoTW website username and password
  • +
  • Upload your .p12 certificate file to enable QSO uploads
  • +
  • Credentials are encrypted and stored securely per station
  • +
  • Click individual upload/download icons in the contact list
  • +
+
+
+
+ {/* API Key Management */} diff --git a/src/components/DXpeditionWidget.tsx b/src/components/DXpeditionWidget.tsx index d9aa20c..b2d0346 100644 --- a/src/components/DXpeditionWidget.tsx +++ b/src/components/DXpeditionWidget.tsx @@ -188,7 +188,15 @@ export default function DXpeditionWidget({ limit = 5 }: DXpeditionWidgetProps) { )}

- Data updated every 6 hours • Source: ng3k.com + Data updated every 6 hours • Source:{' '} + + NG3K +

diff --git a/src/components/LotwSyncIndicator.tsx b/src/components/LotwSyncIndicator.tsx index 2adc4fd..a376130 100644 --- a/src/components/LotwSyncIndicator.tsx +++ b/src/components/LotwSyncIndicator.tsx @@ -1,35 +1,124 @@ 'use client'; -import { Upload, Download, CheckCircle, Clock, AlertCircle } from 'lucide-react'; +import { Upload, Download, CheckCircle, Clock, AlertCircle, Loader2 } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; +import { useState } from 'react'; // Using title attribute for tooltips since tooltip component is not available interface LotwSyncIndicatorProps { // Upload status lotwQslSent?: string; // 'Y', 'N', 'R' (Yes, No, Requested) - - // Download/confirmation status + + // Download/confirmation status lotwQslRcvd?: string; // 'Y', 'N' (Yes, No) qslLotw?: boolean; // Confirmed via LoTW qslLotwDate?: Date | string; // Date of confirmation lotwMatchStatus?: 'confirmed' | 'partial' | 'mismatch' | null; - + + // Contact info for upload/download + contactId?: number; + stationId?: number; + + // Callback to refresh contact data after upload/download + onStatusChange?: () => void; + // Display options size?: 'sm' | 'md'; showLabels?: boolean; orientation?: 'horizontal' | 'vertical'; } -export default function LotwSyncIndicator({ - lotwQslSent, - lotwQslRcvd, - qslLotw, +export default function LotwSyncIndicator({ + lotwQslSent, + lotwQslRcvd, + qslLotw, qslLotwDate, lotwMatchStatus, + contactId, + stationId, + onStatusChange, size = 'sm', showLabels = false, orientation = 'horizontal' }: LotwSyncIndicatorProps) { + const [uploadLoading, setUploadLoading] = useState(false); + const [downloadLoading, setDownloadLoading] = useState(false); + + const handleUploadClick = async (e: React.MouseEvent) => { + e.stopPropagation(); // Prevent parent click handlers + + if (!contactId || !stationId || uploadLoading || lotwQslSent === 'Y') { + return; + } + + setUploadLoading(true); + try { + const response = await fetch('/api/lotw/upload-contact', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ contact_id: contactId }), + }); + + const data = await response.json(); + + if (response.ok && data.success) { + // Success - refresh the contact data + if (onStatusChange) { + onStatusChange(); + } + } else { + // Show error message + console.error('Upload failed:', data.error); + alert(`Upload failed: ${data.error || 'Unknown error'}`); + } + } catch (error) { + console.error('Upload error:', error); + alert('Upload failed: Network error'); + } finally { + setUploadLoading(false); + } + }; + + const handleDownloadClick = async (e: React.MouseEvent) => { + e.stopPropagation(); // Prevent parent click handlers + + if (!contactId || !stationId || downloadLoading || qslLotw === true || lotwQslRcvd === 'Y') { + return; + } + + setDownloadLoading(true); + try { + const response = await fetch('/api/lotw/download-contact', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ contact_id: contactId }), + }); + + const data = await response.json(); + + if (response.ok && data.success) { + // Success - refresh the contact data + if (onStatusChange) { + onStatusChange(); + } + } else { + // Show error message (but not as intrusive for "not found") + console.error('Download failed:', data.error); + if (!data.error?.includes('No matching confirmation')) { + alert(`Download failed: ${data.error || 'Unknown error'}`); + } + } + } catch (error) { + console.error('Download error:', error); + alert('Download failed: Network error'); + } finally { + setDownloadLoading(false); + } + }; // Determine upload status const getUploadStatus = () => { @@ -80,20 +169,40 @@ export default function LotwSyncIndicator({ const iconSize = size === 'sm' ? 'h-3 w-3' : 'h-4 w-4'; const containerClasses = orientation === 'vertical' ? 'flex flex-col space-y-1' : 'flex items-center space-x-2'; + // Determine if icons are clickable + const canUpload = contactId && stationId && lotwQslSent !== 'Y' && !uploadLoading; + const canDownload = contactId && stationId && qslLotw !== true && lotwQslRcvd !== 'Y' && !downloadLoading; + if (showLabels) { return (
-
- +
+ {uploadLoading ? ( + + ) : ( + + )} {showLabels && Up}
- -
- + +
+ {downloadLoading ? ( + + ) : ( + + )} {showLabels && Down}
@@ -103,15 +212,31 @@ export default function LotwSyncIndicator({ // Compact view - just icons return (
-
- +
+ {uploadLoading ? ( + + ) : ( + + )}
-
- +
+ {downloadLoading ? ( + + ) : ( + + )}
); diff --git a/src/lib/lotw.ts b/src/lib/lotw.ts index 91286ae..efa3fc1 100644 --- a/src/lib/lotw.ts +++ b/src/lib/lotw.ts @@ -5,41 +5,15 @@ import { spawn } from 'child_process'; import fs from 'fs/promises'; import path from 'path'; import { LotwConfirmation, ContactWithLoTW } from '@/types/lotw'; +import { encrypt, decrypt } from './crypto'; -// Encryption utilities for secure credential storage -const ENCRYPTION_KEY = process.env.ENCRYPTION_SECRET || 'default-key-change-me'; - +// Use centralized encryption utilities export function encryptString(text: string): string { - if (!text) return ''; - - const algorithm = 'aes-256-gcm'; - const cipher = crypto.createCipher(algorithm, ENCRYPTION_KEY); - - let encrypted = cipher.update(text, 'utf8', 'hex'); - encrypted += cipher.final('hex'); - - return 'encrypted:' + encrypted; + return encrypt(text); } export function decryptString(encryptedText: string): string { - if (!encryptedText) return ''; - - try { - const algorithm = 'aes-256-gcm'; - const parts = encryptedText.split(':'); - if (parts.length !== 2) return ''; - - const encrypted = parts[1]; - const decipher = crypto.createDecipher(algorithm, ENCRYPTION_KEY); - - let decrypted = decipher.update(encrypted, 'hex', 'utf8'); - decrypted += decipher.final('utf8'); - - return decrypted; - } catch (error) { - console.error('Decryption error:', error); - return ''; - } + return decrypt(encryptedText); } // ADIF generation for LoTW upload diff --git a/src/models/Station.ts b/src/models/Station.ts index 4d95fcf..accf051 100644 --- a/src/models/Station.ts +++ b/src/models/Station.ts @@ -29,6 +29,10 @@ export interface StationData { qrz_password?: string; qrz_api_key?: string; club_callsign?: string; + lotw_username?: string; + lotw_password?: string; + lotw_p12_cert?: Buffer; + lotw_cert_created_at?: Date; created_at: string; updated_at: string; } @@ -76,6 +80,7 @@ export interface CreateStationData { is_default?: boolean; qrz_api_key?: string; lotw_username?: string; + lotw_password?: string; club_callsign?: string; } From 97131cca3aba252764a092b8f44cc4ca95e39017 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Sun, 14 Dec 2025 11:13:26 -0600 Subject: [PATCH 2/2] fix versions --- package-lock.json | 142 +++++++++++++++++++++++----------------------- package.json | 2 +- 2 files changed, 72 insertions(+), 72 deletions(-) diff --git a/package-lock.json b/package-lock.json index e3f21af..2f6c839 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,7 @@ "jsonwebtoken": "^9.0.2", "leaflet": "^1.9.4", "lucide-react": "^0.539.0", - "next": "15.4.7", + "next": "^15.5.9", "next-auth": "^4.24.11", "node-html-parser": "^7.0.1", "pg": "^8.11.3", @@ -455,13 +455,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.3.tgz", - "integrity": "sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", + "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.15.1", + "@eslint/core": "^0.15.2", "levn": "^0.4.1" }, "engines": { @@ -469,9 +469,9 @@ } }, "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { - "version": "0.15.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", - "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", + "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1069,9 +1069,9 @@ } }, "node_modules/@next/env": { - "version": "15.4.7", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.4.7.tgz", - "integrity": "sha512-PrBIpO8oljZGTOe9HH0miix1w5MUiGJ/q83Jge03mHEE0E3pyqzAy2+l5G6aJDbXoobmxPJTVhbCuwlLtjSHwg==", + "version": "15.5.9", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.9.tgz", + "integrity": "sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -1085,9 +1085,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "15.4.7", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.4.7.tgz", - "integrity": "sha512-2Dkb+VUTp9kHHkSqtws4fDl2Oxms29HcZBwFIda1X7Ztudzy7M6XF9HDS2dq85TmdN47VpuhjE+i6wgnIboVzQ==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.7.tgz", + "integrity": "sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==", "cpu": [ "arm64" ], @@ -1101,9 +1101,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "15.4.7", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.4.7.tgz", - "integrity": "sha512-qaMnEozKdWezlmh1OGDVFueFv2z9lWTcLvt7e39QA3YOvZHNpN2rLs/IQLwZaUiw2jSvxW07LxMCWtOqsWFNQg==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.7.tgz", + "integrity": "sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==", "cpu": [ "x64" ], @@ -1117,9 +1117,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.4.7", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.4.7.tgz", - "integrity": "sha512-ny7lODPE7a15Qms8LZiN9wjNWIeI+iAZOFDOnv2pcHStncUr7cr9lD5XF81mdhrBXLUP9yT9RzlmSWKIazWoDw==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.7.tgz", + "integrity": "sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==", "cpu": [ "arm64" ], @@ -1133,9 +1133,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.4.7", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.4.7.tgz", - "integrity": "sha512-4SaCjlFR/2hGJqZLLWycccy1t+wBrE/vyJWnYaZJhUVHccpGLG5q0C+Xkw4iRzUIkE+/dr90MJRUym3s1+vO8A==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.7.tgz", + "integrity": "sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==", "cpu": [ "arm64" ], @@ -1149,9 +1149,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.4.7", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.4.7.tgz", - "integrity": "sha512-2uNXjxvONyRidg00VwvlTYDwC9EgCGNzPAPYbttIATZRxmOZ3hllk/YYESzHZb65eyZfBR5g9xgCZjRAl9YYGg==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.7.tgz", + "integrity": "sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==", "cpu": [ "x64" ], @@ -1165,9 +1165,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "15.4.7", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.4.7.tgz", - "integrity": "sha512-ceNbPjsFgLscYNGKSu4I6LYaadq2B8tcK116nVuInpHHdAWLWSwVK6CHNvCi0wVS9+TTArIFKJGsEyVD1H+4Kg==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.7.tgz", + "integrity": "sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==", "cpu": [ "x64" ], @@ -1181,9 +1181,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.4.7", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.4.7.tgz", - "integrity": "sha512-pZyxmY1iHlZJ04LUL7Css8bNvsYAMYOY9JRwFA3HZgpaNKsJSowD09Vg2R9734GxAcLJc2KDQHSCR91uD6/AAw==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.7.tgz", + "integrity": "sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==", "cpu": [ "arm64" ], @@ -1197,9 +1197,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.4.7", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.4.7.tgz", - "integrity": "sha512-HjuwPJ7BeRzgl3KrjKqD2iDng0eQIpIReyhpF5r4yeAHFwWRuAhfW92rWv/r3qeQHEwHsLRzFDvMqRjyM5DI6A==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.7.tgz", + "integrity": "sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==", "cpu": [ "x64" ], @@ -1270,13 +1270,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.54.2", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.54.2.tgz", - "integrity": "sha512-A+znathYxPf+72riFd1r1ovOLqsIIB0jKIoPjyK2kqEIe30/6jF6BC7QNluHuwUmsD2tv1XZVugN8GqfTMOxsA==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", + "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", "devOptional": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.54.2" + "playwright": "1.57.0" }, "bin": { "playwright": "cli.js" @@ -6329,9 +6329,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -6425,12 +6425,12 @@ } }, "node_modules/jws": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", - "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz", + "integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==", "license": "MIT", "dependencies": { - "jwa": "^1.4.1", + "jwa": "^1.4.2", "safe-buffer": "^5.0.1" } }, @@ -6982,12 +6982,12 @@ "license": "MIT" }, "node_modules/next": { - "version": "15.4.7", - "resolved": "https://registry.npmjs.org/next/-/next-15.4.7.tgz", - "integrity": "sha512-OcqRugwF7n7mC8OSYjvsZhhG1AYSvulor1EIUsIkbbEbf1qoE5EbH36Swj8WhF4cHqmDgkiam3z1c1W0J1Wifg==", + "version": "15.5.9", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.9.tgz", + "integrity": "sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==", "license": "MIT", "dependencies": { - "@next/env": "15.4.7", + "@next/env": "15.5.9", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", @@ -7000,14 +7000,14 @@ "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "15.4.7", - "@next/swc-darwin-x64": "15.4.7", - "@next/swc-linux-arm64-gnu": "15.4.7", - "@next/swc-linux-arm64-musl": "15.4.7", - "@next/swc-linux-x64-gnu": "15.4.7", - "@next/swc-linux-x64-musl": "15.4.7", - "@next/swc-win32-arm64-msvc": "15.4.7", - "@next/swc-win32-x64-msvc": "15.4.7", + "@next/swc-darwin-arm64": "15.5.7", + "@next/swc-darwin-x64": "15.5.7", + "@next/swc-linux-arm64-gnu": "15.5.7", + "@next/swc-linux-arm64-musl": "15.5.7", + "@next/swc-linux-x64-gnu": "15.5.7", + "@next/swc-linux-x64-musl": "15.5.7", + "@next/swc-win32-arm64-msvc": "15.5.7", + "@next/swc-win32-x64-msvc": "15.5.7", "sharp": "^0.34.3" }, "peerDependencies": { @@ -7034,9 +7034,9 @@ } }, "node_modules/next-auth": { - "version": "4.24.11", - "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.11.tgz", - "integrity": "sha512-pCFXzIDQX7xmHFs4KVH4luCjaCbuPRtZ9oBUjUhOk84mZ9WVPf94n87TxYI4rSRf9HmfHEF8Yep3JrYDVOo3Cw==", + "version": "4.24.13", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.13.tgz", + "integrity": "sha512-sgObCfcfL7BzIK76SS5TnQtc3yo2Oifp/yIpfv6fMfeBOiBJkDWF3A2y9+yqnmJ4JKc2C+nMjSjmgDeTwgN1rQ==", "license": "ISC", "dependencies": { "@babel/runtime": "^7.20.13", @@ -7050,9 +7050,9 @@ "uuid": "^8.3.2" }, "peerDependencies": { - "@auth/core": "0.34.2", - "next": "^12.2.5 || ^13 || ^14 || ^15", - "nodemailer": "^6.6.5", + "@auth/core": "0.34.3", + "next": "^12.2.5 || ^13 || ^14 || ^15 || ^16", + "nodemailer": "^7.0.7", "react": "^17.0.2 || ^18 || ^19", "react-dom": "^17.0.2 || ^18 || ^19" }, @@ -7494,13 +7494,13 @@ } }, "node_modules/playwright": { - "version": "1.54.2", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.54.2.tgz", - "integrity": "sha512-Hu/BMoA1NAdRUuulyvQC0pEqZ4vQbGfn8f7wPXcnqQmM+zct9UliKxsIkLNmz/ku7LElUNqmaiv1TG/aL5ACsw==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", + "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", "devOptional": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.54.2" + "playwright-core": "1.57.0" }, "bin": { "playwright": "cli.js" @@ -7513,9 +7513,9 @@ } }, "node_modules/playwright-core": { - "version": "1.54.2", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.54.2.tgz", - "integrity": "sha512-n5r4HFbMmWsB4twG7tJLDN9gmBUeSPcsBZiWSE4DnYz9mJMAFqr2ID7+eGC9kpEnxExJ1epttwR59LEWCk8mtA==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", + "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", "devOptional": true, "license": "Apache-2.0", "bin": { diff --git a/package.json b/package.json index 24238fb..396c7f1 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "jsonwebtoken": "^9.0.2", "leaflet": "^1.9.4", "lucide-react": "^0.539.0", - "next": "15.4.7", + "next": "^15.5.9", "next-auth": "^4.24.11", "node-html-parser": "^7.0.1", "pg": "^8.11.3",