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,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-<utc-iso>`.
|
||||
|
||||
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 || "?"}`);
|
||||
Reference in New Issue
Block a user