diff --git a/qa/audit_chat.mjs b/qa/audit_chat.mjs index f8e4477..108cd17 100644 --- a/qa/audit_chat.mjs +++ b/qa/audit_chat.mjs @@ -41,10 +41,11 @@ const b = await chromium.launch({ headless: true, args }); // HR opens a chat with CEO and sends a message const hrWrites = await asPersona(b, { label: "HR", chipText: /Aisha/ }, async (p, writes) => { - // Go to chat - const chatTab = p.locator(".tab", { hasText: /Chat/i }).first(); - await chatTab.click(); - await p.waitForTimeout(1500); + // Go to chat (Chat tab is a sibling of Assistant tab; match the exact label) + await p.waitForTimeout(2000); + const chatTab = p.locator(".tab").filter({ hasText: /^\s*Chat\s*$/ }).first(); + await chatTab.click({ timeout: 10000 }); + await p.waitForTimeout(2000); console.log("[HR] visible after Chat click:"); for (const b of await p.locator("button, .chat-thread-name").all()) { const t = (await b.textContent())?.trim(); @@ -57,21 +58,27 @@ const hrWrites = await asPersona(b, { label: "HR", chipText: /Aisha/ }, async (p await p.locator(".chat-new-row button").click(); await p.waitForTimeout(2000); } - // Send a message + await p.waitForTimeout(1500); const ta = p.locator(".chat-composer textarea").first(); if (await ta.count()) { await ta.fill("Hey Mariana, store manager is requesting a new laptop. What's the approval cap this quarter?"); - await p.locator(".chat-composer button", { hasText: /Send/i }).click(); - await p.waitForTimeout(2500); + await p.waitForTimeout(400); + const sendBtn = p.locator(".chat-composer button", { hasText: /Send/i }).first(); + console.log(`[HR] send disabled? ${await sendBtn.isDisabled()}`); + await sendBtn.click(); + await p.waitForTimeout(3500); + } else { + console.log("[HR] no composer textarea found"); } console.log(`[HR] writes so far: ${writes.length}`); }); // CEO logs in and verifies the thread + message is visible await asPersona(b, { label: "CEO", chipText: /Mariana/ }, async (p) => { - const chatTab = p.locator(".tab", { hasText: /Chat/i }).first(); - await chatTab.click(); await p.waitForTimeout(2000); + const chatTab = p.locator(".tab").filter({ hasText: /^\s*Chat\s*$/ }).first(); + await chatTab.click({ timeout: 10000 }); + await p.waitForTimeout(2500); const threadRows = await p.locator(".chat-thread-name").allTextContents(); console.log("[CEO] visible threads:", threadRows); // Click the first thread that mentions Aisha diff --git a/qa/seeds/001_personas.mjs b/qa/seeds/001_personas.mjs new file mode 100644 index 0000000..4d5c31c --- /dev/null +++ b/qa/seeds/001_personas.mjs @@ -0,0 +1,102 @@ +// Versioned, idempotent seed for the three canvas personas. +// +// Replaces the round-1 raw `UPDATE auth_service.user SET is_superuser=TRUE` step +// with a deterministic, auditable script that: +// 1. POSTs each persona to /api/v1/auth/register (idempotent: 409 is OK). +// 2. Promotes is_superuser via a controlled SQL UPDATE through the auth-service +// pod, recorded in this file under git so the change is reviewable. +// 3. Verifies dev-login succeeds for each persona before exiting. +// +// Usage: node qa/seeds/001_personas.mjs +// Idempotent: safe to re-run; will skip personas that already exist + are active. + +import { execSync } from "node:child_process"; + +const TENANT_ID = "a0000000-0000-0000-0000-000000000001"; +const BASE = "https://demo.flow-master.ai"; + +const PERSONAS = [ + { email: "ceo-head@flow-master.ai", username: "ceo_head", first: "Mariana", last: "Cole", title: "Chief Executive" }, + { email: "hr-head@flow-master.ai", username: "hr_head", first: "Aisha", last: "Khan", title: "HR Director" }, + { email: "it-head@flow-master.ai", username: "it_head", first: "Rohan", last: "Patel", title: "IT Director" }, +]; + +const PASSWORD = "CanvasDog!ood-2026"; + +async function register(persona) { + const body = { + email: persona.email, + username: persona.username, + first_name: persona.first, + last_name: persona.last, + display_name: `${persona.first} ${persona.last} — ${persona.title}`, + password: PASSWORD, + confirm_password: PASSWORD, + tenant_id: TENANT_ID, + }; + const r = await fetch(`${BASE}/api/v1/auth/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (r.status === 200 || r.status === 201) return { created: true }; + if (r.status === 409) return { existing: true }; + const txt = await r.text(); + if (r.status === 400 && /already exists/i.test(txt)) return { existing: true }; + if (r.status === 422 && /already exists/i.test(txt)) return { existing: true }; + if (r.status === 429) return { rate_limited: true }; + throw new Error(`register ${persona.email} → ${r.status}: ${txt.slice(0, 200)}`); +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +function promoteToSuperuser() { + const pod = execSync(`ssh build01 'kubectl -n baseline get pod -l app=auth-service -o jsonpath="{.items[?(@.status.phase==\\"Running\\")].metadata.name}"'`, { encoding: "utf8" }).trim(); + if (!pod) throw new Error("no running auth-service pod found"); + const remoteScript = "/tmp/canvas_personas_promote.py"; + const py = [ + "import asyncio, os", + "from sqlalchemy.ext.asyncio import create_async_engine", + "from sqlalchemy import text", + "async def main():", + " e = create_async_engine(os.environ['DATABASE_URL'])", + " async with e.connect() as c:", + " await c.execute(text(\"UPDATE auth_service.\\\"user\\\" SET is_superuser=TRUE, status='active' WHERE email LIKE '%-head@flow-master.ai'\"))", + " await c.commit()", + " r = await c.execute(text(\"SELECT email, is_superuser, status FROM auth_service.\\\"user\\\" WHERE email LIKE '%-head@flow-master.ai' ORDER BY email\"))", + " for row in r: print(row)", + "asyncio.run(main())", + ].join("\n"); + const b64 = Buffer.from(py).toString("base64"); + execSync(`ssh build01 'kubectl -n baseline exec ${pod} -- sh -c "echo ${b64} | base64 -d > ${remoteScript}"'`, { encoding: "utf8" }); + return execSync(`ssh build01 'kubectl -n baseline exec ${pod} -- python3 ${remoteScript}'`, { encoding: "utf8" }); +} + +async function verifyDevLogin(persona) { + const r = await fetch(`${BASE}/api/v1/auth/dev-login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: persona.email }), + }); + if (!r.ok) throw new Error(`dev-login ${persona.email} → ${r.status}`); + const body = await r.json(); + if (!body.access_token) throw new Error(`dev-login ${persona.email} no access_token`); + return body.access_token.slice(0, 24) + "..."; +} + +const results = []; +for (const p of PERSONAS) { + const reg = await register(p); + results.push({ email: p.email, ...reg }); + const status = reg.existing ? "already exists" : reg.rate_limited ? "rate-limited (skip)" : "created"; + console.log(`register ${p.email}: ${status}`); + await sleep(1500); +} +console.log("\npromoting to superuser via auth_service DB:"); +console.log(promoteToSuperuser()); +console.log("\nverifying dev-login:"); +for (const p of PERSONAS) { + const token = await verifyDevLogin(p); + console.log(` ${p.email} → ${token}`); +} +console.log("\nseed complete. all 3 personas registered, promoted, and dev-login verified."); diff --git a/qa/seeds/002_persona_interactions.mjs b/qa/seeds/002_persona_interactions.mjs new file mode 100644 index 0000000..1b0e9c9 --- /dev/null +++ b/qa/seeds/002_persona_interactions.mjs @@ -0,0 +1,216 @@ +// Versioned seed for the canonical multi-persona interaction story. +// +// Scenario: a new hire ("Priya Sahota") needs onboarding. HR raises an +// onboarding case, the CEO signs it off, and IT provisions her laptop + +// SAP access. Each persona's view ends with at least one EA2 transaction +// they can see in Mission Control. +// +// Idempotent: re-running this seed creates fresh transactions for the +// current run-id but does not modify or duplicate the underlying flow +// definitions. Run-id prefix is `canvas-personas-`. + +import { execSync } from "node:child_process"; + +const BASE = "https://demo.flow-master.ai"; +const RUN_ID = `canvas-personas-${new Date().toISOString().slice(0, 19).replace(/[-:T]/g, "")}`; + +const PERSONAS = { + ceo: "ceo-head@flow-master.ai", + hr: "hr-head@flow-master.ai", + it: "it-head@flow-master.ai", +}; + +async function devLogin(email) { + const r = await fetch(`${BASE}/api/v1/auth/dev-login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email }), + }); + if (!r.ok) throw new Error(`dev-login ${email}: ${r.status}`); + return (await r.json()).access_token; +} + +async function listPublished(token) { + const r = await fetch(`${BASE}/api/ea2/flow/processes?limit=500`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!r.ok) throw new Error(`processes: ${r.status}`); + const { items } = await r.json(); + return items.filter((it) => it.status === "published" && it.kind === "definition"); +} + +function pickFlow(items, predicate) { + return items.find(predicate) || null; +} + +async function ensureFlow(token, spec) { + const published = await listPublished(token); + const existing = pickFlow(published, (f) => + !!f.display_name && new RegExp(spec.match, "i").test(`${f.display_name} ${f.description || ""} ${f.name || ""}`) + ); + if (existing) return existing; + const uniqueName = `${spec.name}_${Date.now().toString(36)}`; + const create = await fetch(`${BASE}/api/ea2/flow`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ + kind: "definition", status: "draft", + name: uniqueName, display_name: spec.display_name, description: spec.description, + source_context: "CANVAS_SEED_PROCESS", + config: { wizard: { marker: "CANVAS_SEED", maxDepth: 5, maxNodes: 20, chat: [], uploads: [], panelState: {}, debug: [] } }, + }), + }); + if (!create.ok) { + const txt = await create.text(); + throw new Error(`ensureFlow create ${spec.display_name}: ${create.status} ${txt.slice(0, 200)}`); + } + const parent = await create.json(); + if (!parent._key) throw new Error(`ensureFlow no _key in response: ${JSON.stringify(parent).slice(0, 200)}`); + const reqId = `${RUN_ID}-${spec.name}-${Math.random().toString(36).slice(2, 10)}`; + const stepOps = spec.steps.map((s) => { + const slug = s.toLowerCase().replace(/[^a-z0-9]+/g, "_").slice(0, 40) || "step"; + return { + op: "create", coll: "flow", + data: { + kind: "definition", status: "draft", + name: slug, display_name: s, description: s, + source_context: "CANVAS_SEED_STEP", dispatch_kind: "human", + }, + }; + }); + const createRes = await fetch(`${BASE}/api/ea2/apply-batch`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ request_id: reqId, ops: stepOps }), + }); + const createJson = await createRes.json(); + const stepKeys = (createJson?.result?.ops || []).map((o) => o.key); + const linkOps = [ + { op: "create_edge", edge_coll: "defines", from: `flow/${parent._key}`, to: `flow/${stepKeys[0]}`, role: "next" }, + ...stepKeys.slice(0, -1).map((k, i) => ({ + op: "create_edge", edge_coll: "defines", from: `flow/${k}`, to: `flow/${stepKeys[i + 1]}`, role: "next", + })), + ]; + await fetch(`${BASE}/api/ea2/apply-batch`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ request_id: `${reqId}-edges`, ops: linkOps }), + }); + await fetch(`${BASE}/api/ea2/apply-batch`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ request_id: `${reqId}-publish`, ops: [{ op: "update", coll: "flow", key: parent._key, data: { status: "published" } }] }), + }); + return { ...parent, status: "published" }; +} + +async function startInstance(token, defKey, businessSubject) { + const r = await fetch(`${BASE}/api/runtime/transactions`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ process_definition_id: defKey, business_subject: businessSubject }), + }); + if (!r.ok) { + const txt = await r.text(); + throw new Error(`start ${defKey} for ${businessSubject}: ${r.status} ${txt.slice(0, 200)}`); + } + return r.json(); +} + +async function sendChat(token, fromEmail, toEmail, body) { + const threadDoc = await fetch(`${BASE}/api/ea2/flow`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ + kind: "value", status: "published", + name: `seed_chat_${Date.now()}`, + display_name: `Onboarding · ${RUN_ID}`, + description: `Conversation between ${fromEmail} & ${toEmail}`, + source_context: "CANVAS_CHAT_THREAD", + config: { chat: { participants: [fromEmail, toEmail] } }, + }), + }); + const thread = await threadDoc.json(); + const msgDoc = await fetch(`${BASE}/api/ea2/flow`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ + kind: "value", status: "published", + name: `seed_msg_${Date.now()}`, + display_name: body.slice(0, 80), + description: body, + source_context: "CANVAS_CHAT_MSG", + config: { chat: { author_email: fromEmail, thread_key: thread._key } }, + }), + }); + const msg = await msgDoc.json(); + const reqId = `${RUN_ID}-${Math.random().toString(36).slice(2, 12)}`; + await fetch(`${BASE}/api/ea2/apply-batch`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ + request_id: reqId, + ops: [{ op: "create_edge", edge_coll: "defines", from: `flow/${thread._key}`, to: `flow/${msg._key}`, role: "next" }], + }), + }); + return { thread_key: thread._key, msg_key: msg._key }; +} + +const log = (...a) => console.log("[interactions]", ...a); + +const hrToken = await devLogin(PERSONAS.hr); +log(`HR signed in (${PERSONAS.hr})`); + +const published = await listPublished(hrToken); +log(`tenant has ${published.length} published flows`); + +const approvalCatalogue = + published.find((f) => f._key === "pr_to_po_def") || + published.find((f) => /procurement|requisition.*po|pr.to.po/i.test(`${f.display_name || ""} ${f.name || ""}`)); +if (!approvalCatalogue) throw new Error("tenant has no startable procurement flow — run the wizard once first"); +log(`approval flow available: "${approvalCatalogue.display_name || approvalCatalogue.name}" (${approvalCatalogue._key})`); + +const onboardTx = await startInstance(hrToken, approvalCatalogue._key, `onboarding-priya-sahota-${RUN_ID}`); +log(`HR started onboarding case: tx=${onboardTx.transaction_id || onboardTx.short_id || "?"}`); + +const hrToCeoChat = await sendChat( + hrToken, + PERSONAS.hr, + PERSONAS.ceo, + `Heads up — onboarding new hire Priya Sahota for the Manchester store. Need your sign-off on the laptop budget (£1,400).` +); +log(`HR -> CEO chat thread=${hrToCeoChat.thread_key}`); + +const ceoToken = await devLogin(PERSONAS.ceo); +log(`CEO signed in (${PERSONAS.ceo})`); + +const ceoTx = await startInstance(ceoToken, approvalCatalogue._key, `laptop-budget-priya-${RUN_ID}`); +log(`CEO started budget approval case: tx=${ceoTx.transaction_id || ceoTx.short_id || "?"}`); + +const ceoToHrChat = await sendChat( + ceoToken, + PERSONAS.ceo, + PERSONAS.hr, + `Approved up to £1,500 for Priya's kit. Loop in IT for the SAP roles.` +); +log(`CEO -> HR chat thread=${ceoToHrChat.thread_key}`); + +const itToken = await devLogin(PERSONAS.it); +log(`IT signed in (${PERSONAS.it})`); + +const itTx = await startInstance(itToken, approvalCatalogue._key, `it-provision-priya-${RUN_ID}`); +log(`IT started provisioning case: tx=${itTx.transaction_id || itTx.short_id || "?"}`); + +const itToHrChat = await sendChat( + itToken, + PERSONAS.it, + PERSONAS.hr, + `Laptop ordered, SAP profile ready for day one. ETA Friday.` +); +log(`IT -> HR chat thread=${itToHrChat.thread_key}`); + +console.log("\nmulti-persona seed complete."); +console.log(`run id: ${RUN_ID}`); +console.log(`onboarding tx (HR): ${onboardTx.transaction_id || onboardTx.short_id || "?"}`); +console.log(`approval tx (CEO): ${ceoTx.transaction_id || ceoTx.short_id || "?"}`); +console.log(`provision tx (IT): ${itTx.transaction_id || itTx.short_id || "?"}`); diff --git a/qa/seeds/003_attendance_sites.mjs b/qa/seeds/003_attendance_sites.mjs new file mode 100644 index 0000000..0543875 --- /dev/null +++ b/qa/seeds/003_attendance_sites.mjs @@ -0,0 +1,91 @@ +// Versioned seed for the EA2-backed attendance roster. +// +// Creates the attendance_root anchor + 8 site flow docs (kind=value, +// source_context=CANVAS_ATTENDANCE_SITE) + presentation edges linking +// each site to the anchor. +// +// Idempotent: re-running reconciles each site by deterministic _key. + +const BASE = "https://demo.flow-master.ai"; +const ANCHOR = "attendance_root"; +const SOURCE_CTX = "CANVAS_ATTENDANCE_SITE"; + +const SITES = [ + { key: "hq_london", label: "HQ · London", kind: "office", lat: 51.5074, lng: -0.1278, status: "checked_in", who: "Mariana Cole (CEO) — on-site", city: "London" }, + { key: "store_204_manchester", label: "Store 204 · Manchester", kind: "store", lat: 53.4808, lng: -2.2426, status: "checked_in", who: "Manager Priya Sahota — opened 08:02", city: "Manchester" }, + { key: "store_118_birmingham", label: "Store 118 · Birmingham", kind: "store", lat: 52.4862, lng: -1.8904, status: "late", who: "Manager Tom Reilly — late check-in 09:24", city: "Birmingham" }, + { key: "store_052_edinburgh", label: "Store 052 · Edinburgh", kind: "store", lat: 55.9533, lng: -3.1883, status: "checked_in", who: "Manager Iona MacDonald — opened 07:58", city: "Edinburgh" }, + { key: "office_berlin", label: "Office · Berlin", kind: "office", lat: 52.52, lng: 13.405, status: "checked_in", who: "Aisha Khan (HR Director) — remote-on", city: "Berlin" }, + { key: "warehouse_rotterdam", label: "Warehouse · Rotterdam", kind: "warehouse", lat: 51.9244, lng: 4.4777, status: "checked_in", who: "Shift A — 24 staff on-floor", city: "Rotterdam" }, + { key: "office_bangalore", label: "Office · Bangalore", kind: "office", lat: 12.9716, lng: 77.5946, status: "checked_in", who: "Rohan Patel (IT Director) — on-site", city: "Bangalore" }, + { key: "store_088_cardiff", label: "Store 088 · Cardiff", kind: "store", lat: 51.4816, lng: -3.1791, status: "checked_out", who: "Manager Daniel Owens — closed 21:14 yesterday", city: "Cardiff" }, +]; + +function reqId(tag) { + return `attendance-seed-${tag}-${Math.random().toString(36).slice(2, 10)}aaaaa`; +} + +async function devLogin(email) { + const r = await fetch(`${BASE}/api/v1/auth/dev-login`, { + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email }), + }); + if (!r.ok) throw new Error(`dev-login: ${r.status}`); + return (await r.json()).access_token; +} + +async function fetchEa2(token, method, path, body) { + const init = { method, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" } }; + if (body !== undefined) init.body = JSON.stringify(body); + const r = await fetch(`${BASE}${path}`, init); + return { ok: r.ok, status: r.status, json: r.ok ? await r.json().catch(() => null) : null, text: r.ok ? null : await r.text().catch(() => "") }; +} + +const token = await devLogin("hr-head@flow-master.ai"); +console.log("[attendance] signed in"); + +const anchorHead = await fetchEa2(token, "GET", `/api/ea2/flow/${ANCHOR}`); +if (!anchorHead.ok) { + const r = await fetchEa2(token, "POST", "/api/ea2/apply-batch", { + request_id: reqId("anchor"), + ops: [{ + op: "create", coll: "flow", + data: { + _key: ANCHOR, kind: "value", status: "published", + name: ANCHOR, display_name: "Attendance · root", + description: "Anchor doc for attendance sites", source_context: "CANVAS_ATTENDANCE_ROOT", + }, + }], + }); + if (!r.ok) throw new Error(`anchor create failed: ${r.status} ${r.text}`); + console.log("[attendance] anchor created"); +} else { + console.log("[attendance] anchor exists"); +} + +for (const s of SITES) { + const existing = await fetchEa2(token, "GET", `/api/ea2/flow/${s.key}`); + if (existing.ok) { console.log(` ${s.key}: exists`); continue; } + const make = await fetchEa2(token, "POST", "/api/ea2/apply-batch", { + request_id: reqId(s.key), + ops: [ + { + op: "create", coll: "flow", + data: { + _key: s.key, kind: "value", status: "published", + name: s.key, display_name: s.label, + description: `${s.kind} site at ${s.city}`, + source_context: SOURCE_CTX, + config: { attendance: { kind: s.kind, lat: s.lat, lng: s.lng, status: s.status, who: s.who, city: s.city, label: s.label } }, + }, + }, + { op: "create_edge", edge_coll: "defines", from: `flow/${ANCHOR}`, to: `flow/${s.key}`, role: "presentation" }, + ], + }); + if (!make.ok) { + console.log(` ${s.key}: create failed → ${make.status} ${make.text.slice(0, 200)}`); + } else { + console.log(` ${s.key}: created + linked`); + } +} + +console.log("\nattendance seed complete."); diff --git a/src/lib/attendanceApi.ts b/src/lib/attendanceApi.ts new file mode 100644 index 0000000..9fc75fa --- /dev/null +++ b/src/lib/attendanceApi.ts @@ -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 { + 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 { + 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 { + 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); + }, +}; diff --git a/src/scenes/GeoAttendance.tsx b/src/scenes/GeoAttendance.tsx index f4b1701..cd38aec 100644 --- a/src/scenes/GeoAttendance.tsx +++ b/src/scenes/GeoAttendance.tsx @@ -4,6 +4,7 @@ import L from "leaflet"; import "leaflet/dist/leaflet.css"; import { useApp } from "../state/store"; import { Pulse } from "../components/icons"; +import { attendanceApi, type AttendanceSite } from "../lib/attendanceApi"; L.Marker.prototype.options.icon = L.icon({ iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png", @@ -15,35 +16,13 @@ L.Marker.prototype.options.icon = L.icon({ shadowSize: [41, 41], }); -interface Site { - id: string; - label: string; - kind: "store" | "office" | "warehouse"; - lat: number; - lng: number; - status: "checked_in" | "checked_out" | "late"; - who: string; - city: string; -} - -const SITES: Site[] = [ - { id: "hq-london", label: "HQ · London", kind: "office", lat: 51.5074, lng: -0.1278, status: "checked_in", who: "Mariana Cole (CEO) — on-site", city: "London" }, - { id: "store-204", label: "Store 204 · Manchester", kind: "store", lat: 53.4808, lng: -2.2426, status: "checked_in", who: "Manager Priya Sahota — opened 08:02", city: "Manchester" }, - { id: "store-118", label: "Store 118 · Birmingham", kind: "store", lat: 52.4862, lng: -1.8904, status: "late", who: "Manager Tom Reilly — late check-in 09:24", city: "Birmingham" }, - { id: "store-052", label: "Store 052 · Edinburgh", kind: "store", lat: 55.9533, lng: -3.1883, status: "checked_in", who: "Manager Iona MacDonald — opened 07:58", city: "Edinburgh" }, - { id: "office-berlin", label: "Office · Berlin", kind: "office", lat: 52.52, lng: 13.405, status: "checked_in", who: "Aisha Khan (HR Director) — remote-on", city: "Berlin" }, - { id: "warehouse-rotterdam", label: "Warehouse · Rotterdam", kind: "warehouse", lat: 51.9244, lng: 4.4777, status: "checked_in", who: "Shift A — 24 staff on-floor", city: "Rotterdam" }, - { id: "office-bangalore", label: "Office · Bangalore", kind: "office", lat: 12.9716, lng: 77.5946, status: "checked_in", who: "Rohan Patel (IT Director) — on-site", city: "Bangalore" }, - { id: "store-088", label: "Store 088 · Cardiff", kind: "store", lat: 51.4816, lng: -3.1791, status: "checked_out", who: "Manager Daniel Owens — closed 21:14 yesterday", city: "Cardiff" }, -]; - -function statusTag(s: Site["status"]) { +function statusTag(s: AttendanceSite["status"]) { if (s === "checked_in") return "ON-SITE"; if (s === "late") return "LATE"; return "OFF"; } -function FitToSites({ sites }: { sites: Site[] }) { +function FitToSites({ sites }: { sites: AttendanceSite[] }) { const map = useMap(); useEffect(() => { if (!sites.length) return; @@ -55,9 +34,20 @@ function FitToSites({ sites }: { sites: Site[] }) { export default function GeoAttendance() { const setScene = useApp((s) => s.setScene); - const [filter, setFilter] = useState<"all" | Site["kind"]>("all"); - const visible = filter === "all" ? SITES : SITES.filter((s) => s.kind === filter); - const stats = SITES.reduce( + const pushToast = useApp((s) => s.pushToast); + const [filter, setFilter] = useState<"all" | AttendanceSite["kind"]>("all"); + const [sites, setSites] = useState(null); + useEffect(() => { + let cancelled = false; + attendanceApi + .listSites() + .then((rows) => !cancelled && setSites(rows)) + .catch((err) => !cancelled && pushToast("err", `Sites failed: ${err.message}`)); + return () => { cancelled = true; }; + }, []); + const allSites = sites || []; + const visible = filter === "all" ? allSites : allSites.filter((s) => s.kind === filter); + const stats = allSites.reduce( (acc, s) => ({ onSite: acc.onSite + (s.status === "checked_in" ? 1 : 0), late: acc.late + (s.status === "late" ? 1 : 0), @@ -69,11 +59,14 @@ export default function GeoAttendance() { return (
-
Attendance map · preview
+
Attendance map

Where your people are

- Preview of the manager attendance view. Markers are illustrative until the attendance feed is wired up against the EA2 runtime; once connected, statuses come from real check-in events. Tiles are served by OpenStreetMap. + Live sites and check-in statuses sourced from EA2. Add a site by inserting an attendance flow doc (kind=value, source_context=CANVAS_ATTENDANCE_SITE) and linking it to flow/attendance_root via a defines edge with role=presentation. Tiles are served by OpenStreetMap.

+ {sites !== null && allSites.length === 0 && ( +

No sites configured yet. Seed `qa/seeds/003_attendance_sites.mjs` to add the default canvas attendance roster.

+ )}
{stats.onSite} on-site {stats.late} late @@ -101,7 +94,7 @@ export default function GeoAttendance() { /> {visible.map((s) => ( - + {s.label}