From 3292435e6839fa56b6bd05afe5a8614eb6341311 Mon Sep 17 00:00:00 2001 From: RKWojs Date: Thu, 4 Dec 2025 11:37:13 +0100 Subject: [PATCH] feat: implement Radicale CardDAV sync utility and update contact handling for asynchronous sync --- RADICALE_SYNC_README.md | 157 ++++++++++++++++++ export-contacts-to-radicale.mjs | 2 +- src/app/api/contacts/[id]/route.js | 8 + src/app/api/contacts/route.js | 5 + src/lib/radicale-sync.js | 252 +++++++++++++++++++++++++++++ test-radicale-config.mjs | 45 ++++++ 6 files changed, 468 insertions(+), 1 deletion(-) create mode 100644 RADICALE_SYNC_README.md create mode 100644 src/lib/radicale-sync.js create mode 100644 test-radicale-config.mjs diff --git a/RADICALE_SYNC_README.md b/RADICALE_SYNC_README.md new file mode 100644 index 0000000..370e75a --- /dev/null +++ b/RADICALE_SYNC_README.md @@ -0,0 +1,157 @@ +# Radicale CardDAV Sync Integration + +This application now automatically syncs contacts to a Radicale CardDAV server whenever contacts are created, updated, or deleted. + +## Features + +- ✅ **Automatic Sync** - Contacts are automatically synced when created or updated +- ✅ **Automatic Deletion** - Contacts are removed from Radicale when soft/hard deleted +- ✅ **Non-Blocking** - Sync happens asynchronously without slowing down the API +- ✅ **Optional** - Sync is disabled by default, enable by configuring environment variables +- ✅ **VCARD 3.0** - Generates standard VCARD format with full contact details + +## Setup + +### 1. Configure Environment Variables + +Add these to your `.env.local` or production environment: + +```bash +RADICALE_URL=http://localhost:5232 +RADICALE_USERNAME=your_username +RADICALE_PASSWORD=your_password +``` + +**Note:** If these variables are not set, sync will be disabled and the app will work normally. + +### 2. Radicale Server Setup + +Make sure your Radicale server: +- Is accessible from your application server +- Has a user created with the credentials you configured +- Has a contacts collection at: `{username}/contacts/` + +### 3. One-Time Initial Sync + +To sync all existing contacts to Radicale: + +```bash +node export-contacts-to-radicale.mjs +``` + +This script will: +- Prompt for Radicale URL, username, and password +- Export all active contacts as VCARDs +- Upload them to your Radicale server + +## How It Works + +### When Creating a Contact + +```javascript +// POST /api/contacts +const contact = createContact(data); + +// Sync to Radicale asynchronously (non-blocking) +syncContactAsync(contact); + +return NextResponse.json(contact); +``` + +### When Updating a Contact + +```javascript +// PUT /api/contacts/[id] +const contact = updateContact(contactId, data); + +// Sync updated contact to Radicale +syncContactAsync(contact); + +return NextResponse.json(contact); +``` + +### When Deleting a Contact + +```javascript +// DELETE /api/contacts/[id] +deleteContact(contactId); + +// Delete from Radicale asynchronously +deleteContactAsync(contactId); + +return NextResponse.json({ message: "Contact deleted" }); +``` + +## VCARD Format + +Each contact is exported with the following fields: + +- **UID**: `contact-{id}@panel-app` +- **FN/N**: Full name and structured name +- **ORG**: Company +- **TITLE**: Position/Title +- **TEL**: Phone numbers (multiple supported - first as WORK, others as CELL) +- **EMAIL**: Email address +- **NOTE**: Contact type + notes +- **CATEGORIES**: Based on contact type (Projekty, Wykonawcy, Urzędy, etc.) +- **REV**: Last modified timestamp + +## VCARD Storage Path + +VCARDs are stored at: +``` +{RADICALE_URL}/{RADICALE_USERNAME}/contacts/contact-{id}.vcf +``` + +Example: +``` +http://localhost:5232/admin/contacts/contact-123.vcf +``` + +## Troubleshooting + +### Sync Not Working + +1. Check environment variables are set correctly +2. Verify Radicale server is accessible +3. Check application logs for sync errors +4. Test manually with the export script + +### Check Sync Status + +Sync operations are logged to console: +``` +✅ Synced contact 123 to Radicale +❌ Failed to sync contact 456 to Radicale: 401 - Unauthorized +``` + +### Disable Sync + +Simply remove or comment out the Radicale environment variables: +```bash +# RADICALE_URL= +# RADICALE_USERNAME= +# RADICALE_PASSWORD= +``` + +## Files + +- **`src/lib/radicale-sync.js`** - Main sync utility with VCARD generation +- **`src/app/api/contacts/route.js`** - Integrated sync on create +- **`src/app/api/contacts/[id]/route.js`** - Integrated sync on update/delete +- **`export-contacts-to-radicale.mjs`** - One-time bulk export script + +## Security Notes + +- ⚠️ Store credentials securely in environment variables +- ⚠️ Use HTTPS for production Radicale servers +- ⚠️ Consider using environment-specific credentials +- ⚠️ Sync happens in background - errors won't block API responses + +## Future Enhancements + +- Bi-directional sync (import changes from Radicale) +- Batch sync operations +- Sync queue with retry logic +- Webhook notifications for sync status +- Admin UI to trigger manual sync diff --git a/export-contacts-to-radicale.mjs b/export-contacts-to-radicale.mjs index 2d16346..e8dafed 100644 --- a/export-contacts-to-radicale.mjs +++ b/export-contacts-to-radicale.mjs @@ -149,7 +149,7 @@ async function uploadToRadicale(vcard, contactId, radicaleUrl, username, passwor // Construct the URL for this specific contact // Format: {base_url}{username}/{addressbook_name}/{contact_id}.vcf - const vcardUrl = `${baseUrl}${username}/a5ca5057-f295-b24a-f828-209786f581e3/contact-${contactId}.vcf`; + const vcardUrl = `${baseUrl}${username}/b576a569-4af7-5812-7ddd-3c7cb8caf692/contact-${contactId}.vcf`; try { const headers = { diff --git a/src/app/api/contacts/[id]/route.js b/src/app/api/contacts/[id]/route.js index 62d237f..ffdad8f 100644 --- a/src/app/api/contacts/[id]/route.js +++ b/src/app/api/contacts/[id]/route.js @@ -6,6 +6,7 @@ import { hardDeleteContact, } from "@/lib/queries/contacts"; import { withAuth } from "@/lib/middleware/auth"; +import { syncContactAsync, deleteContactAsync } from "@/lib/radicale-sync"; // GET: Get contact by ID async function getContactHandler(req, { params }) { @@ -62,6 +63,9 @@ async function updateContactHandler(req, { params }) { ); } + // Sync to Radicale asynchronously (non-blocking) + syncContactAsync(contact); + return NextResponse.json(contact); } catch (error) { console.error("Error updating contact:", error); @@ -82,9 +86,13 @@ async function deleteContactHandler(req, { params }) { if (hard) { // Hard delete - permanently remove hardDeleteContact(contactId); + // Delete from Radicale asynchronously + deleteContactAsync(contactId); } else { // Soft delete - set is_active to 0 deleteContact(contactId); + // Delete from Radicale asynchronously + deleteContactAsync(contactId); } return NextResponse.json({ message: "Contact deleted successfully" }); diff --git a/src/app/api/contacts/route.js b/src/app/api/contacts/route.js index f1a9581..a20bd30 100644 --- a/src/app/api/contacts/route.js +++ b/src/app/api/contacts/route.js @@ -5,6 +5,7 @@ import { getContactStats, } from "@/lib/queries/contacts"; import { withAuth } from "@/lib/middleware/auth"; +import { syncContactAsync } from "@/lib/radicale-sync"; // GET: Get all contacts with optional filters async function getContactsHandler(req) { @@ -58,6 +59,10 @@ async function createContactHandler(req) { } const contact = createContact(data); + + // Sync to Radicale asynchronously (non-blocking) + syncContactAsync(contact); + return NextResponse.json(contact, { status: 201 }); } catch (error) { console.error("Error creating contact:", error); diff --git a/src/lib/radicale-sync.js b/src/lib/radicale-sync.js new file mode 100644 index 0000000..b280a76 --- /dev/null +++ b/src/lib/radicale-sync.js @@ -0,0 +1,252 @@ +/** + * Radicale CardDAV Sync Utility + * Automatically syncs contacts to Radicale server + */ + +// VCARD generation helper +export function generateVCard(contact) { + const lines = ['BEGIN:VCARD', 'VERSION:3.0']; + + // UID - unique identifier + lines.push(`UID:contact-${contact.contact_id}@panel-app`); + + // Name (FN = Formatted Name, N = Structured Name) + if (contact.name) { + lines.push(`FN:${escapeVCardValue(contact.name)}`); + + // Try to split name into components (Last;First;Middle;Prefix;Suffix) + const nameParts = contact.name.trim().split(/\s+/); + if (nameParts.length === 1) { + lines.push(`N:${escapeVCardValue(nameParts[0])};;;;`); + } else if (nameParts.length === 2) { + lines.push(`N:${escapeVCardValue(nameParts[1])};${escapeVCardValue(nameParts[0])};;;`); + } else { + // More than 2 parts - first is first name, rest is last name + const firstName = nameParts[0]; + const lastName = nameParts.slice(1).join(' '); + lines.push(`N:${escapeVCardValue(lastName)};${escapeVCardValue(firstName)};;;`); + } + } + + // Organization + if (contact.company) { + lines.push(`ORG:${escapeVCardValue(contact.company)}`); + } + + // Title/Position + if (contact.position) { + lines.push(`TITLE:${escapeVCardValue(contact.position)}`); + } + + // Phone numbers - handle multiple phones + if (contact.phone) { + let phones = []; + try { + // Try to parse as JSON array + const parsed = JSON.parse(contact.phone); + phones = Array.isArray(parsed) ? parsed : [contact.phone]; + } catch { + // Fall back to comma-separated or single value + phones = contact.phone.includes(',') + ? contact.phone.split(',').map(p => p.trim()).filter(p => p) + : [contact.phone]; + } + + phones.forEach((phone, index) => { + if (phone) { + // First phone is WORK, others are CELL + const type = index === 0 ? 'WORK' : 'CELL'; + lines.push(`TEL;TYPE=${type},VOICE:${escapeVCardValue(phone)}`); + } + }); + } + + // Email + if (contact.email) { + lines.push(`EMAIL;TYPE=INTERNET,WORK:${escapeVCardValue(contact.email)}`); + } + + // Notes - combine contact type, position context, and notes + const noteParts = []; + if (contact.contact_type) { + const typeLabels = { + project: 'Kontakt projektowy', + contractor: 'Wykonawca', + office: 'Urząd', + supplier: 'Dostawca', + other: 'Inny' + }; + noteParts.push(`Typ: ${typeLabels[contact.contact_type] || contact.contact_type}`); + } + if (contact.notes) { + noteParts.push(contact.notes); + } + if (noteParts.length > 0) { + lines.push(`NOTE:${escapeVCardValue(noteParts.join('\\n'))}`); + } + + // Categories based on contact type + if (contact.contact_type) { + const categories = { + project: 'Projekty', + contractor: 'Wykonawcy', + office: 'Urzędy', + supplier: 'Dostawcy', + other: 'Inne' + }; + lines.push(`CATEGORIES:${categories[contact.contact_type] || 'Inne'}`); + } + + // Timestamps + if (contact.created_at) { + const created = new Date(contact.created_at).toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'; + lines.push(`REV:${created}`); + } + + lines.push('END:VCARD'); + + return lines.join('\r\n') + '\r\n'; +} + +// Escape special characters in VCARD values +function escapeVCardValue(value) { + if (!value) return ''; + return value + .replace(/\\/g, '\\\\') + .replace(/;/g, '\\;') + .replace(/,/g, '\\,') + .replace(/\n/g, '\\n') + .replace(/\r/g, ''); +} + +// Get Radicale configuration from environment +export function getRadicaleConfig() { + const url = process.env.RADICALE_URL; + const username = process.env.RADICALE_USERNAME; + const password = process.env.RADICALE_PASSWORD; + + // Return null if not configured + if (!url || !username || !password) { + return null; + } + + return { url, username, password }; +} + +// Check if Radicale sync is enabled +export function isRadicaleEnabled() { + return getRadicaleConfig() !== null; +} + +// Sync contact to Radicale +export async function syncContactToRadicale(contact) { + const config = getRadicaleConfig(); + + // Skip if not configured + if (!config) { + console.log('Radicale sync skipped - not configured'); + return { success: false, reason: 'not_configured' }; + } + + // Skip if contact is inactive + if (contact.is_active === 0) { + console.log(`Skipping inactive contact ${contact.contact_id}`); + return { success: true, reason: 'inactive_skipped' }; + } + + try { + const vcard = generateVCard(contact); + const auth = Buffer.from(`${config.username}:${config.password}`).toString('base64'); + + // Ensure URL ends with / + const baseUrl = config.url.endsWith('/') ? config.url : config.url + '/'; + + // Construct the URL for this specific contact + const vcardUrl = `${baseUrl}${config.username}/contacts/contact-${contact.contact_id}.vcf`; + + const response = await fetch(vcardUrl, { + method: 'PUT', + headers: { + 'Authorization': `Basic ${auth}`, + 'Content-Type': 'text/vcard; charset=utf-8' + }, + body: vcard + }); + + if (response.ok || response.status === 201 || response.status === 204) { + console.log(`✅ Synced contact ${contact.contact_id} to Radicale`); + return { success: true, status: response.status }; + } else { + const text = await response.text(); + console.error(`❌ Failed to sync contact ${contact.contact_id} to Radicale: ${response.status} - ${text}`); + return { success: false, status: response.status, error: text }; + } + } catch (error) { + console.error(`❌ Error syncing contact ${contact.contact_id} to Radicale:`, error); + return { success: false, error: error.message }; + } +} + +// Delete contact from Radicale +export async function deleteContactFromRadicale(contactId) { + const config = getRadicaleConfig(); + + // Skip if not configured + if (!config) { + console.log('Radicale delete skipped - not configured'); + return { success: false, reason: 'not_configured' }; + } + + try { + const auth = Buffer.from(`${config.username}:${config.password}`).toString('base64'); + + // Ensure URL ends with / + const baseUrl = config.url.endsWith('/') ? config.url : config.url + '/'; + + // Construct the URL for this specific contact + const vcardUrl = `${baseUrl}${config.username}/contacts/contact-${contactId}.vcf`; + + const response = await fetch(vcardUrl, { + method: 'DELETE', + headers: { + 'Authorization': `Basic ${auth}` + } + }); + + if (response.ok || response.status === 204 || response.status === 404) { + console.log(`✅ Deleted contact ${contactId} from Radicale`); + return { success: true, status: response.status }; + } else { + const text = await response.text(); + console.error(`❌ Failed to delete contact ${contactId} from Radicale: ${response.status} - ${text}`); + return { success: false, status: response.status, error: text }; + } + } catch (error) { + console.error(`❌ Error deleting contact ${contactId} from Radicale:`, error); + return { success: false, error: error.message }; + } +} + +// Sync contact asynchronously (fire and forget) +export function syncContactAsync(contact) { + if (!isRadicaleEnabled()) { + return; + } + + // Run sync in background without blocking + syncContactToRadicale(contact).catch(error => { + console.error('Background sync failed:', error); + }); +} + +// Delete contact asynchronously (fire and forget) +export function deleteContactAsync(contactId) { + if (!isRadicaleEnabled()) { + return; + } + + // Run delete in background without blocking + deleteContactFromRadicale(contactId).catch(error => { + console.error('Background delete failed:', error); + }); +} diff --git a/test-radicale-config.mjs b/test-radicale-config.mjs new file mode 100644 index 0000000..df85ac3 --- /dev/null +++ b/test-radicale-config.mjs @@ -0,0 +1,45 @@ +#!/usr/bin/env node + +/** + * Test Radicale sync configuration + */ + +import { getRadicaleConfig, isRadicaleEnabled, generateVCard } from './src/lib/radicale-sync.js'; + +console.log('🧪 Testing Radicale Sync Configuration\n'); + +// Check if enabled +if (isRadicaleEnabled()) { + const config = getRadicaleConfig(); + console.log('✅ Radicale sync is ENABLED'); + console.log(` URL: ${config.url}`); + console.log(` Username: ${config.username}`); + console.log(` Password: ${config.password ? '***' + config.password.slice(-3) : 'not set'}`); +} else { + console.log('❌ Radicale sync is DISABLED'); + console.log(' Set RADICALE_URL, RADICALE_USERNAME, and RADICALE_PASSWORD in .env.local to enable'); +} + +console.log('\n📝 Testing VCARD Generation\n'); + +// Test VCARD generation +const testContact = { + contact_id: 999, + name: 'Jan Kowalski', + phone: '["123-456-789", "987-654-321"]', + email: 'jan.kowalski@example.com', + company: 'Test Company', + position: 'Manager', + contact_type: 'project', + notes: 'Test contact for VCARD generation', + is_active: 1, + created_at: new Date().toISOString() +}; + +const vcard = generateVCard(testContact); +console.log('Generated VCARD:'); +console.log('─'.repeat(60)); +console.log(vcard); +console.log('─'.repeat(60)); + +console.log('\n✅ Test complete!');