- lib/pinService.ts: PIN storage/verification via expo-secure-store - lib/syncService.ts: sync abstraction with full merge logic (mDNS/TCP stubbed) - app/lock.tsx: full-screen PIN lock with biometric support - components/ui/PinPad.tsx: reusable 6-digit PIN pad component - components/sync/QRScanner.tsx: QR code scanner via expo-camera - app/(tabs)/sync.tsx: Sync tab with QR display, partner input, history - app/sync-progress.tsx: animated sync progress screen with summary - app/(tabs)/settings.tsx: full settings with PIN setup, security, data sections - app/(tabs)/_layout.tsx: added Sync tab (RefreshCw icon) - app/_layout.tsx: AppState listener locks app after 60s background - package.json: added expo-secure-store, react-native-qrcode-svg
403 lines
13 KiB
TypeScript
403 lines
13 KiB
TypeScript
// 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<SyncPartner[]> {
|
|
// 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<void> {
|
|
// 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<T extends {
|
|
id: string;
|
|
updated_at: number;
|
|
deleted_at?: number | null;
|
|
}>(local: T[], remote: T[]): T[] {
|
|
const merged = new Map<string, T>();
|
|
|
|
// 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<SyncResult> {
|
|
const db = await getDatabase();
|
|
let received = 0;
|
|
let conflicts = 0;
|
|
|
|
// ── Territories ────────────────────────────────────────────────────────────
|
|
const localTerritories = await db.getAllAsync<Territory>(
|
|
'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<Contact>(
|
|
'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<Visit>(
|
|
'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<Contact>('SELECT * FROM contacts WHERE deleted_at IS NULL');
|
|
const visits = await db.getAllAsync<Visit>('SELECT * FROM visits');
|
|
const territories = await db.getAllAsync<Territory>('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<SyncLogEntry[]> {
|
|
const db = await getDatabase();
|
|
return db.getAllAsync<SyncLogEntry>(
|
|
'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<void> {
|
|
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]
|
|
);
|
|
}
|