Compare commits
8 Commits
5b794a59bc
...
ui-fix
| Author | SHA1 | Date | |
|---|---|---|---|
| 628ace4ad5 | |||
| ad6338ecae | |||
| 1bc9dc2dd5 | |||
| 3292435e68 | |||
| 22503e1ce0 | |||
| 05ec244107 | |||
| 77f4c80a79 | |||
| 5abacdc8e1 |
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
|
||||||
272
export-contacts-to-radicale.mjs
Normal file
272
export-contacts-to-radicale.mjs
Normal 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);
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
hardDeleteContact,
|
hardDeleteContact,
|
||||||
} from "@/lib/queries/contacts";
|
} from "@/lib/queries/contacts";
|
||||||
import { withAuth } from "@/lib/middleware/auth";
|
import { withAuth } from "@/lib/middleware/auth";
|
||||||
|
import { syncContactAsync, deleteContactAsync } from "@/lib/radicale-sync";
|
||||||
|
|
||||||
// GET: Get contact by ID
|
// GET: Get contact by ID
|
||||||
async function getContactHandler(req, { params }) {
|
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);
|
return NextResponse.json(contact);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error updating contact:", error);
|
console.error("Error updating contact:", error);
|
||||||
@@ -82,9 +86,13 @@ async function deleteContactHandler(req, { params }) {
|
|||||||
if (hard) {
|
if (hard) {
|
||||||
// Hard delete - permanently remove
|
// Hard delete - permanently remove
|
||||||
hardDeleteContact(contactId);
|
hardDeleteContact(contactId);
|
||||||
|
// Delete from Radicale asynchronously
|
||||||
|
deleteContactAsync(contactId);
|
||||||
} else {
|
} else {
|
||||||
// Soft delete - set is_active to 0
|
// Soft delete - set is_active to 0
|
||||||
deleteContact(contactId);
|
deleteContact(contactId);
|
||||||
|
// Delete from Radicale asynchronously
|
||||||
|
deleteContactAsync(contactId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ message: "Contact deleted successfully" });
|
return NextResponse.json({ message: "Contact deleted successfully" });
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
getContactStats,
|
getContactStats,
|
||||||
} from "@/lib/queries/contacts";
|
} from "@/lib/queries/contacts";
|
||||||
import { withAuth } from "@/lib/middleware/auth";
|
import { withAuth } from "@/lib/middleware/auth";
|
||||||
|
import { syncContactAsync } from "@/lib/radicale-sync";
|
||||||
|
|
||||||
// GET: Get all contacts with optional filters
|
// GET: Get all contacts with optional filters
|
||||||
async function getContactsHandler(req) {
|
async function getContactsHandler(req) {
|
||||||
@@ -58,6 +59,10 @@ async function createContactHandler(req) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const contact = createContact(data);
|
const contact = createContact(data);
|
||||||
|
|
||||||
|
// Sync to Radicale asynchronously (non-blocking)
|
||||||
|
syncContactAsync(contact);
|
||||||
|
|
||||||
return NextResponse.json(contact, { status: 201 });
|
return NextResponse.json(contact, { status: 201 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error creating contact:", error);
|
console.error("Error creating contact:", error);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ async function getContractsHandler() {
|
|||||||
contract_id,
|
contract_id,
|
||||||
contract_number,
|
contract_number,
|
||||||
contract_name,
|
contract_name,
|
||||||
|
customer_contract_number,
|
||||||
customer,
|
customer,
|
||||||
investor,
|
investor,
|
||||||
date_signed,
|
date_signed,
|
||||||
@@ -24,7 +25,7 @@ async function getContractsHandler() {
|
|||||||
|
|
||||||
async function createContractHandler(req) {
|
async function createContractHandler(req) {
|
||||||
const data = await req.json();
|
const data = await req.json();
|
||||||
db.prepare(
|
const result = db.prepare(
|
||||||
`
|
`
|
||||||
INSERT INTO contracts (
|
INSERT INTO contracts (
|
||||||
contract_number,
|
contract_number,
|
||||||
@@ -45,7 +46,10 @@ async function createContractHandler(req) {
|
|||||||
data.date_signed,
|
data.date_signed,
|
||||||
data.finish_date
|
data.finish_date
|
||||||
);
|
);
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
|
// Return the newly created contract with its ID
|
||||||
|
const contract = db.prepare("SELECT * FROM contracts WHERE contract_id = ?").get(result.lastInsertRowid);
|
||||||
|
return NextResponse.json(contract);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Protected routes - require authentication
|
// Protected routes - require authentication
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ export default function ContractsMainPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [filteredContracts, setFilteredContracts] = useState([]);
|
const [filteredContracts, setFilteredContracts] = useState([]);
|
||||||
const [sortBy, setSortBy] = useState("contract_number");
|
const [sortBy, setSortBy] = useState("date_signed");
|
||||||
const [sortOrder, setSortOrder] = useState("asc");
|
const [sortOrder, setSortOrder] = useState("desc");
|
||||||
const [statusFilter, setStatusFilter] = useState("all");
|
const [statusFilter, setStatusFilter] = useState("all");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -53,6 +53,9 @@ export default function ContractsMainPage() {
|
|||||||
contract.contract_name
|
contract.contract_name
|
||||||
?.toLowerCase()
|
?.toLowerCase()
|
||||||
.includes(searchTerm.toLowerCase()) ||
|
.includes(searchTerm.toLowerCase()) ||
|
||||||
|
contract.customer_contract_number
|
||||||
|
?.toLowerCase()
|
||||||
|
.includes(searchTerm.toLowerCase()) ||
|
||||||
contract.customer?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
contract.customer?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
contract.investor?.toLowerCase().includes(searchTerm.toLowerCase())
|
contract.investor?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
);
|
);
|
||||||
@@ -64,9 +67,9 @@ export default function ContractsMainPage() {
|
|||||||
filtered = filtered.filter((contract) => {
|
filtered = filtered.filter((contract) => {
|
||||||
if (statusFilter === "active" && contract.finish_date) {
|
if (statusFilter === "active" && contract.finish_date) {
|
||||||
return new Date(contract.finish_date) >= currentDate;
|
return new Date(contract.finish_date) >= currentDate;
|
||||||
} else if (statusFilter === "completed" && contract.finish_date) {
|
} else if (statusFilter === "expired" && contract.finish_date) {
|
||||||
return new Date(contract.finish_date) < currentDate;
|
return new Date(contract.finish_date) < currentDate;
|
||||||
} else if (statusFilter === "no_end_date") {
|
} else if (statusFilter === "ongoing") {
|
||||||
return !contract.finish_date;
|
return !contract.finish_date;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -117,27 +120,27 @@ export default function ContractsMainPage() {
|
|||||||
const active = contracts.filter(
|
const active = contracts.filter(
|
||||||
(c) => !c.finish_date || new Date(c.finish_date) >= currentDate
|
(c) => !c.finish_date || new Date(c.finish_date) >= currentDate
|
||||||
).length;
|
).length;
|
||||||
const completed = contracts.filter(
|
const expired = contracts.filter(
|
||||||
(c) => c.finish_date && new Date(c.finish_date) < currentDate
|
(c) => c.finish_date && new Date(c.finish_date) < currentDate
|
||||||
).length;
|
).length;
|
||||||
const withoutEndDate = contracts.filter((c) => !c.finish_date).length;
|
const withoutEndDate = contracts.filter((c) => !c.finish_date).length;
|
||||||
|
|
||||||
return { total, active, completed, withoutEndDate };
|
return { total, active, expired, withoutEndDate };
|
||||||
};
|
};
|
||||||
|
|
||||||
const getContractStatus = (contract) => {
|
const getContractStatus = (contract) => {
|
||||||
if (!contract.finish_date) return "ongoing";
|
if (!contract.finish_date) return "ongoing";
|
||||||
const currentDate = new Date();
|
const currentDate = new Date();
|
||||||
const finishDate = new Date(contract.finish_date);
|
const finishDate = new Date(contract.finish_date);
|
||||||
return finishDate >= currentDate ? "active" : "completed";
|
return finishDate >= currentDate ? "active" : "expired";
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusBadge = (status) => {
|
const getStatusBadge = (status) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "active":
|
case "active":
|
||||||
return <Badge variant="success">{t('contracts.active')}</Badge>;
|
return <Badge variant="success">{t('contracts.active')}</Badge>;
|
||||||
case "completed":
|
case "expired":
|
||||||
return <Badge variant="secondary">{t('common.completed')}</Badge>;
|
return <Badge variant="danger">{t('contracts.expired')}</Badge>;
|
||||||
case "ongoing":
|
case "ongoing":
|
||||||
return <Badge variant="primary">{t('contracts.withoutEndDate')}</Badge>;
|
return <Badge variant="primary">{t('contracts.withoutEndDate')}</Badge>;
|
||||||
default:
|
default:
|
||||||
@@ -209,8 +212,8 @@ export default function ContractsMainPage() {
|
|||||||
options: [
|
options: [
|
||||||
{ value: "all", label: "Wszystkie" },
|
{ value: "all", label: "Wszystkie" },
|
||||||
{ value: "active", label: "Aktywne" },
|
{ value: "active", label: "Aktywne" },
|
||||||
{ value: "completed", label: "Zakończone" },
|
{ value: "expired", label: "Przeterminowane" },
|
||||||
{ value: "no_end_date", label: "Bez daty końca" },
|
{ value: "ongoing", label: "W trakcie" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -221,7 +224,7 @@ export default function ContractsMainPage() {
|
|||||||
{ value: "contract_number", label: "Numer umowy" },
|
{ value: "contract_number", label: "Numer umowy" },
|
||||||
{ value: "contract_name", label: "Nazwa umowy" },
|
{ value: "contract_name", label: "Nazwa umowy" },
|
||||||
{ value: "customer", label: "Klient" },
|
{ value: "customer", label: "Klient" },
|
||||||
{ value: "start_date", label: "Data rozpoczęcia" },
|
{ value: "date_signed", label: "Data podpisania" },
|
||||||
{ value: "finish_date", label: "Data zakończenia" },
|
{ value: "finish_date", label: "Data zakończenia" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -338,9 +341,9 @@ export default function ContractsMainPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-4">
|
<div className="ml-4">
|
||||||
<p className="text-sm font-medium text-gray-600">Zakończone</p>
|
<p className="text-sm font-medium text-gray-600">Przeterminowane</p>
|
||||||
<p className="text-2xl font-bold text-gray-900">
|
<p className="text-2xl font-bold text-gray-900">
|
||||||
{stats.completed}
|
{stats.expired}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -588,10 +588,19 @@ export default function ProjectViewPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm font-medium text-gray-500 block mb-1">
|
<span className="text-sm font-medium text-gray-500 block mb-1">
|
||||||
Nazwa umowy
|
Numer umowy klienta
|
||||||
</span>
|
</span>
|
||||||
<p className="text-gray-900 font-medium">
|
<p className="text-gray-900 font-medium">
|
||||||
{project.contract_name || "N/A"}
|
{project.customer_contract_number ? (
|
||||||
|
<Link
|
||||||
|
href={`/contracts/${project.contract_id}`}
|
||||||
|
className="text-inherit hover:text-inherit no-underline"
|
||||||
|
>
|
||||||
|
{project.customer_contract_number}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
"N/A"
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import Badge from "@/components/ui/Badge";
|
|||||||
export default function ProjectContactSelector({ projectId, onChange }) {
|
export default function ProjectContactSelector({ projectId, onChange }) {
|
||||||
const [contacts, setContacts] = useState([]);
|
const [contacts, setContacts] = useState([]);
|
||||||
const [projectContacts, setProjectContacts] = useState([]);
|
const [projectContacts, setProjectContacts] = useState([]);
|
||||||
const [availableContacts, setAvailableContacts] = useState([]);
|
|
||||||
const [showSelector, setShowSelector] = useState(false);
|
const [showSelector, setShowSelector] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
@@ -37,7 +36,6 @@ export default function ProjectContactSelector({ projectId, onChange }) {
|
|||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setProjectContacts(data);
|
setProjectContacts(data);
|
||||||
updateAvailableContacts(data);
|
|
||||||
onChange?.(data);
|
onChange?.(data);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -45,14 +43,6 @@ export default function ProjectContactSelector({ projectId, onChange }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateAvailableContacts(linkedContacts) {
|
|
||||||
const linkedIds = linkedContacts.map((c) => c.contact_id);
|
|
||||||
const available = contacts.filter(
|
|
||||||
(c) => !linkedIds.includes(c.contact_id)
|
|
||||||
);
|
|
||||||
setAvailableContacts(available);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleAddContact(contactId) {
|
async function handleAddContact(contactId) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -125,14 +115,14 @@ export default function ProjectContactSelector({ projectId, onChange }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const filteredAvailable = searchTerm
|
const filteredAvailable = searchTerm
|
||||||
? availableContacts.filter(
|
? contacts.filter(
|
||||||
(c) =>
|
(c) =>
|
||||||
c.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
c.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
c.phone?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
c.phone?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
c.email?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
c.email?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
c.company?.toLowerCase().includes(searchTerm.toLowerCase())
|
c.company?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
)
|
)
|
||||||
: availableContacts;
|
: contacts;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -246,15 +236,22 @@ export default function ProjectContactSelector({ projectId, onChange }) {
|
|||||||
<p className="text-sm text-gray-500 text-center py-4">
|
<p className="text-sm text-gray-500 text-center py-4">
|
||||||
{searchTerm
|
{searchTerm
|
||||||
? "Nie znaleziono kontaktów"
|
? "Nie znaleziono kontaktów"
|
||||||
: "Wszystkie kontakty są już dodane"}
|
: "Brak dostępnych kontaktów"}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
filteredAvailable.map((contact) => {
|
filteredAvailable.map((contact) => {
|
||||||
const typeBadge = getContactTypeBadge(contact.contact_type);
|
const typeBadge = getContactTypeBadge(contact.contact_type);
|
||||||
|
const isAlreadyAdded = projectContacts.some(
|
||||||
|
(pc) => pc.contact_id === contact.contact_id
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={contact.contact_id}
|
key={contact.contact_id}
|
||||||
className="flex items-center justify-between p-2 hover:bg-gray-50 rounded border border-gray-200"
|
className={`flex items-center justify-between p-2 rounded border ${
|
||||||
|
isAlreadyAdded
|
||||||
|
? "bg-gray-100 border-gray-300"
|
||||||
|
: "hover:bg-gray-50 border-gray-200"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -264,6 +261,11 @@ export default function ProjectContactSelector({ projectId, onChange }) {
|
|||||||
<Badge variant={typeBadge.variant} size="xs">
|
<Badge variant={typeBadge.variant} size="xs">
|
||||||
{typeBadge.label}
|
{typeBadge.label}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{isAlreadyAdded && (
|
||||||
|
<Badge variant="secondary" size="xs">
|
||||||
|
Już dodany
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-600 mt-1">
|
<div className="text-xs text-gray-600 mt-1">
|
||||||
{contact.phone && <span>{contact.phone}</span>}
|
{contact.phone && <span>{contact.phone}</span>}
|
||||||
@@ -282,9 +284,9 @@ export default function ProjectContactSelector({ projectId, onChange }) {
|
|||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleAddContact(contact.contact_id)}
|
onClick={() => handleAddContact(contact.contact_id)}
|
||||||
disabled={loading}
|
disabled={loading || isAlreadyAdded}
|
||||||
>
|
>
|
||||||
Dodaj
|
{isAlreadyAdded ? "Dodany" : "Dodaj"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -222,6 +222,7 @@ export function getProjectWithContract(id) {
|
|||||||
p.*,
|
p.*,
|
||||||
c.contract_number,
|
c.contract_number,
|
||||||
c.contract_name,
|
c.contract_name,
|
||||||
|
c.customer_contract_number,
|
||||||
c.customer,
|
c.customer,
|
||||||
c.investor,
|
c.investor,
|
||||||
creator.name as created_by_name,
|
creator.name as created_by_name,
|
||||||
|
|||||||
283
src/lib/radicale-sync.js
Normal file
283
src/lib/radicale-sync.js
Normal 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
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