WFDASH-WEBAPP/src/app/(rep)/rep/outbox.ts

189 lines
5.4 KiB
TypeScript
Raw Normal View History

feat(rep): visit check-in PWA — phone GPS, offline outbox The rep app: open it on site, it finds who's around you, tick who you saw, done. Third instance of the per-route-group PWA pattern already running here (/m boss phone, /counter), so it reuses MobileAuthGate and gets its own manifest + service worker. Phone GPS is the primary position source, not the vehicle tracker. The tracker knows where the bakkie is; the phone knows where the rep is, and those diverge by a few hundred metres once the rep walks into the plant. GPS also needs no network, which matters because mine shafts have poor signal. The tracker is the secondary path (GET /api/v1/visits/position) for when location is denied or unavailable. The independently-collected vehicle trail corroborates check-ins afterwards, so honesty doesn't depend on the phone. Matching runs on the phone, not the server. The app caches a compact customer snapshot and runs rankNearbyCustomers locally, so there's one code path that behaves identically online and off — rather than a server path plus an offline fallback that only gets exercised in the places it's hardest to debug. - (rep)/rep/ layout, page, check-in view, outbox - visits-service.ts getCustomerSnapshot, recordVisits - POST /api/v1/visits record a check-in - GET /api/v1/visits/customers snapshot for the offline cache - GET /api/v1/visits/position tracker fallback - public/manifest-rep.json, public/sw-rep.js, pickSwPath() branch These routes authenticate with the browser session, not AGENT_API_KEY — the caller is a logged-in rep. The rep id comes from the validated session rather than the request body, so a client can't log a visit as someone else. The outbox uses localStorage rather than IndexedDB deliberately: the payloads are tiny and localStorage is synchronous, which matters when the rep hits Save and immediately pockets the phone. Entries are only removed once the server acknowledges them, so a failed flush retries instead of dropping a visit — duplicates are preferred over loss, and surfaced rather than merged. Permanently-rejected entries (4xx other than 401/408/429) are dropped so one bad row can't wedge the queue. recordVisits writes each visit independently: a stale customer id shouldn't cost the other four visits in a queued batch. BLOCKED until one PB admin change: rep_visits.position_source does not yet allow "phone" — verified against the live instance, PB returns 400 "Invalid value phone". Add it to the select. Separately, accuracy_m is silently accepted and discarded (PB ignores unknown fields), so GPS accuracy is thrown away until that number field is added; not blocking. 47 new tests. Verified on :4001: /rep serves 200 with the right metadata, manifest-rep.json and sw-rep.js are served. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 19:09:27 +00:00
/**
* Offline outbox for rep check-ins.
*
* A rep at a mine shaft has a GPS fix but frequently no signal. The
* whole flow is designed to complete without a network: the customer
* list is cached, matching runs on the phone, and the submission lands
* here until there's a connection.
*
* `localStorage` rather than IndexedDB on purpose. The payloads are
* tiny (a position plus a handful of customer ids), the volume is a few
* entries a day, and localStorage is synchronous — which matters when
* the rep hits Save and immediately locks the phone or walks into a
* dead zone. An IndexedDB write is async and can be interrupted between
* the call and the commit; that's precisely the moment we can't afford
* to lose the entry.
*
* Entries are only removed once the server has acknowledged them, so a
* failed flush retries rather than silently dropping a visit. The cost
* of that choice is possible duplicates if a response is lost in
* flight — deliberately preferred over a lost visit, and surfaced to
* the rep rather than merged away.
*/
const OUTBOX_KEY = "wfdash.rep.outbox.v1";
const SNAPSHOT_KEY = "wfdash.rep.customers.v1";
export interface QueuedVisit {
customerId: string;
notes?: string;
pinCustomer?: boolean;
}
export interface QueuedCheckIn {
/** Client-side id, so a retry can be matched to its entry. */
id: string;
lat: number;
lon: number;
accuracyM: number | null;
positionSource: "phone" | "tracker" | "manual";
/** When the rep actually checked in — not when it reached the server. */
visitedAt: string;
visits: QueuedVisit[];
/** Failed flush attempts, for surfacing a stuck entry to the rep. */
attempts: number;
lastError?: string;
}
export interface CachedSnapshot {
customers: unknown[];
fetchedAt: string;
}
function safeRead<T>(key: string, fallback: T): T {
if (typeof window === "undefined") return fallback;
try {
const raw = window.localStorage.getItem(key);
if (!raw) return fallback;
return JSON.parse(raw) as T;
} catch {
// Corrupt or unparseable — treat as empty rather than throwing on a
// screen the rep needs to work right now.
return fallback;
}
}
function safeWrite(key: string, value: unknown): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(key, JSON.stringify(value));
} catch {
/* quota or private mode — nothing useful to do here */
}
}
/* ---------------------------------------------------------------- outbox */
export function readOutbox(): QueuedCheckIn[] {
const entries = safeRead<QueuedCheckIn[]>(OUTBOX_KEY, []);
return Array.isArray(entries) ? entries : [];
}
export function enqueue(entry: Omit<QueuedCheckIn, "id" | "attempts">): QueuedCheckIn {
const queued: QueuedCheckIn = {
...entry,
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
attempts: 0,
};
safeWrite(OUTBOX_KEY, [...readOutbox(), queued]);
return queued;
}
export function removeFromOutbox(id: string): void {
safeWrite(
OUTBOX_KEY,
readOutbox().filter((entry) => entry.id !== id)
);
}
function markAttempt(id: string, error: string): void {
safeWrite(
OUTBOX_KEY,
readOutbox().map((entry) =>
entry.id === id
? { ...entry, attempts: entry.attempts + 1, lastError: error }
: entry
)
);
}
export interface FlushResult {
sent: number;
failed: number;
remaining: number;
}
/**
* Attempts to post every queued check-in, oldest first.
*
* A `4xx` other than 401/408/429 means the server understood and
* rejected the entry — retrying forever would wedge the queue behind a
* permanently-bad row, so those are dropped. Network failures, 401s
* (session expired, will be valid again after re-login) and 5xx are
* kept for the next flush.
*/
export async function flushOutbox(
token: string,
fetchImpl: typeof fetch = fetch
): Promise<FlushResult> {
const result: FlushResult = { sent: 0, failed: 0, remaining: 0 };
for (const entry of readOutbox()) {
try {
const response = await fetchImpl("/api/v1/visits", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
lat: entry.lat,
lon: entry.lon,
accuracyM: entry.accuracyM,
positionSource: entry.positionSource,
visitedAt: entry.visitedAt,
visits: entry.visits,
}),
});
if (response.ok) {
removeFromOutbox(entry.id);
result.sent++;
continue;
}
const permanent =
response.status >= 400 &&
response.status < 500 &&
![401, 408, 429].includes(response.status);
if (permanent) {
removeFromOutbox(entry.id);
result.failed++;
continue;
}
markAttempt(entry.id, `HTTP ${response.status}`);
result.failed++;
} catch (error) {
markAttempt(entry.id, error instanceof Error ? error.message : "offline");
result.failed++;
}
}
result.remaining = readOutbox().length;
return result;
}
/* -------------------------------------------------------------- snapshot */
export function readCachedSnapshot(): CachedSnapshot | null {
const cached = safeRead<CachedSnapshot | null>(SNAPSHOT_KEY, null);
if (!cached || !Array.isArray(cached.customers)) return null;
return cached;
}
export function writeCachedSnapshot(snapshot: CachedSnapshot): void {
safeWrite(SNAPSHOT_KEY, snapshot);
}