From 788a63137e92695636fbca3b131ec6e82a76bd44 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 1 Nov 2026 21:06:52 -0800 Subject: [PATCH] feat(hub): share approval packages and record reviewer decisions # Conflicts: # apps/hub/src/db/migrations/meta/0036_snapshot.json # apps/hub/src/db/migrations/meta/_journal.json # apps/hub/src/db/schema.ts --- .../hub/src/artifacts/approval-rounds.test.ts | 743 +++++++++ apps/hub/src/artifacts/approval-rounds.ts | 1323 +++++++++++++++++ apps/hub/src/artifacts/routes.ts | 2 + apps/hub/src/artifacts/service.ts | 8 + apps/hub/src/config/approval-rounds.ts | 87 ++ .../src/db/migrations/0037_approval_round.sql | 21 + .../src/db/migrations/meta/0037_snapshot.json | 1309 ++++++++++++++++ apps/hub/src/db/migrations/meta/_journal.json | 7 + apps/hub/src/db/schema.ts | 37 + apps/web/src/api/artifacts.ts | 99 +- apps/web/src/config.ts | 119 ++ apps/web/src/pages/settings.tsx | 198 ++- .../pages/workspace/approval-rounds.test.tsx | 116 ++ .../src/pages/workspace/approval-rounds.tsx | 1104 ++++++++++++++ .../pages/workspace/artifact-pane.test.tsx | 19 +- .../web/src/pages/workspace/artifact-pane.tsx | 25 +- .../src/pages/workspace/deliverable-block.tsx | 27 +- apps/web/src/styles.css | 300 +++- apps/web/src/workspace-approvals-setting.ts | 299 ++++ packages/client/src/client/artifacts.ts | 96 ++ packages/client/src/client/shared.ts | 60 + packages/client/src/index.ts | 20 + packages/schemas/package.json | 3 +- packages/schemas/src/approval-round.ts | 134 ++ 24 files changed, 6118 insertions(+), 38 deletions(-) create mode 100644 apps/hub/src/artifacts/approval-rounds.test.ts create mode 100644 apps/hub/src/artifacts/approval-rounds.ts create mode 100644 apps/hub/src/config/approval-rounds.ts create mode 100644 apps/hub/src/db/migrations/0037_approval_round.sql create mode 100644 apps/hub/src/db/migrations/meta/0037_snapshot.json create mode 100644 apps/web/src/pages/workspace/approval-rounds.test.tsx create mode 100644 apps/web/src/pages/workspace/approval-rounds.tsx create mode 100644 apps/web/src/workspace-approvals-setting.ts create mode 100644 packages/schemas/src/approval-round.ts diff --git a/apps/hub/src/artifacts/approval-rounds.test.ts b/apps/hub/src/artifacts/approval-rounds.test.ts new file mode 100644 index 00000000..dadac7d6 --- /dev/null +++ b/apps/hub/src/artifacts/approval-rounds.test.ts @@ -0,0 +1,743 @@ +import { describe, expect, test } from "bun:test"; +import { Hono } from "hono"; +import type { ContentfulStatusCode } from "hono/utils/http-status"; +import { unzip } from "@corbits/zip"; +import { base64Encode } from "@intx/types"; +import type { TenantEnv } from "@intx/hub-api"; +import { + APPROVAL_PACKAGE_KIND, + createApprovalRoundService, + mountApprovalRoundRoutes, + type ApprovalRoundStore, + type StoredApprovalRound, + type StoredPackageVersion, +} from "./approval-rounds.ts"; +import { DEFAULT_WORKSPACE_REVIEWERS } from "../config/approval-rounds.ts"; +import type { WorkspaceReviewer } from "@corbits-builder/schemas/approval-round"; +import { ProjectError } from "../projects/errors.ts"; +import type { Actor } from "../lib/actor.ts"; + +const OWNER: Actor = { + tenantId: "tenant_proj_1", + principalId: "usr_owner", +}; + +const AGENT_CALLER: Actor = { + tenantId: "tenant_proj_1", + principalId: "agt_coordinator", +}; + +const PPTX_BYTES = new TextEncoder().encode("PK-pptx-binary-fixture"); +const PDF_BYTES = new TextEncoder().encode("%PDF-1.7-binary-fixture"); + +function makePackageFixture( + overrides: Partial = {}, +): StoredPackageVersion { + const content = JSON.stringify({ + manifest: { + audience: "Executive committee", + decisionNeeded: "Approve Q3 rollout budget", + tone: "concise", + length: "short", + sources: [ + { + artifactId: "art_brief_1", + kind: "brief", + title: "Product Brief", + version: 2, + sha256: "a".repeat(64), + }, + { + artifactId: "art_req_1", + kind: "requirements", + title: "Requirements", + version: 1, + sha256: "b".repeat(64), + }, + ], + }, + deck: { + theme: {}, + slides: [{ layout: "title", title: "Executive Approval Package" }], + }, + pptxBase64: base64Encode(PPTX_BYTES), + pdfBase64: `data:application/pdf;base64,${base64Encode(PDF_BYTES)}`, + }); + return { + projectTitle: "Acme Portal", + artifactId: "art_pkg_1", + version: 1, + latestVersion: 1, + sha256: "c".repeat(64), + title: "Executive Approval Package", + content, + projectDocuments: [ + { + kind: "brief", + title: "Product Brief", + version: 2, + sha256: "a".repeat(64), + content: "# Product Brief\n\nApproved brief content.", + }, + { + kind: "requirements", + title: "Requirements", + version: 1, + sha256: "b".repeat(64), + content: "# Requirements\n\nApproved requirements content.", + }, + ], + ...overrides, + }; +} + +function createFakeStore(options?: { + initialNow?: Date; + pkg?: StoredPackageVersion; + nonPersonIds?: readonly string[]; + coordinatorRunId?: string; +}) { + let currentNow = options?.initialNow ?? new Date("2026-04-01T12:00:00.000Z"); + let pkg = options?.pkg ?? makePackageFixture(); + const coordinatorRunId = options?.coordinatorRunId ?? "wfr_coord_1"; + const nonPerson = new Set( + options?.nonPersonIds ?? [AGENT_CALLER.principalId], + ); + const approvals: Array<{ + actor: Actor; + projectId: string; + artifactId: string; + version: number; + sha256: string; + }> = []; + const rounds = new Map(); + const workspaceReviewers: WorkspaceReviewer[] = [ + ...DEFAULT_WORKSPACE_REVIEWERS, + ]; + + const store: ApprovalRoundStore = { + now: () => currentNow, + async authorize(actor, _projectId, _action, requirePerson) { + if (requirePerson && nonPerson.has(actor.principalId)) { + throw new ProjectError(403, "approval_round_person_only"); + } + }, + async readPackage(_actor, _projectId, version) { + if (version !== pkg.version) { + throw new ProjectError(404, "package_not_found", { version }); + } + return pkg; + }, + async recordPackageApproval(actor, projectId, ref) { + approvals.push({ + actor, + projectId, + artifactId: ref.artifactId, + version: ref.version, + sha256: ref.sha256, + }); + }, + async insertRound(round) { + rounds.set(round.roundId, structuredClone(round)); + }, + async listRounds(tenantId, projectId) { + return [...rounds.values()].filter( + (item) => item.tenantId === tenantId && item.projectId === projectId, + ); + }, + async readRound(tenantId, projectId, roundId) { + const found = rounds.get(roundId); + if ( + !found || + found.tenantId !== tenantId || + found.projectId !== projectId + ) { + return undefined; + } + return structuredClone(found); + }, + async updateRound(round) { + rounds.set(round.roundId, structuredClone(round)); + }, + async activeCoordinatorRunId() { + return coordinatorRunId; + }, + async listWorkspaceReviewers() { + return [...workspaceReviewers]; + }, + async saveWorkspaceReviewers(_tenantId, entries) { + for (const item of entries) { + const idx = workspaceReviewers.findIndex( + (entry) => entry.name.toLowerCase() === item.name.toLowerCase(), + ); + const next: WorkspaceReviewer = { + id: `ws-rev-${item.name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`, + name: item.name, + persona: item.persona, + ...(item.email === undefined ? {} : { email: item.email }), + }; + if (idx >= 0) { + workspaceReviewers[idx] = next; + } else { + workspaceReviewers.push(next); + } + } + return [...workspaceReviewers]; + }, + }; + + return { + store, + approvals, + rounds, + advanceTime(ms: number) { + currentNow = new Date(currentNow.getTime() + ms); + }, + setPackage(next: StoredPackageVersion) { + pkg = next; + }, + }; +} + +function createTestApp( + service: ReturnType, + actor: Actor = OWNER, + nativeFetch?: (request: Request) => Promise, +) { + const app = new Hono(); + app.use("*", async (c, next) => { + c.set("tenant", { id: actor.tenantId } as never); + c.set("principal", { id: actor.principalId } as never); + await next(); + }); + app.onError((err, c) => { + if (err instanceof ProjectError) { + return c.json( + { error: err.code, ...err.details }, + err.status as ContentfulStatusCode, + ); + } + throw err; + }); + mountApprovalRoundRoutes(app, service, nativeFetch); + return app; +} + +describe("approval package zip and approval rounds", () => { + test("GET /:projectId/packages/:version/zip packs deck.pptx, deck.pdf, README.md, manifest.json, and grounded documents deterministically", async () => { + const { store } = createFakeStore(); + const service = createApprovalRoundService(store); + const app = createTestApp(service); + + const res1 = await app.request("/proj_1/packages/1/zip"); + expect(res1.status).toBe(200); + expect(res1.headers.get("content-type")).toBe("application/zip"); + expect(res1.headers.get("content-disposition")).toContain( + "Acme-Portal-package-v1.zip", + ); + + const bytes1 = new Uint8Array(await res1.arrayBuffer()); + const res2 = await app.request("/proj_1/packages/1/zip"); + const bytes2 = new Uint8Array(await res2.arrayBuffer()); + expect(bytes1).toEqual(bytes2); + + const unzipped = await unzip(bytes1); + const entries = new Map(unzipped.map((entry) => [entry.path, entry.data])); + expect([...entries.keys()].sort()).toEqual([ + "README.md", + "deck.pdf", + "deck.pptx", + "documents/brief.md", + "documents/requirements.md", + "manifest.json", + ]); + expect(entries.get("deck.pptx")).toEqual(PPTX_BYTES); + expect(entries.get("deck.pdf")).toEqual(PDF_BYTES); + + const readme = new TextDecoder().decode(entries.get("README.md")); + expect(readme).toContain("# Acme Portal — Executive Approval Package (v1)"); + expect(readme).toContain("Executive committee"); + expect(readme).toContain("Approve Q3 rollout budget"); + expect(readme).toContain("Product Brief (brief): v2"); + + const manifest = JSON.parse( + new TextDecoder().decode(entries.get("manifest.json")), + ) as { version: number; sha256: string; sources: unknown[] }; + expect(manifest.version).toBe(1); + expect(manifest.sha256).toBe("c".repeat(64)); + expect(manifest.sources).toHaveLength(2); + }); + + test("personalizes the .zip filename, README.md, and manifest.json when a reviewer is specified", async () => { + const { store } = createFakeStore(); + const service = createApprovalRoundService(store); + const app = createTestApp(service); + + const created = await service.start(OWNER, "proj_1", { + packageVersion: 1, + expiry: "7d", + reviewers: [{ name: "Dana Chen", persona: "CEO" }], + }); + const reviewer = created.reviewers[0]!; + + const res = await app.request( + `/proj_1/packages/1/zip?roundId=${encodeURIComponent(created.roundId)}&reviewerId=${encodeURIComponent(reviewer.id)}`, + ); + expect(res.status).toBe(200); + expect(res.headers.get("content-disposition")).toContain( + "acme-portal-approval-package-for-dana-chen-ceo.zip", + ); + + const unzipped = await unzip(new Uint8Array(await res.arrayBuffer())); + const entries = new Map( + unzipped.map((entry) => [ + entry.path, + new TextDecoder().decode(entry.data), + ]), + ); + expect(entries.get("README.md")).toContain("Prepared for: Dana Chen (CEO)"); + const manifest = JSON.parse(entries.get("manifest.json")!) as { + reviewer?: { name: string; persona: string }; + }; + expect(manifest.reviewer).toEqual({ name: "Dana Chen", persona: "CEO" }); + }); + + test("packs the pinned version of an upstream document after a newer version is filed and extracts DESIGN.md from a design bundle", async () => { + const designBundleJson = JSON.stringify({ + files: { + "DESIGN.md": + "# Acme Design Plan\n\nClean grid and accessible contrast.", + "designs/index.html": "Mock", + }, + }); + const content = JSON.stringify({ + manifest: { + audience: "Executive committee", + decisionNeeded: "Approve Q3 rollout budget", + tone: "concise", + length: "short", + sources: [ + { + artifactId: "art_brief_1", + kind: "problem_brief", + title: "Problem Brief", + version: 1, + sha256: "1".repeat(64), + }, + { + artifactId: "art_design_1", + kind: "design", + title: "Design Plan", + version: 1, + sha256: "2".repeat(64), + }, + ], + }, + deck: { + theme: {}, + slides: [{ layout: "title", title: "Executive Approval Package" }], + }, + pptxBase64: base64Encode(PPTX_BYTES), + pdfBase64: `data:application/pdf;base64,${base64Encode(PDF_BYTES)}`, + }); + const { store } = createFakeStore({ + pkg: makePackageFixture({ + content, + projectDocuments: [ + { + kind: "problem_brief", + title: "Problem Brief", + version: 1, + sha256: "1".repeat(64), + content: "# Problem Brief v1\n\nOriginal pinned scope.", + }, + { + kind: "problem_brief", + title: "Problem Brief", + version: 2, + sha256: "9".repeat(64), + content: "# Problem Brief v2\n\nLater unpinned revision.", + }, + { + kind: "design", + title: "Design Plan", + version: 1, + sha256: "2".repeat(64), + content: designBundleJson, + }, + ], + }), + }); + const service = createApprovalRoundService(store); + const app = createTestApp(service); + + const res = await app.request("/proj_1/packages/1/zip"); + expect(res.status).toBe(200); + const unzipped = await unzip(new Uint8Array(await res.arrayBuffer())); + const entries = new Map( + unzipped.map((entry) => [ + entry.path, + new TextDecoder().decode(entry.data), + ]), + ); + expect(entries.get("documents/problem_brief.md")).toBe( + "# Problem Brief v1\n\nOriginal pinned scope.", + ); + expect(entries.get("documents/design.md")).toBe( + "# Acme Design Plan\n\nClean grid and accessible contrast.", + ); + }); + + test("POST /:projectId/approval-rounds records the owner's package approval and starts a round", async () => { + const { store, approvals } = createFakeStore(); + const service = createApprovalRoundService(store); + const app = createTestApp(service); + + const createRes = await app.request("/proj_1/approval-rounds", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + packageVersion: 1, + expiry: "7d", + reviewers: [ + { + name: "Dana Reviewer", + persona: "Finance VP", + email: "dana@example.com", + }, + { + name: "Eli Security", + persona: "Security Lead", + }, + ], + }), + }); + expect(createRes.status).toBe(200); + const created = (await createRes.json()) as { + roundId: string; + status: string; + packageVersion: number; + packageSha256: string; + reviewers: Array<{ + id: string; + name: string; + persona: string; + state: string; + }>; + }; + expect(created.status).toBe("open"); + expect(created.packageVersion).toBe(1); + expect(created.packageSha256).toBe("c".repeat(64)); + expect(created.reviewers).toHaveLength(2); + expect(created.reviewers.map((r) => r.state)).toEqual(["sent", "sent"]); + + expect(approvals).toEqual([ + { + actor: OWNER, + projectId: "proj_1", + artifactId: "art_pkg_1", + version: 1, + sha256: "c".repeat(64), + }, + ]); + expect(APPROVAL_PACKAGE_KIND).toBe("approval_package"); + }); + + test("rejects starting an approval round from a non-user principal or for a stale package version", async () => { + const { store, setPackage } = createFakeStore(); + const service = createApprovalRoundService(store); + + const agentApp = createTestApp(service, AGENT_CALLER); + const agentRes = await agentApp.request("/proj_1/approval-rounds", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + packageVersion: 1, + expiry: "24h", + reviewers: [{ name: "Dana", persona: "VP" }], + }), + }); + expect(agentRes.status).toBe(403); + expect(await agentRes.json()).toEqual({ + error: "approval_round_person_only", + }); + + setPackage(makePackageFixture({ version: 1, latestVersion: 2 })); + const ownerApp = createTestApp(service, OWNER); + const staleRes = await ownerApp.request("/proj_1/approval-rounds", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + packageVersion: 1, + expiry: "24h", + reviewers: [{ name: "Dana", persona: "VP" }], + }), + }); + expect(staleRes.status).toBe(409); + expect((await staleRes.json()) as { error: string }).toMatchObject({ + error: "artifact_ref_stale", + }); + }); + + test("records and amends reviewer decisions with overall and per-slide notes, then settles the round", async () => { + const { store } = createFakeStore(); + const service = createApprovalRoundService(store); + const app = createTestApp(service); + + const created = await service.start(OWNER, "proj_1", { + packageVersion: 1, + expiry: "7d", + reviewers: [ + { name: "Dana", persona: "Finance VP" }, + { name: "Eli", persona: "Security Lead" }, + ], + }); + const [dana, eli] = created.reviewers; + expect(dana).toBeDefined(); + expect(eli).toBeDefined(); + + // Cannot settle while reviewers are still pending before expiry + const earlySettle = await app.request( + `/proj_1/approval-rounds/${created.roundId}/settle`, + { method: "POST" }, + ); + expect(earlySettle.status).toBe(409); + expect(await earlySettle.json()).toEqual({ + error: "round_reviewers_pending", + roundId: created.roundId, + }); + + // Record Dana's initial request_changes decision with per-slide notes + const danaRes = await app.request( + `/proj_1/approval-rounds/${created.roundId}/reviewers/${dana!.id}/decision`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + outcome: "request_changes", + note: "Clarify Q3 staffing assumptions.", + slideNotes: [ + { slide: 3, text: "Break out contractor vs FTE costs." }, + ], + }), + }, + ); + expect(danaRes.status).toBe(200); + + // Amend Dana's decision to approve while round is still open + const amendRes = await app.request( + `/proj_1/approval-rounds/${created.roundId}/reviewers/${dana!.id}/decision`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + outcome: "approve", + note: "Updated numbers look solid.", + }), + }, + ); + expect(amendRes.status).toBe(200); + + // Record Eli's approval without notes + const eliRes = await app.request( + `/proj_1/approval-rounds/${created.roundId}/reviewers/${eli!.id}/decision`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + outcome: "approve", + }), + }, + ); + expect(eliRes.status).toBe(200); + + // Settle the round now that all reviewers have decided + const settleRes = await app.request( + `/proj_1/approval-rounds/${created.roundId}/settle`, + { method: "POST" }, + ); + expect(settleRes.status).toBe(200); + const settled = (await settleRes.json()) as { + status: string; + settledAt?: string; + }; + expect(settled.status).toBe("settled"); + expect(settled.settledAt).toBeDefined(); + + // Amending after settlement is rejected + const afterSettle = await app.request( + `/proj_1/approval-rounds/${created.roundId}/reviewers/${dana!.id}/decision`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ outcome: "comment", note: "Too late" }), + }, + ); + expect(afterSettle.status).toBe(409); + expect(await afterSettle.json()).toEqual({ + error: "approval_round_settled", + roundId: created.roundId, + }); + }); + + test("marks pending reviewers expired after the round expiry window and allows settling", async () => { + const { store, advanceTime } = createFakeStore(); + const service = createApprovalRoundService(store); + const app = createTestApp(service); + + const created = await service.start(OWNER, "proj_1", { + packageVersion: 1, + expiry: "24h", + reviewers: [{ name: "Dana", persona: "Finance VP" }], + }); + const reviewerId = created.reviewers[0]!.id; + + // Advance past 24h + advanceTime(25 * 60 * 60 * 1000); + + const listRes = await app.request("/proj_1/approval-rounds"); + expect(listRes.status).toBe(200); + const listed = (await listRes.json()) as { + rounds: Array<{ status: string; reviewers: Array<{ state: string }> }>; + }; + expect(listed.rounds[0]?.status).toBe("expired"); + expect(listed.rounds[0]?.reviewers[0]?.state).toBe("expired"); + + // Recording a decision on an expired round is rejected + const lateDecision = await app.request( + `/proj_1/approval-rounds/${created.roundId}/reviewers/${reviewerId}/decision`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ outcome: "approve" }), + }, + ); + expect(lateDecision.status).toBe(409); + + // Settling an expired round succeeds + const settleRes = await app.request( + `/proj_1/approval-rounds/${created.roundId}/settle`, + { method: "POST" }, + ); + expect(settleRes.status).toBe(200); + expect(((await settleRes.json()) as { status: string }).status).toBe( + "settled", + ); + }); + + test("saves reviewers marked saveToWorkspace at the workspace level and returns workspaceReviewers on list", async () => { + const { store } = createFakeStore(); + const service = createApprovalRoundService(store); + const app = createTestApp(service); + + const createRes = await app.request("/proj_1/approval-rounds", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + packageVersion: 1, + expiry: "7d", + reviewers: [ + { + name: "Jordan Lee", + persona: "Budget approver", + email: "jordan@example.com", + saveToWorkspace: true, + }, + ], + }), + }); + expect(createRes.status).toBe(200); + + const listRes = await app.request("/proj_1/approval-rounds"); + expect(listRes.status).toBe(200); + const listed = (await listRes.json()) as { + rounds: unknown[]; + workspaceReviewers?: WorkspaceReviewer[]; + }; + expect(listed.workspaceReviewers).toBeDefined(); + expect( + listed.workspaceReviewers?.some( + (reviewer) => + reviewer.name === "Jordan Lee" && + reviewer.persona === "Budget approver", + ), + ).toBe(true); + }); + + test("rejects whitespace-only name, persona, or email before inserting a round", async () => { + const { store, rounds } = createFakeStore(); + const service = createApprovalRoundService(store); + const app = createTestApp(service); + + for (const invalidReviewer of [ + { name: " ", persona: "CEO" }, + { name: "Dana", persona: " " }, + { name: "Dana", persona: "CEO", email: " " }, + ]) { + const res = await app.request("/proj_1/approval-rounds", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + packageVersion: 1, + expiry: "7d", + reviewers: [invalidReviewer], + }), + }); + expect(res.status).toBe(400); + } + expect(rounds.size).toBe(0); + }); + + test("preserves both reviewers' decisions when recorded concurrently", async () => { + const { store } = createFakeStore({ coordinatorRunId: "wfr_coord_99" }); + const service = createApprovalRoundService(store); + const app = createTestApp(service, OWNER); + + const created = await service.start(OWNER, "proj_1", { + packageVersion: 1, + expiry: "7d", + reviewers: [ + { name: "Dana", persona: "CEO" }, + { name: "Marcus", persona: "CTO" }, + ], + }); + const [dana, marcus] = created.reviewers; + + const [resDana, resMarcus] = await Promise.all([ + app.request( + `/proj_1/approval-rounds/${created.roundId}/reviewers/${dana!.id}/decision`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + outcome: "request_changes", + note: "Tighten Phase 1 budget.", + }), + }, + ), + app.request( + `/proj_1/approval-rounds/${created.roundId}/reviewers/${marcus!.id}/decision`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + outcome: "approve", + note: "Architecture looks solid.", + }), + }, + ), + ]); + expect(resDana.status).toBe(200); + expect(resMarcus.status).toBe(200); + + const listed = await service.list(OWNER, "proj_1"); + const round = listed.rounds[0]!; + expect( + round.reviewers.find((r) => r.id === dana!.id)?.decision?.outcome, + ).toBe("request_changes"); + expect( + round.reviewers.find((r) => r.id === marcus!.id)?.decision?.outcome, + ).toBe("approve"); + expect(round.triage).toHaveLength(2); + }); +}); diff --git a/apps/hub/src/artifacts/approval-rounds.ts b/apps/hub/src/artifacts/approval-rounds.ts new file mode 100644 index 00000000..d3ae2814 --- /dev/null +++ b/apps/hub/src/artifacts/approval-rounds.ts @@ -0,0 +1,1323 @@ +import { type } from "arktype"; +import { and, desc, eq } from "drizzle-orm"; +import { PostgresJsDatabase } from "drizzle-orm/postgres-js"; +import type { Hono } from "hono"; +import { describeRoute, resolver } from "hono-openapi"; +import { + contentDispositionHeader, + getArtifact, + getArtifactVersion, + listArtifacts, + listArtifactVersions, + type ArtifactDb, +} from "@corbits/artifacts"; +import { zip, type ZipEntry } from "@corbits/zip"; +import type { DB } from "@intx/db"; +import type { TenantEnv } from "@intx/hub-api"; +import { principal, tenant } from "@intx/db/schema"; +import { base64Decode } from "@intx/types"; +import { + DesignBundle, + type ApprovalPackageBundle, +} from "@corbits-builder/schemas/artifact"; +import { + ApprovalRound, + ApprovalRoundList, + RecordReviewerDecision, + StartApprovalRound, + type ApprovalReviewer, + type ApprovalRoundStatus, + type ExpiryPreset, + type ReviewerDecision, + type ReviewerInput, + type TriageItem, + type WorkspaceReviewer, +} from "@corbits-builder/schemas/approval-round"; +import { + approvalRound, + artifactImportApproval, + project, + projectCoordinator, + settingsRecord, +} from "../db/schema.ts"; +import { + DEFAULT_WORKSPACE_REVIEWERS, + packageReadme, + type PackageManifestSource, +} from "../config/approval-rounds.ts"; +import type { Actor } from "../lib/actor.ts"; +import { parseJson, readJson } from "../lib/json.ts"; +import { actorOf, authorizeProject } from "../projects/authorization.ts"; +import { ProjectError } from "../projects/errors.ts"; +import { parseApprovalPackageBundle } from "./approval-package.ts"; + +export const APPROVAL_PACKAGE_KIND = "approval_package"; + +const EXPIRY_MS: Readonly> = { + "24h": 24 * 60 * 60 * 1000, + "7d": 7 * 24 * 60 * 60 * 1000, + "30d": 30 * 24 * 60 * 60 * 1000, +}; + +export type StoredReviewer = { + id: string; + name: string; + persona: string; + email?: string; + decision?: ReviewerDecision; +}; + +export type StoredApprovalRound = { + roundId: string; + tenantId: string; + projectId: string; + artifactId: string; + packageVersion: number; + packageSha256: string; + startedBy: string; + expiry: ExpiryPreset; + createdAt: Date; + expiresAt: Date; + settledAt: Date | null; + reviewers: StoredReviewer[]; + triage: TriageItem[]; +}; + +export type StoredPackageDocument = { + kind: string; + title: string; + version: number; + sha256: string; + content: string; +}; + +export type StoredPackageVersion = { + projectTitle: string; + artifactId: string; + version: number; + latestVersion: number; + sha256: string; + title: string; + content: string; + projectDocuments: StoredPackageDocument[]; +}; + +export type ApprovalRoundStore = { + authorize( + actor: Actor, + projectId: string, + action: "read" | "manage", + requirePerson?: boolean, + ): Promise; + readPackage( + actor: Actor, + projectId: string, + version: number, + ): Promise; + recordPackageApproval( + actor: Actor, + projectId: string, + ref: { artifactId: string; version: number; sha256: string }, + ): Promise; + insertRound(round: StoredApprovalRound): Promise; + listRounds( + tenantId: string, + projectId: string, + ): Promise; + readRound( + tenantId: string, + projectId: string, + roundId: string, + ): Promise; + updateRound(round: StoredApprovalRound): Promise; + transactRound?( + tenantId: string, + projectId: string, + roundId: string, + fn: ( + round: StoredApprovalRound | undefined, + save: (updated: StoredApprovalRound) => Promise, + ) => Promise, + ): Promise; + listWorkspaceReviewers?(tenantId: string): Promise; + saveWorkspaceReviewers?( + tenantId: string, + reviewers: readonly ReviewerInput[], + ): Promise; + activeCoordinatorRunId?(projectId: string): Promise; + now?(): Date; +}; + +function sanitizeArchiveStem(title: string): string { + const cleaned = title + .replace(/[\p{Cc}\\/:*?"<>|\s]+/gu, "-") + .replace(/\.{2,}/g, "-") + .replace(/^[.-]+/, ""); + return cleaned === "" ? "package" : cleaned; +} + +function isSafeRelativePath(path: string): boolean { + return ( + path !== "" && + !path.startsWith("/") && + !path.includes("\\") && + !path.split("/").includes("..") + ); +} + +function decodeBinaryField(value: string): Uint8Array { + const dataUrlMatch = /^data:[^;,]*;base64,(.*)$/s.exec(value); + const encoded = dataUrlMatch === null ? value : (dataUrlMatch[1] as string); + const decoded = base64Decode(encoded); + const copy = new Uint8Array(new ArrayBuffer(decoded.byteLength)); + copy.set(decoded); + return copy; +} + +function extractDocumentMarkdown(kind: string, content: string): string { + const parsed = DesignBundle(parseJson(content)); + if (!(parsed instanceof type.errors)) { + const markdown = parsed.files["DESIGN.md"]; + if (markdown !== undefined) return markdown; + } + if (kind === "design" || kind === "design_option") { + const raw = parseJson(content); + if ( + typeof raw === "object" && + raw !== null && + "files" in raw && + typeof raw.files === "object" && + raw.files !== null && + "DESIGN.md" in raw.files && + typeof raw.files["DESIGN.md"] === "string" + ) { + return raw.files["DESIGN.md"]; + } + } + return content; +} + +function extractSupportingDocuments( + bundle: ApprovalPackageBundle, + projectDocuments: readonly StoredPackageDocument[], +): ZipEntry[] { + const docMap = new Map(); + if (bundle.documents !== undefined) { + for (const [key, value] of Object.entries(bundle.documents)) { + if (key === "app") continue; + const normalized = key.endsWith(".md") ? key : `${key}.md`; + const path = normalized.startsWith("documents/") + ? normalized + : `documents/${normalized}`; + if (isSafeRelativePath(path)) { + docMap.set(path, extractDocumentMarkdown(key, value)); + } + } + } + for (const source of bundle.manifest.sources) { + if (source.kind === "app" || source.kind === APPROVAL_PACKAGE_KIND) + continue; + const path = `documents/${source.kind}.md`; + if (docMap.has(path) || !isSafeRelativePath(path)) continue; + const matched = projectDocuments.find( + (doc) => + doc.kind === source.kind && + doc.version === source.version && + doc.sha256 === source.sha256, + ); + if (matched !== undefined) { + docMap.set(path, extractDocumentMarkdown(source.kind, matched.content)); + } + } + return [...docMap.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([path, data]) => ({ path, data })); +} + +export type PackageReviewerRecipient = { + name: string; + persona: string; +}; + +async function buildPackageArchive( + pkg: StoredPackageVersion, + reviewer?: PackageReviewerRecipient, +): Promise<{ filename: string; archive: Uint8Array }> { + const bundle = parseApprovalPackageBundle(pkg.content); + const pptx = decodeBinaryField(bundle.pptxBase64); + const pdf = decodeBinaryField(bundle.pdfBase64); + const sources: PackageManifestSource[] = bundle.manifest.sources; + const docEntries = extractSupportingDocuments(bundle, pkg.projectDocuments); + const readme = packageReadme({ + projectTitle: pkg.projectTitle, + packageTitle: pkg.title, + version: pkg.version, + ...(reviewer === undefined ? {} : { reviewer }), + audience: bundle.manifest.audience, + decision: bundle.manifest.decisionNeeded, + sources, + documents: docEntries.map((entry) => entry.path), + }); + const manifestPayload = { + version: pkg.version, + sha256: pkg.sha256, + ...(reviewer === undefined + ? {} + : { reviewer: { name: reviewer.name, persona: reviewer.persona } }), + ...bundle.manifest, + }; + const entries: ZipEntry[] = [ + { path: "README.md", data: readme }, + { path: "manifest.json", data: JSON.stringify(manifestPayload, null, 2) }, + { path: "deck.pptx", data: pptx }, + { path: "deck.pdf", data: pdf }, + ...docEntries, + ]; + const stem = sanitizeArchiveStem(pkg.projectTitle); + const filename = + reviewer === undefined || reviewer.name.trim() === "" + ? `${stem}-package-v${String(pkg.version)}.zip` + : `${stem.toLowerCase()}-approval-package-for-${sanitizeArchiveStem(reviewer.name).toLowerCase()}-${sanitizeArchiveStem(reviewer.persona).toLowerCase()}.zip`; + return { + filename, + archive: await zip(entries), + }; +} + +function deriveRoundStatus( + round: StoredApprovalRound, + now: Date, +): ApprovalRoundStatus { + if (round.settledAt !== null) return "settled"; + if (now.getTime() >= round.expiresAt.getTime()) return "expired"; + return "open"; +} + +function deriveReviewerState( + reviewer: StoredReviewer, + expired: boolean, +): ApprovalReviewer["state"] { + if (reviewer.decision !== undefined) return "decided"; + if (expired) return "expired"; + return "sent"; +} + +function deriveReviewerView( + reviewer: StoredReviewer, + expired: boolean, +): ApprovalReviewer { + return { + id: reviewer.id, + name: reviewer.name, + persona: reviewer.persona, + ...(reviewer.email === undefined ? {} : { email: reviewer.email }), + state: deriveReviewerState(reviewer, expired), + ...(reviewer.decision === undefined ? {} : { decision: reviewer.decision }), + }; +} + +function serializeRound(round: StoredApprovalRound, now: Date): ApprovalRound { + const status = deriveRoundStatus(round, now); + const expired = now.getTime() >= round.expiresAt.getTime(); + return ApprovalRound.assert({ + roundId: round.roundId, + projectId: round.projectId, + artifactId: round.artifactId, + packageVersion: round.packageVersion, + packageSha256: round.packageSha256, + expiry: round.expiry, + status, + createdAt: round.createdAt.toISOString(), + expiresAt: round.expiresAt.toISOString(), + ...(round.settledAt === null + ? {} + : { settledAt: round.settledAt.toISOString() }), + reviewers: round.reviewers.map((reviewer) => + deriveReviewerView(reviewer, expired), + ), + triage: round.triage, + }); +} + +function splitNoteBullets(rawText: string): string[] { + return rawText + .split(/\r?\n/) + .map((line) => + line + .replace(/^[-*•+]\s+/, "") + .replace(/^\d+[.)]\s+/, "") + .trim(), + ) + .filter((line) => line !== "" && !line.startsWith("#")); +} + +function extractTriageItemsForDecision( + roundId: string, + reviewer: StoredReviewer, + decision: ReviewerDecision, +): TriageItem[] { + const items: TriageItem[] = []; + if (decision.note !== undefined) { + for (const text of splitNoteBullets(decision.note)) { + items.push({ + itemId: crypto.randomUUID(), + roundId, + reviewerId: reviewer.id, + reviewerName: reviewer.name, + persona: reviewer.persona, + text, + status: "pending", + }); + } + } + for (const slideNote of decision.slideNotes) { + for (const text of splitNoteBullets(slideNote.text)) { + items.push({ + itemId: crypto.randomUUID(), + roundId, + reviewerId: reviewer.id, + reviewerName: reviewer.name, + persona: reviewer.persona, + slide: slideNote.slide, + text, + status: "pending", + }); + } + } + return items; +} + +const WORKSPACE_REVIEWERS_SCOPE_KEY = "workspace:reviewers"; + +function parseWorkspaceReviewersField( + raw: unknown, +): WorkspaceReviewer[] | null { + if (typeof raw !== "object" || raw === null || !("reviewers" in raw)) { + return null; + } + const list = raw.reviewers; + if (!Array.isArray(list)) return null; + const parsed: WorkspaceReviewer[] = []; + for (const entry of list) { + if ( + typeof entry === "object" && + entry !== null && + "id" in entry && + typeof entry.id === "string" && + "name" in entry && + typeof entry.name === "string" && + "persona" in entry && + typeof entry.persona === "string" + ) { + const email = + "email" in entry && + typeof entry.email === "string" && + entry.email.trim() !== "" + ? entry.email.trim() + : undefined; + parsed.push({ + id: entry.id, + name: entry.name, + persona: entry.persona, + ...(email === undefined ? {} : { email }), + }); + } + } + return parsed; +} + +export function createPgApprovalRoundStore( + rawDB: DB["db"], +): ApprovalRoundStore { + if (!(rawDB instanceof PostgresJsDatabase)) { + throw new Error("Approval rounds require postgres-js"); + } + const db: ArtifactDb = rawDB; + + async function resolveWorkspaceTenantId(tenantId: string): Promise { + const [row] = await rawDB + .select({ parentId: tenant.parentId }) + .from(tenant) + .where(eq(tenant.id, tenantId)); + if (row === undefined || row.parentId === null) { + return tenantId; + } + return row.parentId; + } + + async function readWorkspaceReviewersForRoot( + rootTenantId: string, + ): Promise { + const [row] = await rawDB + .select({ fields: settingsRecord.fields }) + .from(settingsRecord) + .where( + and( + eq(settingsRecord.tenantId, rootTenantId), + eq(settingsRecord.scopeKey, WORKSPACE_REVIEWERS_SCOPE_KEY), + ), + ); + const stored = + row === undefined ? null : parseWorkspaceReviewersField(row.fields); + if (stored !== null) return stored; + return DEFAULT_WORKSPACE_REVIEWERS.map((entry) => ({ ...entry })); + } + + return { + async authorize(actor, projectId, action, requirePerson = false) { + await authorizeProject(rawDB, actor, projectId, action); + if (!requirePerson) return; + const [caller] = await rawDB + .select({ kind: principal.kind }) + .from(principal) + .where(eq(principal.id, actor.principalId)); + if (caller?.kind !== "user") { + throw new ProjectError(403, "approval_round_person_only"); + } + }, + + async readPackage(actor, projectId, version) { + const [projectRow] = await rawDB + .select({ title: project.title }) + .from(project) + .where(eq(project.id, projectId)); + if (!projectRow) throw new ProjectError(404, "project_not_found"); + const [pkg] = ( + await listArtifacts(db, actor.tenantId, { + kind: APPROVAL_PACKAGE_KIND, + limit: 1, + }) + ).rows; + if (!pkg) { + throw new ProjectError(404, "package_not_found", { version }); + } + const [newest] = (await listArtifactVersions(db, pkg.id, { limit: 1 })) + .versions; + const stored = await getArtifactVersion(db, pkg.id, version); + if (!stored?.contentSha256 || !newest) { + throw new ProjectError(404, "package_not_found", { version }); + } + const bundle = parseApprovalPackageBundle(stored.content); + const projectDocuments: StoredPackageDocument[] = []; + for (const source of bundle.manifest.sources) { + if (source.kind === "app" || source.kind === APPROVAL_PACKAGE_KIND) { + continue; + } + const row = await getArtifact(db, source.artifactId); + if (!row || row.tenantId !== actor.tenantId) { + continue; + } + const docVersion = await getArtifactVersion( + db, + source.artifactId, + source.version, + ); + if (docVersion?.contentSha256) { + projectDocuments.push({ + kind: row.kind, + title: docVersion.title, + version: docVersion.version, + sha256: docVersion.contentSha256, + content: docVersion.content, + }); + } + } + return { + projectTitle: projectRow.title, + artifactId: pkg.id, + version: stored.version, + latestVersion: newest.version, + sha256: stored.contentSha256, + title: stored.title, + content: stored.content, + projectDocuments, + }; + }, + + async recordPackageApproval(actor, projectId, ref) { + await rawDB + .insert(artifactImportApproval) + .values({ + artifactId: ref.artifactId, + version: ref.version, + sha256: ref.sha256, + kind: APPROVAL_PACKAGE_KIND, + tenantId: actor.tenantId, + projectId, + principalId: actor.principalId, + }) + .onConflictDoNothing(); + }, + + async insertRound(round) { + await rawDB.insert(approvalRound).values({ + id: round.roundId, + tenantId: round.tenantId, + projectId: round.projectId, + artifactId: round.artifactId, + packageVersion: round.packageVersion, + packageSha256: round.packageSha256, + startedBy: round.startedBy, + expiryPreset: round.expiry, + expiresAt: round.expiresAt, + settledAt: round.settledAt, + reviewers: round.reviewers, + triage: round.triage, + createdAt: round.createdAt, + }); + }, + + async listRounds(tenantId, projectId) { + const rows = await rawDB + .select() + .from(approvalRound) + .where( + and( + eq(approvalRound.tenantId, tenantId), + eq(approvalRound.projectId, projectId), + ), + ) + .orderBy(desc(approvalRound.createdAt)); + return rows.map((row) => ({ + roundId: row.id, + tenantId: row.tenantId, + projectId: row.projectId, + artifactId: row.artifactId, + packageVersion: row.packageVersion, + packageSha256: row.packageSha256, + startedBy: row.startedBy, + expiry: row.expiryPreset as ExpiryPreset, + createdAt: row.createdAt, + expiresAt: row.expiresAt, + settledAt: row.settledAt, + reviewers: row.reviewers as StoredReviewer[], + triage: row.triage as TriageItem[], + })); + }, + + async readRound(tenantId, projectId, roundId) { + const [row] = await rawDB + .select() + .from(approvalRound) + .where( + and( + eq(approvalRound.id, roundId), + eq(approvalRound.tenantId, tenantId), + eq(approvalRound.projectId, projectId), + ), + ); + if (!row) return undefined; + return { + roundId: row.id, + tenantId: row.tenantId, + projectId: row.projectId, + artifactId: row.artifactId, + packageVersion: row.packageVersion, + packageSha256: row.packageSha256, + startedBy: row.startedBy, + expiry: row.expiryPreset as ExpiryPreset, + createdAt: row.createdAt, + expiresAt: row.expiresAt, + settledAt: row.settledAt, + reviewers: row.reviewers as StoredReviewer[], + triage: row.triage as TriageItem[], + }; + }, + + async updateRound(round) { + await rawDB + .update(approvalRound) + .set({ + settledAt: round.settledAt, + reviewers: round.reviewers, + triage: round.triage, + }) + .where( + and( + eq(approvalRound.id, round.roundId), + eq(approvalRound.tenantId, round.tenantId), + eq(approvalRound.projectId, round.projectId), + ), + ); + }, + + async transactRound(tenantId, projectId, roundId, fn) { + return rawDB.transaction(async (tx) => { + const [row] = await tx + .select() + .from(approvalRound) + .where( + and( + eq(approvalRound.id, roundId), + eq(approvalRound.tenantId, tenantId), + eq(approvalRound.projectId, projectId), + ), + ) + .for("update"); + const stored: StoredApprovalRound | undefined = row + ? { + roundId: row.id, + tenantId: row.tenantId, + projectId: row.projectId, + artifactId: row.artifactId, + packageVersion: row.packageVersion, + packageSha256: row.packageSha256, + startedBy: row.startedBy, + expiry: row.expiryPreset as ExpiryPreset, + createdAt: row.createdAt, + expiresAt: row.expiresAt, + settledAt: row.settledAt, + reviewers: row.reviewers as StoredReviewer[], + triage: row.triage as TriageItem[], + } + : undefined; + return fn(stored, async (updated) => { + await tx + .update(approvalRound) + .set({ + settledAt: updated.settledAt, + reviewers: updated.reviewers, + triage: updated.triage, + }) + .where( + and( + eq(approvalRound.id, updated.roundId), + eq(approvalRound.tenantId, updated.tenantId), + eq(approvalRound.projectId, updated.projectId), + ), + ); + }); + }); + }, + + async activeCoordinatorRunId(projectId) { + const [row] = await rawDB + .select({ runId: projectCoordinator.runId }) + .from(projectCoordinator) + .where( + and( + eq(projectCoordinator.projectId, projectId), + eq(projectCoordinator.active, true), + ), + ); + return row?.runId; + }, + + async listWorkspaceReviewers(tenantId) { + const rootTenantId = await resolveWorkspaceTenantId(tenantId); + return readWorkspaceReviewersForRoot(rootTenantId); + }, + + async saveWorkspaceReviewers(tenantId, additions) { + const rootTenantId = await resolveWorkspaceTenantId(tenantId); + const current = await readWorkspaceReviewersForRoot(rootTenantId); + const next = [...current]; + for (const item of additions) { + const name = item.name.trim(); + const persona = item.persona.trim(); + if (name === "" || persona === "") continue; + const email = + item.email !== undefined && item.email.trim() !== "" + ? item.email.trim() + : undefined; + const existingIndex = next.findIndex( + (entry) => + entry.name.toLowerCase() === name.toLowerCase() && + entry.persona.toLowerCase() === persona.toLowerCase(), + ); + if (existingIndex === -1) { + next.push({ + id: `ws-rev-${crypto.randomUUID()}`, + name, + persona, + ...(email === undefined ? {} : { email }), + }); + } else { + const existing = next[existingIndex]!; + next[existingIndex] = { + ...existing, + name, + persona, + ...(email === undefined ? {} : { email }), + }; + } + } + const fieldsPayload = { + providerNames: { value: null, version: 0 }, + title: { value: null, version: 0 }, + reviewers: next, + }; + await rawDB + .insert(settingsRecord) + .values({ + tenantId: rootTenantId, + scopeKey: WORKSPACE_REVIEWERS_SCOPE_KEY, + projectId: null, + fields: + fieldsPayload as unknown as import("@corbits-builder/schemas/settings").SettingsFields, + }) + .onConflictDoUpdate({ + target: [settingsRecord.tenantId, settingsRecord.scopeKey], + set: { + fields: + fieldsPayload as unknown as import("@corbits-builder/schemas/settings").SettingsFields, + }, + }); + return next; + }, + }; +} + +export type PackageZipReviewerFilter = { + roundId?: string; + reviewerId?: string; + reviewerName?: string; + persona?: string; +}; + +export function createApprovalRoundService(store: ApprovalRoundStore) { + const currentNow = () => (store.now === undefined ? new Date() : store.now()); + const roundLocks = new Map>(); + + async function waitForLock( + prev: Promise | undefined, + gate: Promise, + ): Promise { + if (prev !== undefined) await prev; + await gate; + } + + async function withRoundLock( + tenantId: string, + projectId: string, + roundId: string, + fn: ( + round: StoredApprovalRound | undefined, + save: (updated: StoredApprovalRound) => Promise, + ) => Promise, + ): Promise { + const key = `${tenantId}:${projectId}:${roundId}`; + const prev = roundLocks.get(key); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const queued = waitForLock(prev, gate); + roundLocks.set(key, queued); + if (prev !== undefined) await prev; + try { + if (store.transactRound !== undefined) { + return await store.transactRound(tenantId, projectId, roundId, fn); + } + const round = await store.readRound(tenantId, projectId, roundId); + return await fn(round, (updated) => store.updateRound(updated)); + } finally { + release(); + if (roundLocks.get(key) === queued) { + roundLocks.delete(key); + } + } + } + + async function resolveReviewerRecipient( + tenantId: string, + projectId: string, + filter?: PackageZipReviewerFilter, + ): Promise { + if (filter === undefined) return undefined; + const roundId = filter.roundId?.trim(); + const reviewerId = filter.reviewerId?.trim(); + if ( + roundId !== undefined && + roundId !== "" && + reviewerId !== undefined && + reviewerId !== "" + ) { + const round = await store.readRound(tenantId, projectId, roundId); + const found = round?.reviewers.find((r) => r.id === reviewerId); + if (found !== undefined) { + return { name: found.name, persona: found.persona }; + } + } + if (reviewerId !== undefined && reviewerId !== "") { + const rounds = await store.listRounds(tenantId, projectId); + for (const round of rounds) { + const found = round.reviewers.find((r) => r.id === reviewerId); + if (found !== undefined) { + return { name: found.name, persona: found.persona }; + } + } + } + const reviewerName = filter.reviewerName?.trim(); + if (reviewerName !== undefined && reviewerName !== "") { + return { + name: reviewerName, + persona: filter.persona === undefined ? "" : filter.persona.trim(), + }; + } + return undefined; + } + + return { + async packageZip( + actor: Actor, + projectId: string, + version: number, + filter?: PackageZipReviewerFilter, + ) { + await store.authorize(actor, projectId, "read"); + const pkg = await store.readPackage(actor, projectId, version); + const reviewer = await resolveReviewerRecipient( + actor.tenantId, + projectId, + filter, + ); + return buildPackageArchive(pkg, reviewer); + }, + + async list(actor: Actor, projectId: string): Promise { + await store.authorize(actor, projectId, "read"); + const now = currentNow(); + const rows = await store.listRounds(actor.tenantId, projectId); + const workspaceReviewers = + store.listWorkspaceReviewers === undefined + ? undefined + : await store.listWorkspaceReviewers(actor.tenantId); + return { + rounds: rows.map((row) => serializeRound(row, now)), + ...(workspaceReviewers === undefined ? {} : { workspaceReviewers }), + }; + }, + + async start( + actor: Actor, + projectId: string, + raw: unknown, + ): Promise { + const input = StartApprovalRound(raw); + if (input instanceof type.errors) { + throw new ProjectError(400, "invalid_approval_round", { + issues: input.summary, + }); + } + for (const reviewer of input.reviewers) { + if ( + reviewer.name.trim() === "" || + reviewer.persona.trim() === "" || + (reviewer.email !== undefined && reviewer.email.trim() === "") + ) { + throw new ProjectError(400, "invalid_approval_round"); + } + } + await store.authorize(actor, projectId, "manage", true); + const pkg = await store.readPackage( + actor, + projectId, + input.packageVersion, + ); + if (pkg.latestVersion !== pkg.version) { + throw new ProjectError(409, "artifact_ref_stale", { + artifactId: pkg.artifactId, + version: input.packageVersion, + }); + } + await store.recordPackageApproval(actor, projectId, { + artifactId: pkg.artifactId, + version: pkg.version, + sha256: pkg.sha256, + }); + const toSaveToWorkspace = input.reviewers.filter( + (reviewer) => reviewer.saveToWorkspace === true, + ); + if ( + toSaveToWorkspace.length > 0 && + store.saveWorkspaceReviewers !== undefined + ) { + await store.saveWorkspaceReviewers(actor.tenantId, toSaveToWorkspace); + } + const now = currentNow(); + const expiresAt = new Date(now.getTime() + EXPIRY_MS[input.expiry]); + const round: StoredApprovalRound = { + roundId: crypto.randomUUID(), + tenantId: actor.tenantId, + projectId, + artifactId: pkg.artifactId, + packageVersion: pkg.version, + packageSha256: pkg.sha256, + startedBy: actor.principalId, + expiry: input.expiry, + createdAt: now, + expiresAt, + settledAt: null, + reviewers: input.reviewers.map((reviewer) => ({ + id: crypto.randomUUID(), + name: reviewer.name.trim(), + persona: reviewer.persona.trim(), + ...(reviewer.email === undefined + ? {} + : { email: reviewer.email.trim() }), + })), + triage: [], + }; + await store.insertRound(round); + return serializeRound(round, now); + }, + + async recordDecision( + actor: Actor, + projectId: string, + roundId: string, + reviewerId: string, + raw: unknown, + ): Promise { + const input = RecordReviewerDecision(raw); + if (input instanceof type.errors) { + throw new ProjectError(400, "invalid_reviewer_decision", { + issues: input.summary, + }); + } + await store.authorize(actor, projectId, "manage", true); + return withRoundLock( + actor.tenantId, + projectId, + roundId, + async (round, save) => { + if (!round) { + throw new ProjectError(404, "approval_round_not_found", { + roundId, + }); + } + if (round.settledAt !== null) { + throw new ProjectError(409, "approval_round_settled", { roundId }); + } + const now = currentNow(); + if (now.getTime() >= round.expiresAt.getTime()) { + throw new ProjectError(409, "approval_round_expired", { roundId }); + } + const index = round.reviewers.findIndex( + (reviewer) => reviewer.id === reviewerId, + ); + if (index === -1) { + throw new ProjectError(404, "approval_reviewer_not_found", { + roundId, + reviewerId, + }); + } + const trimmedNote = input.note === undefined ? "" : input.note.trim(); + const decision: ReviewerDecision = { + outcome: input.outcome, + ...(trimmedNote === "" ? {} : { note: trimmedNote }), + slideNotes: input.slideNotes === undefined ? [] : input.slideNotes, + decidedAt: now.toISOString(), + }; + const target = round.reviewers[index]!; + const updatedReviewers = [...round.reviewers]; + updatedReviewers[index] = { + ...target, + decision, + }; + const retainedTriage = round.triage.filter( + (item) => + item.reviewerId !== reviewerId || item.status !== "pending", + ); + const extractedTriage = extractTriageItemsForDecision( + round.roundId, + target, + decision, + ).filter( + (item) => + !retainedTriage.some( + (existing) => + existing.reviewerId === item.reviewerId && + existing.slide === item.slide && + existing.text === item.text, + ), + ); + const updatedRound: StoredApprovalRound = { + ...round, + reviewers: updatedReviewers, + triage: [...retainedTriage, ...extractedTriage], + }; + await save(updatedRound); + return serializeRound(updatedRound, now); + }, + ); + }, + + async settle( + actor: Actor, + projectId: string, + roundId: string, + ): Promise { + await store.authorize(actor, projectId, "manage", true); + return withRoundLock( + actor.tenantId, + projectId, + roundId, + async (round, save) => { + if (!round) { + throw new ProjectError(404, "approval_round_not_found", { + roundId, + }); + } + const now = currentNow(); + if (round.settledAt !== null) { + return serializeRound(round, now); + } + const expired = now.getTime() >= round.expiresAt.getTime(); + const allDecided = round.reviewers.every( + (reviewer) => reviewer.decision !== undefined, + ); + if (!expired && !allDecided) { + throw new ProjectError(409, "round_reviewers_pending", { roundId }); + } + const settledRound: StoredApprovalRound = { + ...round, + settledAt: now, + }; + await save(settledRound); + return serializeRound(settledRound, now); + }, + ); + }, + }; +} + +export type ApprovalRoundService = ReturnType< + typeof createApprovalRoundService +>; + +const security = [ + { sessionCookie: [] }, + { deviceBearer: [] }, + { coordinatorBearer: [] }, +]; + +export function mountApprovalRoundRoutes( + app: Hono, + service: ApprovalRoundService, + _nativeFetch?: (request: Request) => Promise, +) { + const body = async (c: { req: { json(): Promise } }) => { + const json = await readJson(c.req); + if (json === undefined) throw new ProjectError(400, "invalid_json"); + return json; + }; + + app.get( + "/:projectId/packages/:version/zip", + describeRoute({ + tags: ["Artifacts"], + operationId: "downloadApprovalPackageZip", + description: + "Packs a filed approval_package version into a deterministic zip archive containing deck.pptx, deck.pdf, README.md, manifest.json, and any grounded supporting documents.", + security, + parameters: [ + { + name: "version", + in: "path", + required: true, + schema: { type: "integer", minimum: 1 }, + }, + { + name: "roundId", + in: "query", + required: false, + schema: { type: "string" }, + }, + { + name: "reviewerId", + in: "query", + required: false, + schema: { type: "string" }, + }, + { + name: "reviewerName", + in: "query", + required: false, + schema: { type: "string" }, + }, + { + name: "persona", + in: "query", + required: false, + schema: { type: "string" }, + }, + ], + responses: { + 200: { + description: "The approval package zip archive", + content: { + "application/zip": { schema: { type: "string", format: "binary" } }, + }, + }, + 400: { description: "Invalid package version" }, + 404: { description: "Approval package version not found" }, + }, + }), + async (c) => { + const rawVersion = c.req.param("version"); + const version = Number(rawVersion); + if (!Number.isInteger(version) || version < 1) { + throw new ProjectError(400, "invalid_package_version", { + version: rawVersion, + }); + } + const roundId = c.req.query("roundId"); + const reviewerId = c.req.query("reviewerId"); + const reviewerName = c.req.query("reviewerName"); + const persona = c.req.query("persona"); + const { filename, archive } = await service.packageZip( + actorOf(c), + c.req.param("projectId"), + version, + { + ...(roundId === undefined ? {} : { roundId }), + ...(reviewerId === undefined ? {} : { reviewerId }), + ...(reviewerName === undefined ? {} : { reviewerName }), + ...(persona === undefined ? {} : { persona }), + }, + ); + return new Response(archive, { + headers: { + "content-type": "application/zip", + "content-disposition": contentDispositionHeader( + "attachment", + filename, + ), + }, + }); + }, + ); + + app.get( + "/:projectId/approval-rounds", + describeRoute({ + tags: ["Artifacts"], + operationId: "listApprovalRounds", + description: + "Lists the project's approval rounds, newest first, with each reviewer's current state and recorded decision.", + security, + responses: { + 200: { + description: "The project's approval rounds", + content: { + "application/json": { schema: resolver(ApprovalRoundList) }, + }, + }, + }, + }), + async (c) => { + return c.json(await service.list(actorOf(c), c.req.param("projectId"))); + }, + ); + + app.post( + "/:projectId/approval-rounds", + describeRoute({ + tags: ["Artifacts"], + operationId: "startApprovalRound", + description: + "Approves the exact approval_package version and SHA-256 as the calling person and opens a review round for the named reviewers.", + security, + requestBody: { + required: true, + content: { + "application/json": { schema: resolver(StartApprovalRound) }, + }, + }, + responses: { + 200: { + description: "The created approval round", + content: { + "application/json": { schema: resolver(ApprovalRound) }, + }, + }, + 400: { description: "Invalid approval round input" }, + 403: { description: "Caller is not a person who manages the project" }, + 404: { description: "Approval package version not found" }, + 409: { description: "Package has a newer version than the one shared" }, + }, + }), + async (c) => { + return c.json( + await service.start( + actorOf(c), + c.req.param("projectId"), + await body(c), + ), + ); + }, + ); + + app.post( + "/:projectId/approval-rounds/:roundId/reviewers/:reviewerId/decision", + describeRoute({ + tags: ["Artifacts"], + operationId: "recordReviewerDecision", + description: + "Records or amends a reviewer's decision (approve, request_changes, or comment) with optional overall note and per-slide notes while the round is open.", + security, + requestBody: { + required: true, + content: { + "application/json": { schema: resolver(RecordReviewerDecision) }, + }, + }, + responses: { + 200: { + description: "The updated approval round", + content: { + "application/json": { schema: resolver(ApprovalRound) }, + }, + }, + 400: { description: "Invalid reviewer decision" }, + 403: { description: "Caller is not a person who manages the project" }, + 404: { description: "Round or reviewer not found" }, + 409: { description: "Round is already expired or settled" }, + }, + }), + async (c) => { + return c.json( + await service.recordDecision( + actorOf(c), + c.req.param("projectId"), + c.req.param("roundId"), + c.req.param("reviewerId"), + await body(c), + ), + ); + }, + ); + + app.post( + "/:projectId/approval-rounds/:roundId/settle", + describeRoute({ + tags: ["Artifacts"], + operationId: "settleApprovalRound", + description: + "Marks the approval round settled once every reviewer has decided or the round has expired.", + security, + responses: { + 200: { + description: "The settled approval round", + content: { + "application/json": { schema: resolver(ApprovalRound) }, + }, + }, + 403: { description: "Caller is not a person who manages the project" }, + 404: { description: "Round not found" }, + 409: { description: "Reviewers are still pending on an open round" }, + }, + }), + async (c) => { + return c.json( + await service.settle( + actorOf(c), + c.req.param("projectId"), + c.req.param("roundId"), + ), + ); + }, + ); +} diff --git a/apps/hub/src/artifacts/routes.ts b/apps/hub/src/artifacts/routes.ts index 71fb2dec..68b8b065 100644 --- a/apps/hub/src/artifacts/routes.ts +++ b/apps/hub/src/artifacts/routes.ts @@ -37,6 +37,7 @@ import { actorOf } from "../projects/authorization.ts"; import { ProjectError } from "../projects/errors.ts"; import { readJson } from "../lib/json.ts"; import { mailRunAsCaller } from "../mail/caller-mail.ts"; +import { mountApprovalRoundRoutes } from "./approval-rounds.ts"; import type { createArtifactService } from "./service.ts"; const security = [ @@ -755,4 +756,5 @@ export function mountArtifactRoutes( ); }, ); + mountApprovalRoundRoutes(app, service.approvalRounds); } diff --git a/apps/hub/src/artifacts/service.ts b/apps/hub/src/artifacts/service.ts index 95da3984..08cff9d2 100644 --- a/apps/hub/src/artifacts/service.ts +++ b/apps/hub/src/artifacts/service.ts @@ -151,6 +151,10 @@ import { readProjectPackageSource, validateApprovalPackage, } from "./approval-package.ts"; +import { + createApprovalRoundService, + createPgApprovalRoundStore, +} from "./approval-rounds.ts"; import { requestErrorMetadata } from "../request-error.ts"; const log = getLogger(["corbits-builder", "hub", "artifacts"]); @@ -2181,5 +2185,9 @@ export function createArtifactService( ); return { design, concept: stored.title }; }, + + approvalRounds: createApprovalRoundService( + createPgApprovalRoundStore(rawDB), + ), }; } diff --git a/apps/hub/src/config/approval-rounds.ts b/apps/hub/src/config/approval-rounds.ts new file mode 100644 index 00000000..87cd5151 --- /dev/null +++ b/apps/hub/src/config/approval-rounds.ts @@ -0,0 +1,87 @@ +export type PackageManifestSource = { + kind: string; + version: number; + sha256: string; + title?: string; +}; + +export const DEFAULT_WORKSPACE_REVIEWERS = [ + { + id: "ws-rev-dana-chen", + name: "Dana Chen", + persona: "CEO", + email: "dana.chen@example.com", + }, + { + id: "ws-rev-marcus-vance", + name: "Marcus Vance", + persona: "CTO", + email: "marcus.vance@example.com", + }, + { + id: "ws-rev-priya-patel", + name: "Priya Patel", + persona: "VP Product", + email: "priya.patel@example.com", + }, +] as const; + +export function packageReadme(input: { + projectTitle: string; + packageTitle: string; + version: number; + reviewer?: { name: string; persona: string }; + audience?: string; + decision?: string; + note?: string; + sources: readonly PackageManifestSource[]; + documents: readonly string[]; +}): string { + const lines = [ + `# ${input.projectTitle} — ${input.packageTitle} (v${String(input.version)})`, + "", + ]; + if (input.reviewer !== undefined && input.reviewer.name.trim() !== "") { + const personaSuffix = + input.reviewer.persona.trim() === "" + ? "" + : ` (${input.reviewer.persona.trim()})`; + lines.push( + `Prepared for: ${input.reviewer.name.trim()}${personaSuffix}`, + "", + ); + } + if (input.audience !== undefined && input.audience.trim() !== "") { + lines.push(`**Audience:** ${input.audience.trim()}`, ""); + } + if (input.decision !== undefined && input.decision.trim() !== "") { + lines.push(`**Decision needed:** ${input.decision.trim()}`, ""); + } + if (input.note !== undefined && input.note.trim() !== "") { + lines.push(input.note.trim(), ""); + } + lines.push( + "## Package Contents", + "", + "- `deck.pptx` — Editable presentation deck", + "- `deck.pdf` — Printable presentation PDF", + "- `manifest.json` — Pinned source document versions and SHA-256 digests", + ); + for (const path of input.documents) { + lines.push(`- \`${path}\` — Supporting project document`); + } + if (input.sources.length > 0) { + lines.push("", "## Grounded Source Manifest", ""); + for (const source of input.sources) { + const label = + source.title === undefined + ? source.kind + : `${source.title} (${source.kind})`; + lines.push( + `- ${label}: v${String(source.version)} (\`${source.sha256}\`)`, + ); + } + } + lines.push(""); + return lines.join("\n"); +} diff --git a/apps/hub/src/db/migrations/0037_approval_round.sql b/apps/hub/src/db/migrations/0037_approval_round.sql new file mode 100644 index 00000000..99e88e68 --- /dev/null +++ b/apps/hub/src/db/migrations/0037_approval_round.sql @@ -0,0 +1,21 @@ +CREATE TABLE "sb_approval_round" ( + "id" text PRIMARY KEY NOT NULL, + "tenant_id" text NOT NULL, + "project_id" text NOT NULL, + "artifact_id" text NOT NULL, + "package_version" integer NOT NULL, + "package_sha256" text NOT NULL, + "started_by" text NOT NULL, + "expiry_preset" text NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "settled_at" timestamp with time zone, + "reviewers" jsonb NOT NULL, + "triage" jsonb DEFAULT '[]'::jsonb NOT NULL, + "triggered_run_id" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "sb_approval_round" ADD CONSTRAINT "sb_approval_round_tenant_id_tenant_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenant"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sb_approval_round" ADD CONSTRAINT "sb_approval_round_project_id_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."project"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sb_approval_round" ADD CONSTRAINT "sb_approval_round_started_by_principal_id_fk" FOREIGN KEY ("started_by") REFERENCES "public"."principal"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "sb_approval_round_project_idx" ON "sb_approval_round" USING btree ("tenant_id","project_id","created_at"); \ No newline at end of file diff --git a/apps/hub/src/db/migrations/meta/0037_snapshot.json b/apps/hub/src/db/migrations/meta/0037_snapshot.json new file mode 100644 index 00000000..b4704b0c --- /dev/null +++ b/apps/hub/src/db/migrations/meta/0037_snapshot.json @@ -0,0 +1,1309 @@ +{ + "id": "2157db3b-c1c6-4217-867d-ba9a31a07130", + "prevId": "31045c86-dbe9-421a-a5b9-1a717da13cab", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.sb_approval_round": { + "name": "sb_approval_round", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "artifact_id": { + "name": "artifact_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "package_version": { + "name": "package_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "package_sha256": { + "name": "package_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_by": { + "name": "started_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expiry_preset": { + "name": "expiry_preset", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "settled_at": { + "name": "settled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "reviewers": { + "name": "reviewers", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "triage": { + "name": "triage", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "triggered_run_id": { + "name": "triggered_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sb_approval_round_project_idx": { + "name": "sb_approval_round_project_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sb_approval_round_tenant_id_tenant_id_fk": { + "name": "sb_approval_round_tenant_id_tenant_id_fk", + "tableFrom": "sb_approval_round", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "sb_approval_round_project_id_project_id_fk": { + "name": "sb_approval_round_project_id_project_id_fk", + "tableFrom": "sb_approval_round", + "tableTo": "project", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "sb_approval_round_started_by_principal_id_fk": { + "name": "sb_approval_round_started_by_principal_id_fk", + "tableFrom": "sb_approval_round", + "tableTo": "principal", + "columnsFrom": ["started_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sb_artifact_approval": { + "name": "sb_artifact_approval", + "schema": "", + "columns": { + "approval_id": { + "name": "approval_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "artifact_id": { + "name": "artifact_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool": { + "name": "tool", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sb_artifact_approval_artifact_idx": { + "name": "sb_artifact_approval_artifact_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "artifact_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sb_artifact_approval_approval_id_approval_id_fk": { + "name": "sb_artifact_approval_approval_id_approval_id_fk", + "tableFrom": "sb_artifact_approval", + "tableTo": "approval", + "columnsFrom": ["approval_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "sb_artifact_approval_tenant_id_tenant_id_fk": { + "name": "sb_artifact_approval_tenant_id_tenant_id_fk", + "tableFrom": "sb_artifact_approval", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "sb_artifact_approval_project_id_project_id_fk": { + "name": "sb_artifact_approval_project_id_project_id_fk", + "tableFrom": "sb_artifact_approval", + "tableTo": "project", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sb_artifact_approval_approval_id_artifact_id_pk": { + "name": "sb_artifact_approval_approval_id_artifact_id_pk", + "columns": ["approval_id", "artifact_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sb_artifact_import_approval": { + "name": "sb_artifact_import_approval", + "schema": "", + "columns": { + "artifact_id": { + "name": "artifact_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sb_artifact_import_approval_tenant_idx": { + "name": "sb_artifact_import_approval_tenant_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "artifact_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sb_artifact_import_approval_tenant_id_tenant_id_fk": { + "name": "sb_artifact_import_approval_tenant_id_tenant_id_fk", + "tableFrom": "sb_artifact_import_approval", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "sb_artifact_import_approval_project_id_project_id_fk": { + "name": "sb_artifact_import_approval_project_id_project_id_fk", + "tableFrom": "sb_artifact_import_approval", + "tableTo": "project", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "sb_artifact_import_approval_principal_id_principal_id_fk": { + "name": "sb_artifact_import_approval_principal_id_principal_id_fk", + "tableFrom": "sb_artifact_import_approval", + "tableTo": "principal", + "columnsFrom": ["principal_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sb_artifact_import_approval_artifact_id_version_pk": { + "name": "sb_artifact_import_approval_artifact_id_version_pk", + "columns": ["artifact_id", "version"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sb_custom_model_provider": { + "name": "sb_custom_model_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "model_provider_id": { + "name": "model_provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "models": { + "name": "models", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "keyless": { + "name": "keyless", + "type": "boolean", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sb_custom_model_provider_model_provider_id_model_provider_id_fk": { + "name": "sb_custom_model_provider_model_provider_id_model_provider_id_fk", + "tableFrom": "sb_custom_model_provider", + "tableTo": "model_provider", + "columnsFrom": ["model_provider_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sb_custom_model_provider_model_provider_id_unique": { + "name": "sb_custom_model_provider_model_provider_id_unique", + "nullsNotDistinct": false, + "columns": ["model_provider_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sb_design_concept": { + "name": "sb_design_concept", + "schema": "", + "columns": { + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sb_design_concept_tenant_idx": { + "name": "sb_design_concept_tenant_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "label", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sb_design_concept_run_id_workflow_run_id_fk": { + "name": "sb_design_concept_run_id_workflow_run_id_fk", + "tableFrom": "sb_design_concept", + "tableTo": "workflow_run", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "sb_design_concept_tenant_id_tenant_id_fk": { + "name": "sb_design_concept_tenant_id_tenant_id_fk", + "tableFrom": "sb_design_concept", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "sb_design_concept_project_id_project_id_fk": { + "name": "sb_design_concept_project_id_project_id_fk", + "tableFrom": "sb_design_concept", + "tableTo": "project", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_code": { + "name": "device_code", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "device_code": { + "name": "device_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "polling_interval": { + "name": "polling_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "device_code_device_code_unique": { + "name": "device_code_device_code_unique", + "nullsNotDistinct": false, + "columns": ["device_code"] + }, + "device_code_user_code_unique": { + "name": "device_code_user_code_unique", + "nullsNotDistinct": false, + "columns": ["user_code"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_session": { + "name": "device_session", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "device_session_session_id_session_id_fk": { + "name": "device_session_session_id_session_id_fk", + "tableFrom": "device_session", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sb_ended_run": { + "name": "sb_ended_run", + "schema": "", + "columns": { + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "notified": { + "name": "notified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sb_ended_run_run_id_workflow_run_id_fk": { + "name": "sb_ended_run_run_id_workflow_run_id_fk", + "tableFrom": "sb_ended_run", + "tableTo": "workflow_run", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "sb_ended_run_project_id_project_id_fk": { + "name": "sb_ended_run_project_id_project_id_fk", + "tableFrom": "sb_ended_run", + "tableTo": "project", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sb_invitation": { + "name": "sb_invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_sha256": { + "name": "role_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload_sha256": { + "name": "payload_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "accepted_principal_id": { + "name": "accepted_principal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sb_invitation_recipient_idx": { + "name": "sb_invitation_recipient_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sb_invitation_tenant_id_tenant_id_fk": { + "name": "sb_invitation_tenant_id_tenant_id_fk", + "tableFrom": "sb_invitation", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sb_invitation_tenant_id_invited_by_idempotency_key_unique": { + "name": "sb_invitation_tenant_id_invited_by_idempotency_key_unique", + "nullsNotDistinct": false, + "columns": ["tenant_id", "invited_by", "idempotency_key"] + } + }, + "policies": {}, + "checkConstraints": { + "sb_invitation_terminal_check": { + "name": "sb_invitation_terminal_check", + "value": "NOT (\"sb_invitation\".\"accepted_at\" IS NOT NULL AND \"sb_invitation\".\"revoked_at\" IS NOT NULL)" + }, + "sb_invitation_acceptance_check": { + "name": "sb_invitation_acceptance_check", + "value": "(\"sb_invitation\".\"accepted_at\" IS NULL) = (\"sb_invitation\".\"accepted_principal_id\" IS NULL)" + }, + "sb_invitation_revocation_check": { + "name": "sb_invitation_revocation_check", + "value": "(\"sb_invitation\".\"revoked_at\" IS NULL) = (\"sb_invitation\".\"revoked_by\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.mutation_receipt": { + "name": "mutation_receipt", + "schema": "", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_principal_id": { + "name": "actor_principal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload_sha256": { + "name": "payload_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "result_body": { + "name": "result_body", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "mutation_receipt_tenant_id_tenant_id_fk": { + "name": "mutation_receipt_tenant_id_tenant_id_fk", + "tableFrom": "mutation_receipt", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "mutation_receipt_actor_principal_id_principal_id_fk": { + "name": "mutation_receipt_actor_principal_id_principal_id_fk", + "tableFrom": "mutation_receipt", + "tableTo": "principal", + "columnsFrom": ["actor_principal_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mutation_receipt_tenant_id_actor_principal_id_command_idempotency_key_pk": { + "name": "mutation_receipt_tenant_id_actor_principal_id_command_idempotency_key_pk", + "columns": [ + "tenant_id", + "actor_principal_id", + "command", + "idempotency_key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox": { + "name": "outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sb_outbox_project_time_id_idx": { + "name": "sb_outbox_project_time_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "outbox_project_id_project_id_fk": { + "name": "outbox_project_id_project_id_fk", + "tableFrom": "outbox", + "tableTo": "project", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project": { + "name": "project", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "version_of_project_id": { + "name": "version_of_project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shipped_at": { + "name": "shipped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "project_tenant_id_tenant_id_fk": { + "name": "project_tenant_id_tenant_id_fk", + "tableFrom": "project", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "project_version_of_project_id_project_id_fk": { + "name": "project_version_of_project_id_project_id_fk", + "tableFrom": "project", + "tableTo": "project", + "columnsFrom": ["version_of_project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_tenant_id_unique": { + "name": "project_tenant_id_unique", + "nullsNotDistinct": false, + "columns": ["tenant_id"] + } + }, + "policies": {}, + "checkConstraints": { + "project_status_check": { + "name": "project_status_check", + "value": "\"project\".\"status\" in ('active', 'archived')" + } + }, + "isRLSEnabled": false + }, + "public.project_coordinator": { + "name": "project_coordinator", + "schema": "", + "columns": { + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "registered_by": { + "name": "registered_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_coordinator_active_project_idx": { + "name": "project_coordinator_active_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"project_coordinator\".\"active\"", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_coordinator_run_id_workflow_run_id_fk": { + "name": "project_coordinator_run_id_workflow_run_id_fk", + "tableFrom": "project_coordinator", + "tableTo": "workflow_run", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "project_coordinator_project_id_project_id_fk": { + "name": "project_coordinator_project_id_project_id_fk", + "tableFrom": "project_coordinator", + "tableTo": "project", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "project_coordinator_registered_by_principal_id_fk": { + "name": "project_coordinator_registered_by_principal_id_fk", + "tableFrom": "project_coordinator", + "tableTo": "principal", + "columnsFrom": ["registered_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sb_settings": { + "name": "sb_settings", + "schema": "", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fields": { + "name": "fields", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sb_settings_tenant_id_tenant_id_fk": { + "name": "sb_settings_tenant_id_tenant_id_fk", + "tableFrom": "sb_settings", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "sb_settings_project_id_project_id_fk": { + "name": "sb_settings_project_id_project_id_fk", + "tableFrom": "sb_settings", + "tableTo": "project", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sb_settings_tenant_id_scope_key_pk": { + "name": "sb_settings_tenant_id_scope_key_pk", + "columns": ["tenant_id", "scope_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/hub/src/db/migrations/meta/_journal.json b/apps/hub/src/db/migrations/meta/_journal.json index c5469e28..73edb033 100644 --- a/apps/hub/src/db/migrations/meta/_journal.json +++ b/apps/hub/src/db/migrations/meta/_journal.json @@ -260,6 +260,13 @@ "when": 1794000000000, "tag": "0036_custom_model_provider", "breakpoints": true + }, + { + "idx": 37, + "version": "7", + "when": 1793596830302, + "tag": "0037_approval_round", + "breakpoints": true } ] } diff --git a/apps/hub/src/db/schema.ts b/apps/hub/src/db/schema.ts index e8b5640e..31be0802 100644 --- a/apps/hub/src/db/schema.ts +++ b/apps/hub/src/db/schema.ts @@ -305,3 +305,40 @@ export const customModelProvider = pgTable("sb_custom_model_provider", { // Its connection carries a placeholder in place of a key; the placeholder itself is never shown. keyless: boolean("keyless").notNull(), }); + +// Sharing an approval package starts a review round in the hub without parking a long-lived +// workflow on a sidecar. Reviewer decisions and triaged items stay here until the owner acts. +export const approvalRound = pgTable( + "sb_approval_round", + { + id: text("id").primaryKey(), + tenantId: text("tenant_id") + .notNull() + .references(() => tenant.id), + projectId: text("project_id") + .notNull() + .references(() => project.id), + artifactId: text("artifact_id").notNull(), + packageVersion: integer("package_version").notNull(), + packageSha256: text("package_sha256").notNull(), + startedBy: text("started_by") + .notNull() + .references(() => principal.id), + expiryPreset: text("expiry_preset").notNull(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + settledAt: timestamp("settled_at", { withTimezone: true }), + reviewers: jsonb("reviewers").notNull(), + triage: jsonb("triage").notNull().default([]), + triggeredRunId: text("triggered_run_id"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => [ + index("sb_approval_round_project_idx").on( + t.tenantId, + t.projectId, + t.createdAt, + ), + ], +); diff --git a/apps/web/src/api/artifacts.ts b/apps/web/src/api/artifacts.ts index 3a1be0da..f9c49edd 100644 --- a/apps/web/src/api/artifacts.ts +++ b/apps/web/src/api/artifacts.ts @@ -1,8 +1,16 @@ -import { ProductApiError } from "@corbits-builder/client"; +import { + ProductApiError, + type PackageZipReviewerQuery, +} from "@corbits-builder/client"; import type { DesignChoice, DesignTweaks, } from "@corbits-builder/schemas/artifact"; +import type { + RecordReviewerDecision, + StartApprovalRound, + TriageDecisionInput, +} from "@corbits-builder/schemas/approval-round"; import { productClient } from "./transport.ts"; import { workspaceTenantId } from "./auth.ts"; import { ApiFailure } from "./types.ts"; @@ -200,3 +208,92 @@ export async function attachMaterials( const tenantId = await workspaceTenantId(); return productClient().attachMaterials(tenantId, projectId, files, message); } + +export async function downloadPackageZip( + projectId: string, + version: number, + signalOrReviewer?: AbortSignal | PackageZipReviewerQuery, + signal?: AbortSignal, +) { + const tenantId = await workspaceTenantId(); + return productClient().downloadPackageZip( + tenantId, + projectId, + version, + signalOrReviewer, + signal, + ); +} + +export async function downloadApprovalPackageZip( + projectId: string, + version: number, + reviewer?: { + name: string; + persona: string; + reviewerId?: string; + roundId?: string; + }, + signal?: AbortSignal, +): Promise { + const { filename, archive } = await downloadPackageZip( + projectId, + version, + reviewer, + signal, + ); + return new File([archive], filename, { type: "application/zip" }); +} + +export async function listApprovalRounds( + projectId: string, + signal?: AbortSignal, +) { + const tenantId = await workspaceTenantId(); + return productClient().approvalRounds(tenantId, projectId, signal); +} + +export async function startApprovalRound( + projectId: string, + input: StartApprovalRound, +) { + const tenantId = await workspaceTenantId(); + return productClient().startApprovalRound(tenantId, projectId, input); +} + +export async function recordReviewerDecision( + projectId: string, + roundId: string, + reviewerId: string, + input: RecordReviewerDecision, +) { + const tenantId = await workspaceTenantId(); + return productClient().recordReviewerDecision( + tenantId, + projectId, + roundId, + reviewerId, + input, + ); +} + +export async function settleApprovalRound(projectId: string, roundId: string) { + const tenantId = await workspaceTenantId(); + return productClient().settleApprovalRound(tenantId, projectId, roundId); +} + +export async function triageReviewerFeedback( + projectId: string, + roundId: string, + itemId: string, + input: TriageDecisionInput, +) { + const tenantId = await workspaceTenantId(); + return productClient().triageReviewerFeedback( + tenantId, + projectId, + roundId, + itemId, + input, + ); +} diff --git a/apps/web/src/config.ts b/apps/web/src/config.ts index 963a24b8..78561adf 100644 --- a/apps/web/src/config.ts +++ b/apps/web/src/config.ts @@ -183,6 +183,8 @@ export const wording = { sharePackagePdf: "Share the package PDF", downloadSheet: "Download as CSV", shareSheet: "Share as CSV", + downloadPackage: "Download package", + sharePackage: "Share package", downloaded: (name: string) => `Downloaded ${name}.`, shareReady: (name: string) => `${name} is ready to share.`, shareNow: "Share now", @@ -1085,6 +1087,22 @@ export const wording = { saved: "Saved", failed: "Not saved", }, + approvals: { + section: "Workspace approvals", + lead: "Control whether projects use stakeholder approval packages and manage saved reviewers across this workspace.", + requireLabel: "Require stakeholder approval", + requireHint: + "When off, stakeholder approval packages are bypassed and the Approval tab is hidden in projects.", + reviewersSubhead: "Workspace reviewers", + reviewersLead: + "Saved reviewers can be selected with one click when sharing an approval package in any project.", + reviewerName: "Reviewer name", + reviewerPersona: "Persona", + reviewerEmail: "Email (optional)", + addReviewer: "Add reviewer", + removeReviewer: "Remove", + emptyReviewers: "No saved workspace reviewers yet.", + }, appearance: { section: "Appearance", lead: "Follows the system until you say otherwise.", @@ -1112,4 +1130,105 @@ export const wording = { failed: "That did not go through. Try again.", }, }, + approvalRound: { + heading: "Share for sign-off", + lead: "Add each person reviewing this package to download their personalized .zip (slide deck, PDF, README and pinned source documents) and track their sign-off or feedback.", + shareButton: "Add reviewers & download .zip", + cancelShare: "Cancel", + dialogTitle: "Share approval package", + expiryLabel: "Expires in", + expiry: { + "24h": "24 hours", + "7d": "7 days", + "30d": "30 days", + }, + reviewerRowLabel: (index: number) => `Reviewer ${String(index)}`, + reviewerName: "Reviewer name", + reviewerPersona: "Persona", + reviewerPersonaPlaceholder: "Select role or persona…", + reviewerPersonas: [ + "Project owner", + "Budget approver", + "Technical approver", + "Audience member", + "Builder operator", + "Delivery recipient", + "CEO", + "CTO", + "VP Product", + "Investor", + "Client", + "End user", + ], + otherPersona: "Other…", + customPersonaLabel: "Custom persona", + customPersonaPlaceholder: "Type custom role or persona…", + addCustomPersona: "Add", + workspaceReviewerLabel: "Pick from workspace", + workspaceReviewerPlaceholder: "Select a workspace reviewer…", + workspaceReviewerOption: (name: string, persona: string) => + `${name} (${persona})`, + saveToWorkspaceLabel: (name: string) => + name === "" ? "Save to workspace?" : `Save ${name} to workspace?`, + saveToWorkspaceTooltip: + "Saves this reviewer to the workspace so you can pick them in any project without retyping their details.", + reviewerEmail: "Email (optional)", + addReviewer: "Add reviewer", + removeReviewer: "Remove", + startRound: "Save & download .zip", + startingRound: "Preparing .zip…", + roundHeading: (version: number) => `Package v${String(version)} reviewers`, + roundStatus: { + open: "Open", + expired: "Expired", + settled: "Settled", + }, + reviewerState: { + sent: "Sent", + decided: "Decided", + expired: "Expired", + }, + downloadReviewerZip: (name: string) => `Download .zip for ${name}`, + downloadZipShort: "Download .zip", + shareReviewerZip: (name: string) => `Share .zip for ${name}`, + outcomeLabel: "Decision", + outcome: { + approve: "Approve", + request_changes: "Request changes", + comment: "Comment", + }, + recordDecision: "Record decision", + amendDecision: "Amend decision", + noteLabel: "Feedback notes", + notePlaceholder: + "Paste reviewer feedback or attach a .md, .txt, or .csv file…", + attachFeedbackFile: "Attach feedback file", + attachFeedbackHint: "Drop or attach .md, .txt, .csv, or .json files", + removeAttachedFile: (name: string) => `Remove ${name}`, + saveDecision: "Save decision", + savingDecision: "Saving…", + settleRound: "Mark settled", + settlingRound: "Settling…", + loading: "Reading reviewers…", + empty: + "No reviewers added yet. Add the people reviewing this package to download a .zip for each person and record their sign-off.", + failed: "That sign-off action did not go through.", + slidePrefix: (slide: number) => `Slide ${String(slide)}`, + triageHeading: "Feedback triage", + triageLead: + "Reviewer notes stay here until you incorporate, dismiss, or reply to each item.", + triageStatus: { + pending: "Pending", + incorporated: "Incorporated", + dismissed: "Dismissed", + replied: "Replied", + }, + incorporate: "Incorporate", + dismiss: "Dismiss", + reply: "Reply", + replyPlaceholder: "Write a reply…", + sendReply: "Save reply", + overallScope: "Overall", + ownerReplyLabel: (reply: string) => `Reply: ${reply}`, + }, } as const; diff --git a/apps/web/src/pages/settings.tsx b/apps/web/src/pages/settings.tsx index 98934c62..b203dd53 100644 --- a/apps/web/src/pages/settings.tsx +++ b/apps/web/src/pages/settings.tsx @@ -11,6 +11,7 @@ */ import { wording } from "../config.ts"; import { + Badge, Section, Switch, Tabs, @@ -27,6 +28,16 @@ import { useDocumentTips, writeDocumentTips, } from "../document-tips-setting.ts"; +import { + addCustomReviewerPersona, + addWorkspaceReviewer, + allReviewerPersonas, + removeWorkspaceReviewer, + useCustomReviewerPersonas, + useWorkspaceApprovalsEnabled, + useWorkspaceReviewers, + writeWorkspaceApprovalsEnabled, +} from "../workspace-approvals-setting.ts"; import { Collaborators } from "./collaborators.jsx"; import { Banner, Button, QuietSelect, QuietState } from "../components.jsx"; import { productClient } from "../api/transport.ts"; @@ -102,6 +113,30 @@ export function Settings({ onSignOut?: () => Promise; }) { const [tab, setTab] = useTabInUrl(); + const renderTabContent = (active: SettingsTab) => { + if (active === "providers") { + return ; + } + if (active === "profiles") { + return ; + } + if (active === "collaborators") { + return ; + } + if (active === "about") { + return ; + } + return ( + <> + {user ? : null} + + + {user ? ( + + ) : null} + + ); + }; return (

{wording.settings.title}

@@ -112,25 +147,7 @@ export function Settings({ label={wording.settings.sections} className="settings-tabs" > - {(active) => - active === "providers" ? ( - - ) : active === "profiles" ? ( - - ) : active === "collaborators" ? ( - - ) : active === "about" ? ( - - ) : ( - <> - {user ? : null} - - {user ? ( - - ) : null} - - ) - } + {renderTabContent}
); @@ -165,6 +182,149 @@ function Row({ ); } +const OTHER_PERSONA_OPTION = "__other__"; + +/** Workspace-level approval toggle and saved workspace reviewers. */ +export function WorkspaceApprovals() { + const approvalsEnabled = useWorkspaceApprovalsEnabled(); + const reviewers = useWorkspaceReviewers(); + const customPersonas = useCustomReviewerPersonas(); + const personaOptions = allReviewerPersonas(customPersonas, reviewers); + const [name, setName] = useState(""); + const [persona, setPersona] = useState( + wording.approvalRound.reviewerPersonas[0], + ); + const [customPersonaOpen, setCustomPersonaOpen] = useState(false); + const [customPersonaDraft, setCustomPersonaDraft] = useState(""); + const [email, setEmail] = useState(""); + const copy = wording.settings.approvals; + const roundCopy = wording.approvalRound; + + const effectivePersona = customPersonaOpen + ? customPersonaDraft.trim() + : persona.trim(); + const canAdd = name.trim().length > 0 && effectivePersona.length > 0; + + const handleSelectPersona = (value: string) => { + if (value === OTHER_PERSONA_OPTION) { + setCustomPersonaOpen(true); + setCustomPersonaDraft(""); + return; + } + setCustomPersonaOpen(false); + setPersona(value); + }; + + const handleAddReviewer = () => { + if (!canAdd) return; + if (customPersonaOpen) { + addCustomReviewerPersona(effectivePersona); + } + const trimmedEmail = email.trim(); + addWorkspaceReviewer({ + name: name.trim(), + persona: effectivePersona, + ...(trimmedEmail.length > 0 ? { email: trimmedEmail } : {}), + }); + setName(""); + setEmail(""); + if (customPersonaOpen) { + setPersona(effectivePersona); + setCustomPersonaOpen(false); + setCustomPersonaDraft(""); + } + }; + + return ( +
+
+ + + writeWorkspaceApprovalsEnabled(on ? "on" : "off") + } + /> + +

{copy.reviewersSubhead}

+

{copy.reviewersLead}

+ {reviewers.length === 0 ? ( +

{copy.emptyReviewers}

+ ) : ( + reviewers.map((reviewer) => ( + + + {reviewer.persona} + + + + )) + )} +
+
+ setName(event.target.value)} + /> + handleSelectPersona(event.target.value)} + > + {personaOptions.map((option) => ( + + ))} + + + {customPersonaOpen ? ( + setCustomPersonaDraft(event.target.value)} + /> + ) : null} + setEmail(event.target.value)} + /> + +
+
+
+
+ ); +} + /** The theme, and the document tips choice kept in this browser. */ function Appearance() { const { mode, setMode } = useTheme(); diff --git a/apps/web/src/pages/workspace/approval-rounds.test.tsx b/apps/web/src/pages/workspace/approval-rounds.test.tsx new file mode 100644 index 00000000..3fcfee71 --- /dev/null +++ b/apps/web/src/pages/workspace/approval-rounds.test.tsx @@ -0,0 +1,116 @@ +import { describe, expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { ApprovalRound } from "@corbits-builder/schemas/approval-round"; +import { ApprovalRoundsPanel, SharePackageDialog } from "./approval-rounds.tsx"; +import { WorkspaceApprovals } from "../settings.tsx"; + +const openRound: ApprovalRound = { + roundId: "rnd_1", + projectId: "proj_1", + artifactId: "art_pkg_1", + packageVersion: 1, + packageSha256: "a".repeat(64), + expiry: "7d", + status: "open", + createdAt: "2026-04-01T12:00:00.000Z", + expiresAt: "2026-04-08T12:00:00.000Z", + reviewers: [ + { + id: "rev_1", + name: "Dana Reviewer", + persona: "Finance VP", + email: "dana@example.com", + state: "decided", + decision: { + outcome: "request_changes", + note: "Clarify Q3 staffing assumptions.", + slideNotes: [], + decidedAt: "2026-04-02T09:00:00.000Z", + }, + }, + { + id: "rev_2", + name: "Eli Security", + persona: "Security Lead", + state: "sent", + }, + ], + triage: [], +}; + +describe("approval rounds panel", () => { + test("renders open round with reviewer states, per-person Download .zip buttons, recorded decision, and disabled settle button while a reviewer is pending", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("Share for sign-off"); + expect(html).toContain("Add reviewers & download .zip"); + expect(html).toContain("Package v1 reviewers"); + expect(html).toContain("Dana Reviewer"); + expect(html).toContain("Finance VP"); + expect(html).toContain('aria-label="Download .zip for Dana Reviewer"'); + expect(html).toContain("Decided"); + expect(html).toContain("Request changes"); + expect(html).toContain("Clarify Q3 staffing assumptions."); + expect(html).toContain("Amend decision"); + expect(html).toContain("Eli Security"); + expect(html).toContain("Security Lead"); + expect(html).toContain('aria-label="Download .zip for Eli Security"'); + expect(html).toContain("Sent"); + expect(html).toContain("Record decision"); + expect(html).toContain("Mark settled"); + }); + + test("renders share dialog with expiry selector, numbered reviewer inputs, Save & download .zip button, and cancel button", () => { + const html = renderToStaticMarkup( + {}} + onCancel={() => {}} + onFault={() => {}} + />, + ); + expect(html).toContain('role="dialog"'); + expect(html).toContain('aria-label="Share approval package"'); + expect(html).toContain('aria-label="Expires in"'); + expect(html).toContain('aria-label="Reviewer 1"'); + expect(html).toContain('aria-label="Reviewer 1 Pick from workspace"'); + expect(html).toContain("Dana Chen (CEO)"); + expect(html).toContain("Marcus Vance (CTO)"); + expect(html).toContain("Priya Patel (VP Product)"); + expect(html).toContain('aria-label="Reviewer 1 Reviewer name"'); + expect(html).toContain('aria-label="Reviewer 1 Persona"'); + expect(html).toContain("Budget approver"); + expect(html).toContain("Technical approver"); + expect(html).toContain("Other…"); + expect(html).toContain('aria-label="Reviewer 1 Email (optional)"'); + expect(html).toContain("Save to workspace?"); + expect(html).toContain( + "Saves this reviewer to the workspace so you can pick them in any project without retyping their details.", + ); + expect(html).toContain("Add reviewer"); + expect(html).toContain("Save & download .zip"); + expect(html).toContain("Cancel"); + }); + + test("renders WorkspaceApprovals settings section with require stakeholder approval toggle and seeded workspace reviewers", () => { + const html = renderToStaticMarkup(); + expect(html).toContain("Workspace approvals"); + expect(html).toContain("Require stakeholder approval"); + expect(html).toContain("Workspace reviewers"); + expect(html).toContain("Dana Chen"); + expect(html).toContain("CEO"); + expect(html).toContain("Marcus Vance"); + expect(html).toContain("CTO"); + expect(html).toContain("Priya Patel"); + expect(html).toContain("VP Product"); + expect(html).toContain("Remove"); + expect(html).toContain("Add reviewer"); + expect(html).toContain("Other…"); + }); +}); diff --git a/apps/web/src/pages/workspace/approval-rounds.tsx b/apps/web/src/pages/workspace/approval-rounds.tsx new file mode 100644 index 00000000..ad5312ad --- /dev/null +++ b/apps/web/src/pages/workspace/approval-rounds.tsx @@ -0,0 +1,1104 @@ +import { + Badge, + Button, + Input, + Select, + Switch, + Textarea, +} from "@corbits/react-ui"; +import type { + ApprovalReviewer, + ApprovalRound, + ApprovalRoundStatus, + ExpiryPreset, + ReviewerInput, + ReviewerOutcome, + ReviewerState, + TriageAction, + TriageItem, + TriageItemStatus, +} from "@corbits-builder/schemas/approval-round"; +import { Download, Paperclip } from "lucide-react"; +import { useContext, useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { + downloadApprovalPackageZip, + listApprovalRounds, + recordReviewerDecision, + settleApprovalRound, + startApprovalRound, + triageReviewerFeedback, +} from "../../api/artifacts.ts"; +import { wording } from "../../config.ts"; +import { queryClient } from "../../queries/client.ts"; +import { keys } from "../../queries/keys.ts"; +import { + addCustomReviewerPersona, + addWorkspaceReviewer, + allReviewerPersonas, + mergeWorkspaceReviewers, + useCustomReviewerPersonas, + useWorkspaceReviewers, +} from "../../workspace-approvals-setting.ts"; +import { humanFault } from "./human-fault.ts"; +import { DesignTarget } from "./mockup-tweaks.tsx"; + +const roundText = wording.approvalRound; +const OTHER_PERSONA_VALUE = "__other__"; +const EXPIRY_OPTIONS: readonly ExpiryPreset[] = ["24h", "7d", "30d"]; +const OUTCOME_OPTIONS: readonly ReviewerOutcome[] = [ + "approve", + "request_changes", + "comment", +]; + +type BadgeTone = "neutral" | "accent" | "success" | "warning" | "danger"; + +const ROUND_STATUS_TONE: Readonly> = { + open: "neutral", + settled: "neutral", + expired: "neutral", +}; + +const REVIEWER_STATE_TONE: Readonly> = { + sent: "neutral", + decided: "neutral", + expired: "neutral", +}; + +const OUTCOME_TONE: Readonly> = { + approve: "neutral", + request_changes: "neutral", + comment: "neutral", +}; + +const TRIAGE_STATUS_TONE: Readonly> = { + pending: "neutral", + incorporated: "neutral", + dismissed: "neutral", + replied: "neutral", +}; + +function triggerBrowserDownload(file: File) { + if ( + typeof document === "undefined" || + typeof URL.createObjectURL !== "function" + ) { + return; + } + const href = URL.createObjectURL(file); + const link = document.createElement("a"); + link.href = href; + link.download = file.name; + link.click(); + setTimeout(() => URL.revokeObjectURL(href)); +} + +type ReviewerDraft = ReviewerInput & { + key: string; + workspaceReviewerId?: string | undefined; + customPersonaOpen?: boolean | undefined; + customPersonaDraft?: string | undefined; +}; + +type AttachedFeedbackFile = { + name: string; + block: string; +}; + +export function ReviewerDecisionEditor({ + projectId, + roundId, + reviewer, + onUpdated, +}: { + projectId: string; + roundId: string; + reviewer: ApprovalReviewer; + onUpdated: (round: ApprovalRound) => void; +}) { + const existing = reviewer.decision; + const initialOutcome: ReviewerOutcome = + existing === undefined ? "approve" : existing.outcome; + const initialNote = + existing === undefined || existing.note === undefined ? "" : existing.note; + + const fileInputRef = useRef(null); + const [open, setOpen] = useState(false); + const [outcome, setOutcome] = useState(initialOutcome); + const [note, setNote] = useState(initialNote); + const [attachedFiles, setAttachedFiles] = useState( + [], + ); + const [saving, setSaving] = useState(false); + const [fault, setFault] = useState(null); + + const resetFromDecision = () => { + const current = reviewer.decision; + setOutcome(current === undefined ? "approve" : current.outcome); + setNote( + current === undefined || current.note === undefined ? "" : current.note, + ); + setAttachedFiles([]); + setFault(null); + }; + + const openEditor = () => { + resetFromDecision(); + setOpen(true); + }; + + const cancelEditor = () => { + resetFromDecision(); + setOpen(false); + }; + + const readAttachedFiles = async (fileList: FileList | null) => { + if (fileList === null || fileList.length === 0) return; + setFault(null); + try { + const additions: AttachedFeedbackFile[] = []; + for (const file of Array.from(fileList)) { + const raw = await file.text(); + const trimmed = raw.trim(); + if (trimmed === "") continue; + additions.push({ + name: file.name, + block: `### ${file.name}\n${trimmed}`, + }); + } + if (additions.length === 0) return; + setAttachedFiles((current) => [...current, ...additions]); + setNote((current) => { + const blocks = additions.map((entry) => entry.block).join("\n\n"); + return current.trim() === "" + ? blocks + : `${current.trim()}\n\n${blocks}`; + }); + } catch (error) { + setFault(humanFault(error, roundText.failed)); + } + }; + + const removeAttachedFile = (index: number) => { + const target = attachedFiles[index]; + if (target === undefined) return; + setAttachedFiles((current) => + current.filter((_, itemIndex) => itemIndex !== index), + ); + setNote((current) => + current + .replace(target.block, "") + .replace(/\n{3,}/g, "\n\n") + .trim(), + ); + }; + + const submit = async () => { + setSaving(true); + setFault(null); + try { + const trimmed = note.trim(); + const updated = await recordReviewerDecision( + projectId, + roundId, + reviewer.id, + { + outcome, + ...(trimmed === "" ? {} : { note: trimmed }), + }, + ); + onUpdated(updated); + setOpen(false); + } catch (error) { + setFault(humanFault(error, roundText.failed)); + } finally { + setSaving(false); + } + }; + + if (!open) { + return ( + + ); + } + + return ( +
event.preventDefault()} + onDrop={(event) => { + event.preventDefault(); + void readAttachedFiles(event.dataTransfer.files); + }} + > + {fault === null ? null : ( +

+ {fault} +

+ )} + +