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.
104 lines
3.1 KiB
TypeScript
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);
|
|
},
|
|
};
|