feat: implement Radicale CardDAV sync utility and update contact handling for asynchronous sync
This commit is contained in:
157
RADICALE_SYNC_README.md
Normal file
157
RADICALE_SYNC_README.md
Normal file
@@ -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
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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);
|
||||
|
||||
252
src/lib/radicale-sync.js
Normal file
252
src/lib/radicale-sync.js
Normal file
@@ -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);
|
||||
});
|
||||
}
|
||||
45
test-radicale-config.mjs
Normal file
45
test-radicale-config.mjs
Normal file
@@ -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!');
|
||||
Reference in New Issue
Block a user