mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 15:55:46 +01:00
## Thinking Path > - Paperclip helps people manage AI agents and their work. > - The inbox collects requests that need a human decision. > - Teams need to know who owns each request and who may act on it. > - A shared close operation must differ from hiding a card for one person. > - This pull request adds personal routing and durable outcomes to the existing attention service. > - People can claim or transfer work and inspect what another person did for them. > - Domain permissions still decide who may resolve each request. ## Linked Issues or Issue Description **Subsystem affected** Server, shared contracts, and database. This is the routing foundation for a stacked inbox UI change. **Problem or motivation** A company-wide attention feed does not give teams a clear personal queue. Archive, withdrawal, transfer, and decisions by another person need distinct scopes and records. **Proposed solution** Resolve a request audience from its addressee, task participants, and configured owners. Provide personal, unassigned, company, all-assignee, and archived views. Add claim, transfer, personal archive and snooze, shared withdrawal, receipts, and personal history. **Alternatives considered** A single shared inbox leaves ownership and personal hiding ambiguous. The service keeps one canonical request and projects its audience instead of copying requests for each person. **Roadmap alignment** This extends the existing Multiple Human Users and Agent Reviews and Approvals capabilities in ROADMAP.md. **Additional context** Refs #19363 and #19520 for the current inbox navigation and confirmation replacement rules. Refs #20268 for related actionable-request work. That PR filters actionability; this stack adds audience routing and personal outcomes. This is part 1 of a two-PR stack. The inbox UI PR #20961 targets this branch. ## What Changed - Add nullable company and project decision owners and a company budget owner. - Add audience views and personal badge counts to the canonical attention projections. - Add claim, transfer, withdrawal, personal archive, snooze, and restore operations with permission checks and activity records. Lock addressed transfers to the current addressee or routing administrators, and validate the recipient against canonical resolution policies. - Add receipts and scoped history for work handled by another person. - Keep closed-task requests and replaced confirmations out of current cards. - Add an idempotent migration and a concurrent receipt-history index. - Update typed fixtures so the existing UI remains compatible with the new contracts. ## Verification - Server layered typechecking passes, including all three test-tree partitions. - Seven routing and interaction-resolution suites pass all 249 tests against a dedicated external PostgreSQL test database on `4caecc21d8`. Six new regressions failed before the repair; they cover takeover followed by approval, legitimate delegation, excluded creators/review requesters, and unauthorized claims. - Prior focused verification covers private approval history and receipts, transfer visibility, audience escalation, withdrawal governance, hidden-card counts, per-recipient confirmation replacement, and SDK source contracts. - A synthetic three-person company passed 37 routing and lifecycle acceptance checks. The UI stack adds browser acceptance for the shared controls. - The complete [GitHub Actions CI matrix](https://github.com/paperclipai/paperclip/actions/runs/43134997331) passes on `4caecc21d891677323a7335f23b1a015cce24c13` (Linux/Node 24: typecheck, build, general and serialized tests, Runner support, browser shards, and aggregate gates). Greptile is 5/5 on that exact head with no open findings. The fresh security scan passes after explicitly rechecking the addressee-transfer repair. - `pnpm verify` reports exit 2 because a CI-global script changed; full CI supplies that broader coverage. Embedded PostgreSQL could not start on this Mac because shared-memory slots were exhausted, so local database checks use a separate external test database. Linux CI covers the embedded setup. ## Risks - Personal badge counts change to the viewer's assigned requests. Accessible feed totals and task membership keep their existing scope. - The migration adds nullable owner fields and a dismissal fingerprint. Its index runs outside a transaction. The migration is classified and rerunnable. - Routing changes the addressee used by resolution authorization. The current addressee or a routing administrator must authorize an addressed transfer; the recipient must satisfy writer access, canonical resolver exclusions, and task review policy under row locks. - The shared validator export barrel crosses 1,000 lines because it exports the new attention schemas. Schema implementation stays in the dedicated attention validator module. ## Model Used OpenAI GPT-6 through Codex. Used repository tools, code execution, and browser checks. This session does not expose a more specific runtime model ID or its context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal Paperclip ticket id - [x] I have run focused tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes (OpenAPI contracts and docs/api/attention.md) - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Scott Tong <scott@scottsmbpm5max.lan> Co-authored-by: Paperclip <noreply@paperclip.ing>
323 lines
24 KiB
TypeScript
323 lines
24 KiB
TypeScript
/**
|
|
* scripts/verify-needs-you-checklist.ts
|
|
*
|
|
* PAP-749 final check: walks the plan's section 7 "things to try" checklist,
|
|
* plus the task 1d escalation owners and the PAP-768 additions (P1 Everyone,
|
|
* P2 personal history, P3 one budget-stop card), through the real HTTP API as each demo
|
|
* user (Scott, Dotta, Riley). It signs in with the demo password, so it needs a
|
|
* running server in `authenticated` mode whose database holds a FRESH run of
|
|
* scripts/seed-needs-you-demo.ts (default seed options).
|
|
*
|
|
* It CHANGES the demo data (withdraw, transfer, claim, archive/restore,
|
|
* withdraw all stale, keep a budget stop paused). Re-run the seed afterwards
|
|
* to give a reviewer the untouched demo.
|
|
*
|
|
* BASE_URL=http://127.0.0.1:3200 \
|
|
* pnpm --filter @paperclipai/server exec tsx ../scripts/verify-needs-you-checklist.ts
|
|
*
|
|
* What it cannot prove: anything a browser shows (the live update without a
|
|
* reload, the confirmation dialogs, the classic page's layout). Those steps are
|
|
* reported as API-level checks only.
|
|
*/
|
|
import type { AttentionFeed, AttentionItem, AttentionReceipt, DecisionHistoryItem } from "../packages/shared/src/index.js";
|
|
|
|
const BASE_URL = (process.env.BASE_URL ?? "http://127.0.0.1:3200").replace(/\/$/, "");
|
|
const PASSWORD = process.env.SEED_DEMO_PASSWORD ?? "needs-you-demo-2026";
|
|
const DEMO_COMPANY_NAME = "Needs You Demo";
|
|
const USERS = {
|
|
scott: "scott@needs-you-demo.test",
|
|
dotta: "dotta@needs-you-demo.test",
|
|
riley: "riley@needs-you-demo.test",
|
|
} as const;
|
|
type UserKey = keyof typeof USERS;
|
|
|
|
type Session = { key: UserKey; userId: string; cookie: string };
|
|
type View = "mine" | "unassigned" | "company" | "archived" | "all";
|
|
|
|
const results: Array<{ step: string; ok: boolean; detail: string }> = [];
|
|
function check(step: string, ok: boolean, detail: string) {
|
|
results.push({ step, ok, detail });
|
|
console.log(`${ok ? "PASS" : "FAIL"} ${step} — ${detail}`);
|
|
}
|
|
|
|
async function http<T>(session: Session | null, method: string, path: string, body?: unknown): Promise<{ status: number; json: T }> {
|
|
const res = await fetch(`${BASE_URL}${path}`, {
|
|
method,
|
|
headers: {
|
|
Accept: "application/json",
|
|
Origin: BASE_URL,
|
|
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
|
|
...(session ? { Cookie: session.cookie } : {}),
|
|
},
|
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
});
|
|
const text = await res.text();
|
|
let json: unknown = null;
|
|
try { json = text ? JSON.parse(text) : null; } catch { json = text; }
|
|
return { status: res.status, json: json as T };
|
|
}
|
|
|
|
async function signIn(key: UserKey): Promise<Session> {
|
|
const res = await fetch(`${BASE_URL}/api/auth/sign-in/email`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", Accept: "application/json", Origin: BASE_URL },
|
|
body: JSON.stringify({ email: USERS[key], password: PASSWORD }),
|
|
});
|
|
if (!res.ok) throw new Error(`sign-in for ${key} failed: ${res.status} ${await res.text()}`);
|
|
const cookie = res.headers.getSetCookie().map((value) => value.split(";")[0]).join("; ");
|
|
const session = await http<{ user?: { id: string } }>({ key, userId: "", cookie }, "GET", "/api/auth/get-session");
|
|
const userId = session.json?.user?.id;
|
|
if (!userId) throw new Error(`no session user for ${key}`);
|
|
return { key, userId, cookie };
|
|
}
|
|
|
|
let companyId = "";
|
|
async function feed(session: Session, view: View): Promise<AttentionFeed> {
|
|
const res = await http<AttentionFeed>(session, "GET", `/api/companies/${companyId}/attention?all=true&view=${view}`);
|
|
if (res.status !== 200) throw new Error(`feed ${view} for ${session.key}: ${res.status} ${JSON.stringify(res.json)}`);
|
|
return res.json;
|
|
}
|
|
|
|
/** The cards a page shows: obligation evidence folds into its lead (task 2d). */
|
|
function leads(items: AttentionItem[]) {
|
|
return items.filter((item) => !item.obligationLeadId || item.obligationLeadId === item.id);
|
|
}
|
|
const has = (items: AttentionItem[], id: string) => items.some((item) => item.id === id);
|
|
const titleOf = (item: AttentionItem | undefined) => item?.subject.title ?? "(none)";
|
|
|
|
async function close(session: Session, item: AttentionItem, body: Record<string, unknown>) {
|
|
return http<unknown>(session, "POST", `/api/companies/${companyId}/attention/${item.sourceKind}/${item.subject.id}/close`, body);
|
|
}
|
|
|
|
async function main() {
|
|
console.log(`[verify] ${BASE_URL}`);
|
|
const scott = await signIn("scott");
|
|
const dotta = await signIn("dotta");
|
|
const riley = await signIn("riley");
|
|
const name = (userId: string | null | undefined) =>
|
|
[scott, dotta, riley].find((session) => session.userId === userId)?.key ?? userId ?? "nobody";
|
|
|
|
const companies = await http<Array<{ id: string; name: string }>>(scott, "GET", "/api/companies");
|
|
const company = companies.json.find((entry) => entry.name === DEMO_COMPANY_NAME);
|
|
if (!company) throw new Error("demo company not found; run the seed first");
|
|
companyId = company.id;
|
|
console.log(`[verify] company ${companyId}`);
|
|
|
|
// 1. Each For you list is different, and the badge agrees with the list.
|
|
const mine = { scott: await feed(scott, "mine"), dotta: await feed(dotta, "mine"), riley: await feed(riley, "mine") };
|
|
const scottIds = new Set(mine.scott.items.map((item) => item.id));
|
|
const overlap = mine.dotta.items.filter((item) => scottIds.has(item.id));
|
|
check("1 For you differs", mine.scott.items.length > 0 && mine.dotta.items.length > 0 && overlap.length === 0,
|
|
`Scott ${mine.scott.items.length} cards, Dotta ${mine.dotta.items.length}, shared ${overlap.length}; Riley ${mine.riley.items.length}`);
|
|
for (const [key, data] of Object.entries(mine)) {
|
|
const shown = leads(data.items).length;
|
|
check(`1 badge = list (${key})`, data.deskBadgeCount === data.viewCounts.mine && data.viewCounts.mine === shown,
|
|
`badge ${data.deskBadgeCount}, For you tab ${data.viewCounts.mine}, cards shown ${shown}`);
|
|
}
|
|
check("1 every For you card is the viewer's", (["scott", "dotta", "riley"] as const).every((key) =>
|
|
mine[key].items.every((item) => item.audience.userId === { scott, dotta, riley }[key].userId)), "audience.userId matches the viewer");
|
|
|
|
// Task 1d: escalation owners.
|
|
const hardStop = mine.scott.items.find((item) => item.sourceKind === "budget_alert");
|
|
check("1d budget owner card", hardStop?.audience.reason === "budget_owner" && hardStop.audience.alsoInCompany === true
|
|
&& hardStop.decisionVerbs.map((verb) => verb.label).join("/") === "Raise limit/Keep paused",
|
|
`Scott For you: "${titleOf(hardStop)}" (${hardStop?.audience.reason}; ${hardStop?.decisionVerbs.map((verb) => verb.label).join(" / ")})`);
|
|
const companyViews = { scott: await feed(scott, "company"), dotta: await feed(dotta, "company"), riley: await feed(riley, "company") };
|
|
check("1d Company still shows the hard stop", Boolean(hardStop) && Object.values(companyViews).every((data) => has(data.items, hardStop!.id)),
|
|
"in Company for Scott, Dotta and Riley (as For @scott for the other two)");
|
|
check("1d hard stop only in the owner's For you", Boolean(hardStop) && !has(mine.dotta.items, hardStop!.id) && !has(mine.riley.items, hardStop!.id),
|
|
"not in Dotta's or Riley's For you");
|
|
const warning = companyViews.scott.items.find((item) => item.sourceKind === "budget_alert" && item.subject.metadata?.thresholdType === "soft");
|
|
check("1d budget warning stays Company only", warning?.audience.kind === "company", `"${titleOf(warning)}" audience ${warning?.audience.kind}`);
|
|
// PAP-768 part 3: the hard stop and its override approval are one card. The
|
|
// stop leads; the approval is its evidence and is no longer an ownerless
|
|
// card in Unassigned.
|
|
const overrideApprovalId = (hardStop?.subject.metadata as { approvalId?: string | null } | null)?.approvalId ?? null;
|
|
const overrideEvidence = mine.scott.items.find((item) => item.sourceKind === "approval" && item.subject.id === overrideApprovalId);
|
|
check("P3 one card for the budget stop", Boolean(hardStop && overrideEvidence) && overrideEvidence!.obligationLeadId === hardStop!.id
|
|
&& has(leads(mine.scott.items), hardStop!.id) && !has(leads(mine.scott.items), overrideEvidence!.id),
|
|
`Scott For you: "${titleOf(hardStop)}" leads; "${titleOf(overrideEvidence)}" folded in (lead ${overrideEvidence?.obligationLeadId === hardStop?.id ? "the stop" : overrideEvidence?.obligationLeadId ?? "none"})`);
|
|
const pools = { scott: await feed(scott, "unassigned"), dotta: await feed(dotta, "unassigned"), riley: await feed(riley, "unassigned") };
|
|
check("P3 override approval not in Unassigned", Boolean(overrideApprovalId)
|
|
&& Object.values(pools).every((data) => !data.items.some((item) => item.subject.id === overrideApprovalId)),
|
|
`not in Scott's, Dotta's or Riley's Unassigned (${Object.values(pools).map((data) => data.viewCounts.unassigned).join("/")} cards)`);
|
|
check("P3 the stop counts once", Object.values(companyViews).every((data) => data.items.filter((item) => item.subject.id === overrideApprovalId)
|
|
.every((item) => item.obligationLeadId === hardStop?.id)) && mine.scott.viewCounts.mine === leads(mine.scott.items).length,
|
|
"in Company the approval is evidence of the stop; For you counts the stop once");
|
|
|
|
const projectCard = mine.dotta.items.find((item) => item.audience.reason === "project_owner");
|
|
check("1d project decision owner", Boolean(projectCard), `Dotta For you: "${titleOf(projectCard)}" (project_owner)`);
|
|
|
|
// PAP-768 part 1: Everyone (view=all) for board members, with its own count.
|
|
const scottAll = await feed(scott, "all");
|
|
const access = await http<{ memberships?: Array<{ companyId: string; membershipRole: string | null; status: string }> }>(scott, "GET", "/api/cli-auth/me");
|
|
const scottMembership = access.json?.memberships?.find((entry) => entry.companyId === companyId && entry.status === "active");
|
|
check("P1 Scott is a board member who sees Everyone", Boolean(scottMembership?.membershipRole && scottMembership.membershipRole !== "viewer"),
|
|
`role ${scottMembership?.membershipRole ?? "none"}`);
|
|
check("P1 Everyone count is its own, not the badge", scottAll.viewCounts.all === leads(scottAll.items).length
|
|
&& scottAll.deskBadgeCount === scottAll.viewCounts.mine && scottAll.viewCounts.all > scottAll.viewCounts.mine,
|
|
`Everyone tab ${scottAll.viewCounts.all}, cards ${leads(scottAll.items).length}, badge ${scottAll.deskBadgeCount} (For you ${scottAll.viewCounts.mine})`);
|
|
const othersInEveryone = leads(scottAll.items).filter((item) => item.audience.kind === "user" && item.audience.userId && item.audience.userId !== scott.userId);
|
|
check("P1 Everyone shows other people's cards", othersInEveryone.some((item) => item.audience.userId === dotta.userId),
|
|
`${othersInEveryone.length} cards for others (${[...new Set(othersInEveryone.map((item) => name(item.audience.userId)))].join(", ")}); the UI shows each as "For @name"`);
|
|
|
|
// 2. As Scott, withdraw a card for Dotta from Everyone; Dotta gets the
|
|
// receipt. Cards addressed to Dotta stay out of Scott's feed (the
|
|
// pre-PAP-749 addressee filter), so these are her instigator and
|
|
// project-owner cards; the project-owner card goes last so the task 1d demo
|
|
// card survives step 2.
|
|
const dottaCards = scottAll.items
|
|
.filter((item) => item.audience.userId === dotta.userId
|
|
&& item.sourceKind === "issue_thread_interaction" && item.closeModes.includes("withdraw") && !item.stale)
|
|
.sort((left, right) => Number(left.audience.reason === "project_owner") - Number(right.audience.reason === "project_owner"));
|
|
const toWithdraw = dottaCards[0];
|
|
if (!toWithdraw) throw new Error("no withdrawable card for Dotta");
|
|
const withdraw = await close(scott, toWithdraw, { mode: "withdraw", reason: "Out of date (checklist step 2)" });
|
|
const dottaAfterWithdraw = await feed(dotta, "mine");
|
|
const withdrawReceipt = (dottaAfterWithdraw.receipts ?? []).find((receipt: AttentionReceipt) => receipt.sourceId === toWithdraw.subject.id);
|
|
check("2 withdraw for Dotta (from Everyone)", withdraw.status === 200 && !has(dottaAfterWithdraw.items, toWithdraw.id)
|
|
&& withdrawReceipt?.outcome === "withdrawn" && withdrawReceipt.actorUserId === scott.userId,
|
|
`"${titleOf(toWithdraw)}" -> ${withdraw.status}; Dotta receipt: ${withdrawReceipt ? `${withdrawReceipt.outcome} by ${name(withdrawReceipt.actorUserId)} (reason: ${withdrawReceipt.reason})` : "none"}`);
|
|
|
|
// 3. As Scott, transfer a card to Riley; it shows in Riley's For you.
|
|
const toTransfer = dottaCards.find((item) => item.id !== toWithdraw.id);
|
|
if (!toTransfer) throw new Error("no second card for Dotta to transfer");
|
|
const transfer = await http<unknown>(scott, "POST", `/api/companies/${companyId}/attention/${toTransfer.sourceKind}/${toTransfer.subject.id}/transfer`, {
|
|
userId: riley.userId, reason: "Riley owns this now (checklist step 3)",
|
|
});
|
|
const rileyAfter = await feed(riley, "mine");
|
|
const transferred = rileyAfter.items.find((item) => item.subject.id === toTransfer.subject.id);
|
|
const dottaAfterTransfer = await feed(dotta, "mine");
|
|
const transferReceipt = (dottaAfterTransfer.receipts ?? []).find((receipt: AttentionReceipt) => receipt.sourceId === toTransfer.subject.id);
|
|
check("3 transfer to Riley", transfer.status === 200 && transferred?.audience.transferredByUserId === scott.userId
|
|
&& !has(dottaAfterTransfer.items, toTransfer.id) && transferReceipt?.outcome === "transferred",
|
|
`"${titleOf(toTransfer)}" -> ${transfer.status}; Riley For you: ${transferred ? `yes, "Transferred by @${name(transferred.audience.transferredByUserId)}"` : "no"}; Dotta receipt: ${transferReceipt?.outcome ?? "none"}`);
|
|
|
|
// 4. As Scott, claim an Unassigned card; it leaves Dotta's Unassigned.
|
|
const scottPool = await feed(scott, "unassigned");
|
|
const toClaim = scottPool.items.find((item) => item.sourceKind === "issue_thread_interaction");
|
|
if (!toClaim) throw new Error("no claimable Unassigned card");
|
|
const dottaPoolBefore = await feed(dotta, "unassigned");
|
|
const claim = await http<unknown>(scott, "POST", `/api/companies/${companyId}/attention/${toClaim.sourceKind}/${toClaim.subject.id}/claim`, {});
|
|
const dottaPoolAfter = await feed(dotta, "unassigned");
|
|
const scottMineAfterClaim = await feed(scott, "mine");
|
|
check("4 claim from Unassigned", claim.status === 200 && has(dottaPoolBefore.items, toClaim.id) && !has(dottaPoolAfter.items, toClaim.id)
|
|
&& has(scottMineAfterClaim.items, toClaim.id),
|
|
`"${titleOf(toClaim)}" -> ${claim.status}; Dotta Unassigned ${dottaPoolBefore.viewCounts.unassigned} -> ${dottaPoolAfter.viewCounts.unassigned}; now in Scott's For you`);
|
|
|
|
// 5. Archive a card; it shows in Archived; Restore works; Dotta still sees it.
|
|
const scottCompany = await feed(scott, "company");
|
|
const toArchive = scottCompany.items.find((item) => item.sourceKind === "agent_error_alert") ?? scottCompany.items[0];
|
|
if (!toArchive) throw new Error("no Company card to archive");
|
|
const archive = await close(scott, toArchive, { mode: "archive", fingerprint: toArchive.changeFingerprint });
|
|
const scottArchived = await feed(scott, "archived");
|
|
const scottCompanyAfter = await feed(scott, "company");
|
|
const dottaCompanyAfter = await feed(dotta, "company");
|
|
check("5 archive for me", archive.status === 200 && has(scottArchived.items, toArchive.id) && !has(scottCompanyAfter.items, toArchive.id)
|
|
&& has(dottaCompanyAfter.items, toArchive.id),
|
|
`"${titleOf(toArchive)}" -> ${archive.status}; in Scott's Archived (tab ${scottArchived.viewCounts.archived}); Dotta still sees it`);
|
|
const restore = await http<unknown>(scott, "DELETE", `/api/companies/${companyId}/inbox-dismissals/${encodeURIComponent(toArchive.dismissalKey)}`);
|
|
const scottCompanyRestored = await feed(scott, "company");
|
|
const scottArchivedRestored = await feed(scott, "archived");
|
|
check("5 restore", restore.status >= 200 && restore.status < 300 && has(scottCompanyRestored.items, toArchive.id) && !has(scottArchivedRestored.items, toArchive.id),
|
|
`restore -> ${restore.status}; back in Scott's Company view`);
|
|
|
|
// 6. Withdraw all stale: only non-required cards with a real withdraw close.
|
|
const scottMine = await feed(scott, "mine");
|
|
const stale = leads(scottMine.items).filter((item) => item.stale);
|
|
const withdrawable = stale.filter((item) => item.closeModes.includes("withdraw") && !item.required);
|
|
const kept = stale.filter((item) => !withdrawable.includes(item));
|
|
const statuses: number[] = [];
|
|
for (const item of withdrawable) statuses.push((await close(scott, item, { mode: "withdraw", reason: "Withdraw all stale (checklist step 6)" })).status);
|
|
const scottMineAfterStale = await feed(scott, "mine");
|
|
check("6 withdraw all stale", stale.length > 0 && statuses.every((status) => status === 200)
|
|
&& withdrawable.every((item) => !has(scottMineAfterStale.items, item.id)) && kept.every((item) => has(scottMineAfterStale.items, item.id)),
|
|
`Stale (${stale.length}): withdrew ${withdrawable.length} [${statuses.join(",")}], kept ${kept.length} (${kept.map((item) => item.required ? "required" : "no withdraw").join(", ") || "none"})`);
|
|
|
|
// 7. The stale approval is in Company with "Waiting 30+ days", still pending.
|
|
const companyNow = await feed(dotta, "company");
|
|
const staleApproval = companyNow.items.find((item) => item.sourceKind === "approval" && item.stale?.reason === "aged");
|
|
check("7 stale approval escalated, not withdrawn", staleApproval?.subject.status === "pending" && staleApproval.required === true,
|
|
`"${titleOf(staleApproval)}" in Company, stale ${staleApproval?.stale?.reason}, status ${staleApproval?.subject.status}`);
|
|
|
|
// 8. Decision history filters.
|
|
type History = { items: Array<Pick<DecisionHistoryItem, "sourceKind" | "sourceId" | "label" | "reason" | "action" | "resolvedByUserId" | "handledForUserId">> };
|
|
const history = async (session: Session, query: string) =>
|
|
(await http<History>(session, "GET", `/api/companies/${companyId}/attention/history?limit=100&${query}`)).json.items;
|
|
const withdrawn = await history(scott, "outcome=withdrawn");
|
|
const expired = await history(scott, "outcome=expired");
|
|
const closedWithTask = await history(scott, "outcome=issue_closed");
|
|
const handledForDotta = await history(dotta, "handledForMe=true");
|
|
check("8 history Withdrawn", withdrawn.some((item) => item.sourceId === toWithdraw.subject.id), `${withdrawn.length} entries, includes step 2`);
|
|
check("8 history Expired", expired.length > 0, `${expired.length} entries`);
|
|
check("8 history Closed with task", closedWithTask.length > 0, `${closedWithTask.length} entries`);
|
|
check("8 history Handled for me (Dotta)", handledForDotta.some((item) => item.sourceId === toWithdraw.subject.id), `${handledForDotta.length} entries, includes step 2`);
|
|
|
|
// PAP-768 part 2: history opens on "Handled by me or for me" (scope=mine,
|
|
// what both pages ask for first); Everyone (scope=all) is every person's.
|
|
// The board's case: Scott answers the confirmation on "Scott: task you
|
|
// created"; it must stay out of Dotta's default history.
|
|
const ownTaskCard = (await feed(scott, "mine")).items.find((item) => item.sourceKind === "issue_thread_interaction"
|
|
&& item.relatedIssue?.title === "Scott: task you created");
|
|
if (!ownTaskCard?.relatedIssue) throw new Error("no confirmation on \"Scott: task you created\" in Scott's For you");
|
|
const accept = await http<unknown>(scott, "POST", `/api/issues/${ownTaskCard.relatedIssue.id}/interactions/${ownTaskCard.subject.id}/accept`, {});
|
|
const ownTaskRow = (items: History["items"]) => items.some((item) => item.sourceId === ownTaskCard.subject.id);
|
|
const scottMineHistory = await history(scott, "scope=mine");
|
|
const dottaMineHistory = await history(dotta, "scope=mine");
|
|
const dottaEveryoneHistory = await history(dotta, "scope=all");
|
|
check("P2 Scott's own answer is in his history", accept.status === 200 && ownTaskRow(scottMineHistory),
|
|
`accept "${ownTaskCard.relatedIssue.identifier} · ${ownTaskCard.relatedIssue.title}" -> ${accept.status}; in Scott's Handled by me or for me`);
|
|
check("P2 Dotta's default history leaves it out", !ownTaskRow(dottaMineHistory),
|
|
`Dotta Handled by me or for me: ${dottaMineHistory.length} entries, none on ${ownTaskCard.relatedIssue.identifier}`);
|
|
check("P2 Dotta's Everyone history shows it", ownTaskRow(dottaEveryoneHistory),
|
|
`Dotta Everyone: ${dottaEveryoneHistory.length} entries, includes ${ownTaskCard.relatedIssue.identifier} · Scott: task you created`);
|
|
check("P2 Dotta's history is hers", dottaMineHistory.length > 0 && dottaMineHistory.length < dottaEveryoneHistory.length,
|
|
`${dottaMineHistory.length} of ${dottaEveryoneHistory.length} entries`);
|
|
const dottaMineWithdrawn = await history(dotta, "scope=mine&outcome=withdrawn");
|
|
const dottaMineHandled = await history(dotta, "scope=mine&handledForMe=true");
|
|
const scottMineExpired = await history(scott, "scope=mine&outcome=expired");
|
|
check("P2 outcome filters work inside each scope", dottaMineWithdrawn.some((item) => item.sourceId === toWithdraw.subject.id)
|
|
&& dottaMineHandled.some((item) => item.sourceId === toWithdraw.subject.id) && scottMineExpired.length > 0,
|
|
`Dotta mine + Withdrawn ${dottaMineWithdrawn.length} (step 2 withdraw for her), mine + Handled for me ${dottaMineHandled.length}; Scott mine + Expired ${scottMineExpired.length}`);
|
|
|
|
// 9. The classic Decisions page reads the same per-person feed (`all=true&view=`).
|
|
const classic = await feed(dotta, "mine");
|
|
check("9 classic page feed (API only)", classic.deskBadgeCount === classic.viewCounts.mine,
|
|
"same endpoint and query as Needs You; its layout needs a browser");
|
|
|
|
// Task 1d: Dotta keeps Scott's hard stop paused; Scott gets the receipt.
|
|
if (hardStop) {
|
|
const resolve = await http<unknown>(dotta, "POST", `/api/companies/${companyId}/budget-incidents/${hardStop.subject.id}/resolve`, { action: "keep_paused" });
|
|
const scottAfterBudget = await feed(scott, "mine");
|
|
const budgetReceipt = (scottAfterBudget.receipts ?? []).find((receipt: AttentionReceipt) => receipt.sourceId === hardStop.subject.id);
|
|
const companyAfterBudget = await feed(riley, "company");
|
|
check("1d receipt for the budget owner", resolve.status === 200 && budgetReceipt?.actorUserId === dotta.userId
|
|
&& budgetReceipt.outcome === "kept_paused"
|
|
&& !has(scottAfterBudget.items, hardStop.id) && !has(companyAfterBudget.items, hardStop.id),
|
|
`Keep paused by Dotta -> ${resolve.status}; Scott receipt: ${budgetReceipt ? `${budgetReceipt.outcome} by ${name(budgetReceipt.actorUserId)} -> ${budgetReceipt.href}` : "none"}`);
|
|
// PAP-767: the same action is in Scott's "Handled for me" history as the
|
|
// hard stop's approval, kept paused by Dotta for Scott.
|
|
const approvalId = overrideApprovalId;
|
|
const overrideAfter = approvalId ? await http<{ status?: string; decidedByUserId?: string | null }>(scott, "GET", `/api/approvals/${approvalId}`) : null;
|
|
check("P3 Keep paused rejects the folded approval", overrideAfter?.json?.status === "rejected" && overrideAfter.json.decidedByUserId === dotta.userId,
|
|
`approval ${overrideAfter?.json?.status ?? "missing"} by ${name(overrideAfter?.json?.decidedByUserId)}`);
|
|
const scottHandled = await history(scott, "handledForMe=true");
|
|
const keptPaused = scottHandled.find((item) => item.sourceKind === "approval" && item.sourceId === approvalId);
|
|
check("1d history: kept paused by Dotta for Scott", Boolean(approvalId) && keptPaused?.action?.outcome === "kept_paused"
|
|
&& keptPaused.resolvedByUserId === dotta.userId && keptPaused.handledForUserId === scott.userId,
|
|
keptPaused ? `${keptPaused.action?.outcome} by ${name(keptPaused.resolvedByUserId)} for ${name(keptPaused.handledForUserId)}` : `not in Scott's Handled for me (${scottHandled.length} entries)`);
|
|
// The same row in both people's own history (PAP-768 parts 2 and 3).
|
|
const inOwnHistory = async (session: Session) => (await history(session, "scope=mine")).find((item) => item.sourceId === approvalId);
|
|
const [scottOwn, dottaOwn, rileyOwn] = [await inOwnHistory(scott), await inOwnHistory(dotta), await inOwnHistory(riley)];
|
|
check("P3 history: Kept paused by @Dotta · for @Scott in both histories", scottOwn?.action?.outcome === "kept_paused" && dottaOwn?.handledForUserId === scott.userId
|
|
&& !rileyOwn,
|
|
`Scott ${scottOwn ? "yes" : "no"}, Dotta ${dottaOwn ? "yes" : "no"}, Riley ${rileyOwn ? "yes" : "no"}`);
|
|
}
|
|
|
|
const failed = results.filter((result) => !result.ok);
|
|
console.log(`\n[verify] ${results.length - failed.length}/${results.length} checks passed`);
|
|
process.exit(failed.length > 0 ? 1 : 0);
|
|
}
|
|
|
|
await main();
|