feat: Sprint 5 — PIN lock, biometric auth, sync screen, QR code, merge logic
- 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
This commit is contained in:
402
lib/syncService.ts
Normal file
402
lib/syncService.ts
Normal file
@@ -0,0 +1,402 @@
|
||||
// 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]
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user