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.
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
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);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user