// lib/syncService.ts import { getDatabase } from '@/lib/database'; // ── Types ───────────────────────────────────────────────────────────────────── export interface SyncPartner { id: string; name: string; address: string; // IP:port } export interface Contact { id: string; owner_id: string; full_name: string; address?: string; household_count?: number; gender?: string; category: string; status?: string; tags?: string; notes?: string; territory_code?: string; latitude?: number; longitude?: number; created_at: number; updated_at: number; deleted_at?: number | null; } export interface Visit { id: string; contact_id: string; visited_by_name: string; visited_by_id: string; visit_date: number; topic?: string; response?: string; remarks?: string; next_visit_date?: number; created_at: number; updated_at: number; } export interface Territory { id: string; territory_code: string; municipality?: string; barangay?: string; area?: string; block?: string; assigned_to?: string; created_at: number; updated_at: number; } export interface SyncPayload { contacts: Contact[]; visits: Visit[]; territories: Territory[]; exportedAt: number; exportedBy: string; } export interface SyncResult { sent: number; received: number; conflicts: number; } // ── STUB: mDNS Discovery ────────────────────────────────────────────────────── // TODO: Implement with react-native-zeroconf in bare workflow (expo-dev-client). // This requires native modules that are not available in managed Expo Go. // Steps to activate: // 1. Run `npx expo eject` or use a bare workflow // 2. Install: npm install react-native-zeroconf // 3. Run `npx pod-install` (iOS) and rebuild // 4. Replace this stub with actual Zeroconf implementation export async function discoverPartners(): Promise { // STUB — bare workflow required throw new Error( 'mDNS discovery requires expo-dev-client and bare workflow. ' + 'Install react-native-zeroconf after ejecting from managed workflow.' ); } // ── STUB: TCP Sync Server ───────────────────────────────────────────────────── // TODO: Implement with react-native-tcp-socket in bare workflow. // Steps to activate: // 1. Run `npx expo eject` or use a bare workflow // 2. Install: npm install react-native-tcp-socket // 3. Run `npx pod-install` (iOS) and rebuild // 4. Replace this stub with actual TCP server implementation export async function startSyncServer(port: number): Promise { // STUB — bare workflow required throw new Error( 'TCP server requires expo-dev-client and bare workflow. ' + 'Install react-native-tcp-socket after ejecting from managed workflow.' ); } // ── Merge Logic (FULLY IMPLEMENTED) ────────────────────────────────────────── /** * Merges two arrays of records, resolving conflicts using updated_at timestamp. * - Latest updated_at wins per record (by id) * - Soft deletes (deleted_at != null) are respected */ export function mergeRecords(local: T[], remote: T[]): T[] { const merged = new Map(); // Load all local records for (const record of local) { merged.set(record.id, record); } // Merge remote records: remote wins if updated_at is newer or equal for (const remoteRecord of remote) { const localRecord = merged.get(remoteRecord.id); if (!localRecord) { // New record from remote merged.set(remoteRecord.id, remoteRecord); } else { // Conflict: pick the most recently updated if (remoteRecord.updated_at >= localRecord.updated_at) { merged.set(remoteRecord.id, remoteRecord); } // else: keep local (it's newer) } } return Array.from(merged.values()); } // ── Serialize for Sync (FULLY IMPLEMENTED) ──────────────────────────────────── /** * Serializes local data into a SyncPayload for transmission. */ export function serializeForSync( contacts: Contact[], visits: Visit[], territories: Territory[], exportedBy: string ): SyncPayload { return { contacts, visits, territories, exportedAt: Math.floor(Date.now() / 1000), exportedBy, }; } // ── Apply Incoming Sync (FULLY IMPLEMENTED) ──────────────────────────────────── /** * Applies a received SyncPayload to the local database. * Merges records using updated_at conflict resolution. * Returns a SyncResult with counts. */ export async function applyIncomingSync( payload: SyncPayload, localUserId: string ): Promise { const db = await getDatabase(); let received = 0; let conflicts = 0; // ── Territories ──────────────────────────────────────────────────────────── const localTerritories = await db.getAllAsync( 'SELECT * FROM territories' ); const mergedTerritories = mergeRecords(localTerritories, payload.territories); for (const territory of mergedTerritories) { const existing = localTerritories.find((t) => t.id === territory.id); if (existing) { if (territory.updated_at > existing.updated_at) { conflicts++; await db.runAsync( `UPDATE territories SET territory_code=?, municipality=?, barangay=?, area=?, block=?, assigned_to=?, updated_at=? WHERE id=?`, [ territory.territory_code, territory.municipality ?? null, territory.barangay ?? null, territory.area ?? null, territory.block ?? null, territory.assigned_to ?? null, territory.updated_at, territory.id, ] ); received++; } } else { await db.runAsync( `INSERT OR IGNORE INTO territories (id, territory_code, municipality, barangay, area, block, assigned_to, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)`, [ territory.id, territory.territory_code, territory.municipality ?? null, territory.barangay ?? null, territory.area ?? null, territory.block ?? null, territory.assigned_to ?? null, territory.created_at, territory.updated_at, ] ); received++; } } // ── Contacts ─────────────────────────────────────────────────────────────── const localContacts = await db.getAllAsync( 'SELECT * FROM contacts' ); const mergedContacts = mergeRecords(localContacts, payload.contacts); for (const contact of mergedContacts) { const existing = localContacts.find((c) => c.id === contact.id); if (existing) { if (contact.updated_at > existing.updated_at) { conflicts++; await db.runAsync( `UPDATE contacts SET owner_id=?, full_name=?, address=?, household_count=?, gender=?, category=?, status=?, tags=?, notes=?, territory_code=?, latitude=?, longitude=?, updated_at=?, deleted_at=? WHERE id=?`, [ contact.owner_id, contact.full_name, contact.address ?? null, contact.household_count ?? 1, contact.gender ?? null, contact.category, contact.status ?? 'Active', contact.tags ?? '[]', contact.notes ?? null, contact.territory_code ?? null, contact.latitude ?? null, contact.longitude ?? null, contact.updated_at, contact.deleted_at ?? null, contact.id, ] ); received++; } } else { await db.runAsync( `INSERT OR IGNORE INTO contacts (id, owner_id, full_name, address, household_count, gender, category, status, tags, notes, territory_code, latitude, longitude, created_at, updated_at, deleted_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, [ contact.id, contact.owner_id, contact.full_name, contact.address ?? null, contact.household_count ?? 1, contact.gender ?? null, contact.category, contact.status ?? 'Active', contact.tags ?? '[]', contact.notes ?? null, contact.territory_code ?? null, contact.latitude ?? null, contact.longitude ?? null, contact.created_at, contact.updated_at, contact.deleted_at ?? null, ] ); received++; } } // ── Visits ───────────────────────────────────────────────────────────────── const localVisits = await db.getAllAsync( 'SELECT * FROM visits' ); const mergedVisits = mergeRecords( localVisits.map((v) => ({ ...v, deleted_at: undefined })), payload.visits.map((v) => ({ ...v, deleted_at: undefined })) ); for (const visit of mergedVisits) { const existing = localVisits.find((v) => v.id === visit.id); if (existing) { if (visit.updated_at > existing.updated_at) { conflicts++; await db.runAsync( `UPDATE visits SET contact_id=?, visited_by_name=?, visited_by_id=?, visit_date=?, topic=?, response=?, remarks=?, next_visit_date=?, updated_at=? WHERE id=?`, [ visit.contact_id, visit.visited_by_name, visit.visited_by_id, visit.visit_date, visit.topic ?? null, visit.response ?? null, visit.remarks ?? null, visit.next_visit_date ?? null, visit.updated_at, visit.id, ] ); received++; } } else { await db.runAsync( `INSERT OR IGNORE INTO visits (id, contact_id, visited_by_name, visited_by_id, visit_date, topic, response, remarks, next_visit_date, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)`, [ visit.id, visit.contact_id, visit.visited_by_name, visit.visited_by_id, visit.visit_date, visit.topic ?? null, visit.response ?? null, visit.remarks ?? null, visit.next_visit_date ?? null, visit.created_at, visit.updated_at, ] ); received++; } } return { sent: 0, // sent is tracked by the caller received, conflicts, }; } // ── Local data fetch helpers ─────────────────────────────────────────────────── export async function getLocalSyncData(): Promise<{ contacts: Contact[]; visits: Visit[]; territories: Territory[]; }> { const db = await getDatabase(); const contacts = await db.getAllAsync('SELECT * FROM contacts WHERE deleted_at IS NULL'); const visits = await db.getAllAsync('SELECT * FROM visits'); const territories = await db.getAllAsync('SELECT * FROM territories'); return { contacts, visits, territories }; } // ── Sync Log helpers ────────────────────────────────────────────────────────── export interface SyncLogEntry { id: string; partner_id: string; partner_name: string | null; synced_at: number; sent_count: number; received_count: number; } export async function getSyncHistory(): Promise { const db = await getDatabase(); return db.getAllAsync( 'SELECT * FROM sync_log ORDER BY synced_at DESC LIMIT 50' ); } export async function recordSync( partnerId: string, partnerName: string | null, sentCount: number, receivedCount: number ): Promise { const db = await getDatabase(); const id = `sync_${Date.now()}`; await db.runAsync( 'INSERT INTO sync_log (id, partner_id, partner_name, synced_at, sent_count, received_count) VALUES (?,?,?,?,?,?)', [id, partnerId, partnerName, Math.floor(Date.now() / 1000), sentCount, receivedCount] ); }