Compare commits

...

4 Commits

6 changed files with 770 additions and 0 deletions

157
RADICALE_SYNC_README.md Normal file
View 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

View File

@@ -0,0 +1,272 @@
#!/usr/bin/env node
/**
* One-time script to export all contacts as VCARDs and upload to Radicale
* Usage: node export-contacts-to-radicale.mjs
*/
import db from './src/lib/db.js';
import readline from 'readline';
import { createInterface } from 'readline';
// VCARD generation helper
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, '');
}
// Prompt for input
function prompt(question) {
const rl = createInterface({
input: process.stdin,
output: process.stdout
});
return new Promise((resolve) => {
rl.question(question, (answer) => {
rl.close();
resolve(answer);
});
});
}
// Upload VCARD to Radicale via CardDAV
async function uploadToRadicale(vcard, contactId, radicaleUrl, username, password, forceUpdate = false) {
const auth = Buffer.from(`${username}:${password}`).toString('base64');
// Ensure URL ends with /
const baseUrl = radicaleUrl.endsWith('/') ? radicaleUrl : radicaleUrl + '/';
// Construct the URL for this specific contact
// Format: {base_url}{username}/{addressbook_name}/{contact_id}.vcf
const vcardUrl = `${baseUrl}${username}/b576a569-4af7-5812-7ddd-3c7cb8caf692/contact-${contactId}.vcf`;
try {
const headers = {
'Authorization': `Basic ${auth}`,
'Content-Type': 'text/vcard; charset=utf-8'
};
// If not forcing update, only create if doesn't exist
if (!forceUpdate) {
headers['If-None-Match'] = '*';
}
const response = await fetch(vcardUrl, {
method: 'PUT',
headers: headers,
body: vcard
});
// Handle conflict - try again with force update
if (response.status === 412 || response.status === 409) {
// Conflict - contact already exists, update it instead
return await uploadToRadicale(vcard, contactId, radicaleUrl, username, password, true);
}
if (response.ok || response.status === 201 || response.status === 204) {
return { success: true, status: response.status, updated: forceUpdate };
} else {
const text = await response.text();
return { success: false, status: response.status, error: text };
}
} catch (error) {
return { success: false, error: error.message };
}
}
// Main execution
async function main() {
console.log('🚀 Export Contacts to Radicale (CardDAV)\n');
console.log('This script will export all active contacts as VCARDs and upload them to your Radicale server.\n');
// Get Radicale connection details
const radicaleUrl = await prompt('Radicale URL (e.g., http://localhost:5232): ');
const username = await prompt('Username: ');
const password = await prompt('Password: ');
if (!radicaleUrl || !username || !password) {
console.error('❌ All fields are required!');
process.exit(1);
}
console.log('\n📊 Fetching contacts from database...\n');
// Get all active contacts
const contacts = db.prepare(`
SELECT * FROM contacts
WHERE is_active = 1
ORDER BY name ASC
`).all();
if (contacts.length === 0) {
console.log(' No active contacts found.');
process.exit(0);
}
console.log(`Found ${contacts.length} active contacts\n`);
console.log('📤 Uploading to Radicale...\n');
let uploaded = 0;
let updated = 0;
let failed = 0;
const errors = [];
for (const contact of contacts) {
const vcard = generateVCard(contact);
const result = await uploadToRadicale(vcard, contact.contact_id, radicaleUrl, username, password);
if (result.success) {
if (result.updated) {
updated++;
console.log(`🔄 ${contact.name} (${contact.contact_id}) - updated`);
} else {
uploaded++;
console.log(`${contact.name} (${contact.contact_id}) - created`);
}
} else {
failed++;
const errorMsg = `${contact.name} (${contact.contact_id}): ${result.error || `HTTP ${result.status}`}`;
console.log(errorMsg);
errors.push(errorMsg);
}
// Small delay to avoid overwhelming the server
await new Promise(resolve => setTimeout(resolve, 100));
}
console.log('\n' + '='.repeat(60));
console.log('📊 Upload Summary:');
console.log(` ✅ Created: ${uploaded}`);
console.log(` 🔄 Updated: ${updated}`);
console.log(` ❌ Failed: ${failed}`);
console.log(` 📋 Total: ${contacts.length}`);
if (errors.length > 0 && errors.length <= 10) {
console.log('\n❌ Failed uploads:');
errors.forEach(err => console.log(` ${err}`));
}
if (uploaded > 0 || updated > 0) {
console.log('\n✨ Success! Your contacts have been exported to Radicale.');
console.log(` Access them at: ${radicaleUrl}`);
}
console.log('');
}
main().catch(error => {
console.error('❌ Error:', error);
process.exit(1);
});

View File

@@ -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" });

View File

@@ -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);

283
src/lib/radicale-sync.js Normal file
View File

@@ -0,0 +1,283 @@
/**
* 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 (internal function with retry logic)
async function syncContactToRadicaleInternal(contact, forceUpdate = false) {
const config = getRadicaleConfig();
// Skip if not configured
if (!config) {
return { success: false, reason: 'not_configured' };
}
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}/b576a569-4af7-5812-7ddd-3c7cb8caf692/contact-${contact.contact_id}.vcf`;
const headers = {
'Authorization': `Basic ${auth}`,
'Content-Type': 'text/vcard; charset=utf-8'
};
// If not forcing update, only create if doesn't exist
if (!forceUpdate) {
headers['If-None-Match'] = '*';
}
const response = await fetch(vcardUrl, {
method: 'PUT',
headers: headers,
body: vcard
});
// Handle conflict - try again with force update
if (response.status === 412 || response.status === 409) {
// Conflict - contact already exists, update it instead
return await syncContactToRadicaleInternal(contact, true);
}
if (response.ok || response.status === 201 || response.status === 204) {
return { success: true, status: response.status, updated: forceUpdate };
} else {
const text = await response.text();
return { success: false, status: response.status, error: text };
}
} catch (error) {
return { success: false, error: error.message };
}
}
// 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' };
}
const result = await syncContactToRadicaleInternal(contact);
if (result.success) {
const action = result.updated ? 'updated' : 'created';
console.log(`✅ Synced contact ${contact.contact_id} to Radicale (${action})`);
} else if (result.reason !== 'not_configured') {
console.error(`❌ Failed to sync contact ${contact.contact_id} to Radicale: ${result.status} - ${result.error || result.reason}`);
}
return result;
}
// 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}/b576a569-4af7-5812-7ddd-3c7cb8caf692/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
View 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!');