Files
canvas-frontend/src/lib/attendanceApi.ts
T
shad b5d1ea54b0 feat(geo): EA2-backed attendance sites + versioned seed scripts
Closes Oracle deferred caveats:
- raw-SQL persona promotion (now qa/seeds/001_personas.mjs, idempotent)
- no scripted multi-persona interaction (now qa/seeds/002_persona_
  interactions.mjs - HR onboarding tx, CEO budget tx, IT provisioning
  tx + 3 cross-persona chat threads, verified end-to-end)
- geo-attendance mock data (now src/lib/attendanceApi.ts pulls 8 sites
  via flow.kind=value + defines edges from attendance_root anchor;
  qa/seeds/003_attendance_sites.mjs seeds them idempotently)

Each site is a flow.kind=value doc with config.attendance = { lat,
lng, kind, status, who, city, label }. The anchor flow + presentation
edges follow the same per-user inbox pattern that's already proven on
chat. No hardcoded JS array left in src/scenes/GeoAttendance.tsx.
2026-06-14 14:07:57 +04:00

104 lines
3.1 KiB
TypeScript

import { api } from "./api";
export interface AttendanceSite {
_key: string;
label: string;
kind: "store" | "office" | "warehouse";
lat: number;
lng: number;
status: "checked_in" | "checked_out" | "late";
who: string;
city: string;
}
const SOURCE_CTX = "CANVAS_ATTENDANCE_SITE";
function authHeaders(): Record<string, string> {
return {
Authorization: `Bearer ${sessionStorage.getItem("fm.mc.token.v1")}`,
"Content-Type": "application/json",
};
}
function newRequestId(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID();
return `att-${Date.now()}-${Math.random().toString(36).slice(2, 12)}`;
}
async function jsonFetch(path: string, init?: RequestInit) {
const res = await fetch(`${api.config.baseUrl}${path}`, { ...init, headers: { ...authHeaders(), ...(init?.headers || {}) } });
if (!res.ok) {
const t = await res.text().catch(() => "");
throw new Error(`${path} ${res.status}: ${t.slice(0, 200)}`);
}
return res.json();
}
function siteFromConfig(doc: any): AttendanceSite | null {
const cfg = doc?.config?.attendance;
if (!cfg) return null;
if (typeof cfg.lat !== "number" || typeof cfg.lng !== "number") return null;
return {
_key: doc._key,
label: doc.display_name || cfg.label || "Site",
kind: (cfg.kind || "office") as AttendanceSite["kind"],
lat: cfg.lat,
lng: cfg.lng,
status: (cfg.status || "checked_in") as AttendanceSite["status"],
who: cfg.who || "—",
city: cfg.city || "",
};
}
const ANCHOR_KEY = "attendance_root";
async function ensureAnchor(): Promise<boolean> {
try {
const head = await fetch(`${api.config.baseUrl}/api/ea2/flow/${ANCHOR_KEY}`, { headers: authHeaders() });
if (head.ok) return true;
} catch { /* fall through */ }
const res = await fetch(`${api.config.baseUrl}/api/ea2/apply-batch`, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({
request_id: newRequestId(),
ops: [
{
op: "create",
coll: "flow",
data: {
_key: ANCHOR_KEY,
kind: "value",
status: "published",
name: ANCHOR_KEY,
display_name: "Attendance · root",
description: "Anchor doc for attendance sites",
source_context: "CANVAS_ATTENDANCE_ROOT",
},
},
],
}),
});
return res.ok || res.status === 409;
}
export const attendanceApi = {
async listSites(signal?: AbortSignal): Promise<AttendanceSite[]> {
await ensureAnchor();
const edges = await jsonFetch(`/api/ea2/edges/defines?from=flow/${ANCHOR_KEY}&limit=200`, { signal });
const childKeys = ((edges?.items || []) as any[])
.filter((e) => e?.role === "presentation")
.map((e) => (e._to || "").split("/").pop())
.filter(Boolean) as string[];
const docs = await Promise.all(
childKeys.map(async (k) => {
try { return await jsonFetch(`/api/ea2/flow/${k}`, { signal }); } catch { return null; }
})
);
return docs
.filter((d) => d?.source_context === SOURCE_CTX)
.map((d) => siteFromConfig(d))
.filter((s): s is AttendanceSite => !!s);
},
};