WFDASH-WEBAPP/src/app/(rep)/rep/outbox.ts
Dave c8b7e1f51b 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 21:09:27 +02:00

188 lines
5.4 KiB
TypeScript

/**
* 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);
}