diff --git a/bun.lock b/bun.lock index 7ca3d49c..dd3c986b 100644 --- a/bun.lock +++ b/bun.lock @@ -47,6 +47,7 @@ "@corbits-builder/presenter": "workspace:*", "@corbits-builder/schemas": "workspace:*", "@corbits-builder/writer": "workspace:*", + "@corbits/slidedeck": "workspace:*", "@emulators/core": "0.12.3", "@emulators/google": "0.12.3", "@intx/sidecar-app": "workspace:*", diff --git a/package.json b/package.json index 5acbdb75..2f037ac7 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "@corbits-builder/presenter": "workspace:*", "@corbits-builder/schemas": "workspace:*", "@corbits-builder/writer": "workspace:*", + "@corbits/slidedeck": "workspace:*", "@emulators/core": "0.12.3", "@emulators/google": "0.12.3", "@intx/sidecar-app": "workspace:*", diff --git a/scripts/seed-projects.ts b/scripts/seed-projects.ts index 49aefb9a..1c24c9a8 100644 --- a/scripts/seed-projects.ts +++ b/scripts/seed-projects.ts @@ -8,10 +8,17 @@ import { import { readHubEnvironment } from "@corbits-builder/hub/environment"; import { migrateDatabase } from "@corbits-builder/hub/migrate"; import type { ApproachesMeta } from "@corbits-builder/schemas/approaches"; +import { + ApprovalRound, + type ApprovalRound as ApprovalRoundType, + type ExpiryPreset, +} from "@corbits-builder/schemas/approval-round"; import { AppBundle, + ApprovalPackageBundle, DesignBundle, DocumentWrite, + type ApprovalPackageSource, type ArtifactContent, type ArtifactList, type ArtifactListItem, @@ -55,6 +62,8 @@ import { loadSeedProjectFixture, loadSeedProjectFixtures, resolveSeedFixtures, + type SeedApprovalPackageFixture, + type SeedApprovalRoundMetadata, type SeedDesignOptionFixture, type SeedFixtureId, type SeedFixtureSelector, @@ -70,6 +79,8 @@ export { loadSeedProjectFixture, loadSeedProjectFixtures, resolveSeedFixtures, + type SeedApprovalPackageFixture, + type SeedApprovalRoundMetadata, type SeedDesignOptionFixture, type SeedFixtureId, type SeedFixtureSelector, @@ -91,6 +102,12 @@ const DEFAULT_DEV_USER = { workspaceSlug: "dev-workspace", } as const; +const EXPIRY_MS: Readonly> = { + "24h": 24 * 60 * 60 * 1000, + "7d": 7 * 24 * 60 * 60 * 1000, + "30d": 30 * 24 * 60 * 60 * 1000, +}; + const projectTable = pgTable("project", { id: text("id").primaryKey(), tenantId: text("tenant_id").notNull(), @@ -132,6 +149,25 @@ const artifactImportApprovalTable = pgTable( (table) => [primaryKey({ columns: [table.artifactId, table.version] })], ); +const approvalRoundTable = pgTable("sb_approval_round", { + id: text("id").primaryKey(), + tenantId: text("tenant_id").notNull(), + projectId: text("project_id").notNull(), + artifactId: text("artifact_id").notNull(), + packageVersion: integer("package_version").notNull(), + packageSha256: text("package_sha256").notNull(), + startedBy: text("started_by").notNull(), + 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(), +}); + export interface ResolvedWorkspaceTenant { tenantId: string; principalId: string; @@ -151,6 +187,13 @@ export interface SeedArtifactWriteInput { approaches?: ApproachesMeta; } +export interface SeedApprovalRoundInput { + artifactId: string; + packageVersion: number; + packageSha256: string; + round: SeedApprovalRoundMetadata; +} + export interface SeedDeps { resolveWorkspaceTenant(tenantId?: string): Promise; createProject( @@ -176,6 +219,11 @@ export interface SeedDeps { projectId: string, input: { artifactId: string; version: number }, ): Promise; + seedApprovalRound( + actor: SeedActor, + projectId: string, + input: SeedApprovalRoundInput, + ): Promise; shipProject(actor: SeedActor, projectId: string): Promise; listArtifacts(actor: SeedActor, projectId: string): Promise; } @@ -199,6 +247,7 @@ export interface SeededProjectSummary { shippedAt: string | null; artifacts: ArtifactListItem[]; awaitingApproval: string[]; + approvalRound?: ApprovalRoundType; } export interface SeedDevelopmentProjectsResult { @@ -247,6 +296,7 @@ export async function seedDevelopmentProjects( }); const { actor, projectId } = created; const docs = fixture.documents; + const sourcePins: ApprovalPackageSource[] = []; if (reachesStage(stage, "brief")) { const briefRef = await deps.writeArtifact(actor, projectId, { @@ -254,6 +304,7 @@ export async function seedDevelopmentProjects( title: docs.problem_brief.title, content: docs.problem_brief.content, }); + sourcePins.push({ ...briefRef, title: docs.problem_brief.title }); if (approve || isPastStage(stage, "brief")) { await deps.recordArtifactApproval(actor, projectId, briefRef); } @@ -265,17 +316,23 @@ export async function seedDevelopmentProjects( title: docs.constraints.title, content: docs.constraints.content, }); + sourcePins.push({ ...constraintsRef, title: docs.constraints.title }); const approachesRef = await deps.writeArtifact(actor, projectId, { kind: "approaches", title: docs.approaches.title, content: docs.approaches.content, approaches: docs.approaches.approaches, }); + sourcePins.push({ ...approachesRef, title: docs.approaches.title }); const prdRef = await deps.writeArtifact(actor, projectId, { kind: "product_requirements", title: docs.product_requirements.title, content: docs.product_requirements.content, }); + sourcePins.push({ + ...prdRef, + title: docs.product_requirements.title, + }); if (approve || isPastStage(stage, "planning")) { await deps.recordArtifactApproval(actor, projectId, constraintsRef); await deps.recordArtifactApproval(actor, projectId, approachesRef); @@ -306,10 +363,14 @@ export async function seedDevelopmentProjects( `Fixture ${fixture.id} is missing chosen design option "${fixture.chosenDesignOption}"`, ); } - await deps.chooseDesign(actor, projectId, { + const chosenDesign = await deps.chooseDesign(actor, projectId, { artifactId: pickedDesignOptionRef.artifactId, version: pickedDesignOptionRef.version, }); + sourcePins.push({ + ...chosenDesign.design, + title: chosenDesign.concept, + }); } if (reachesStage(stage, "ready_to_build")) { @@ -318,11 +379,43 @@ export async function seedDevelopmentProjects( title: docs.build_plan.title, content: docs.build_plan.content, }); + sourcePins.push({ ...buildPlanRef, title: docs.build_plan.title }); if (approve || isPastStage(stage, "ready_to_build")) { await deps.recordArtifactApproval(actor, projectId, buildPlanRef); } } + let approvalRound: ApprovalRoundType | undefined; + if ( + reachesStage(stage, "ready_to_build") && + fixture.approvalPackage !== undefined + ) { + const livePackageBundle = ApprovalPackageBundle.assert({ + ...fixture.approvalPackage.bundle, + manifest: { + ...fixture.approvalPackage.bundle.manifest, + sources: + sourcePins.length > 0 + ? sourcePins + : fixture.approvalPackage.bundle.manifest.sources, + }, + }); + const packageRef = await deps.writeArtifact(actor, projectId, { + kind: "approval_package", + title: fixture.approvalPackage.title, + content: JSON.stringify(livePackageBundle), + }); + if (fixture.approvalRound !== undefined) { + await deps.recordArtifactApproval(actor, projectId, packageRef); + approvalRound = await deps.seedApprovalRound(actor, projectId, { + artifactId: packageRef.artifactId, + packageVersion: packageRef.version, + packageSha256: packageRef.sha256, + round: fixture.approvalRound, + }); + } + } + let shippedAt: string | null = null; if (reachesStage(stage, "shipped")) { await deps.writeArtifact(actor, projectId, { @@ -347,6 +440,7 @@ export async function seedDevelopmentProjects( shippedAt, artifacts: listed.artifacts, awaitingApproval: listed.awaitingApproval, + ...(approvalRound === undefined ? {} : { approvalRound }), }); } @@ -625,6 +719,8 @@ export function createDatabaseSeedDeps( DesignBundle.assert(JSON.parse(input.content)); } else if (input.kind === "app") { AppBundle.assert(JSON.parse(input.content)); + } else if (input.kind === "approval_package") { + ApprovalPackageBundle.assert(JSON.parse(input.content)); } else { DocumentWrite.assert(input); } @@ -634,7 +730,9 @@ export function createDatabaseSeedDeps( ...(input.approaches ? { approaches: input.approaches } : {}), }; const origin = - input.kind === "design_option" || input.kind === "app" + input.kind === "design_option" || + input.kind === "app" || + input.kind === "approval_package" ? "workflow" : "user"; @@ -715,6 +813,54 @@ export function createDatabaseSeedDeps( }; }, + async seedApprovalRound(actor, projectId, input) { + const roundId = crypto.randomUUID(); + const now = new Date(); + const expiresAt = new Date(now.getTime() + EXPIRY_MS[input.round.expiry]); + const triage = input.round.triage.map((item) => ({ + ...item, + roundId, + })); + await db.insert(approvalRoundTable).values({ + id: roundId, + tenantId: actor.tenantId, + projectId, + artifactId: input.artifactId, + packageVersion: input.packageVersion, + packageSha256: input.packageSha256, + startedBy: actor.principalId, + expiryPreset: input.round.expiry, + expiresAt, + settledAt: null, + reviewers: input.round.reviewers, + triage, + createdAt: now, + }); + return ApprovalRound.assert({ + roundId, + projectId, + artifactId: input.artifactId, + packageVersion: input.packageVersion, + packageSha256: input.packageSha256, + expiry: input.round.expiry, + status: "open", + createdAt: now.toISOString(), + expiresAt: expiresAt.toISOString(), + reviewers: input.round.reviewers.map((reviewer) => ({ + id: reviewer.id, + name: reviewer.name, + persona: reviewer.persona, + ...(reviewer.email === undefined ? {} : { email: reviewer.email }), + ...(reviewer.goal === undefined ? {} : { goal: reviewer.goal }), + state: reviewer.decision === undefined ? "sent" : "decided", + ...(reviewer.decision === undefined + ? {} + : { decision: reviewer.decision }), + })), + triage, + }); + }, + async shipProject(_actor, projectId) { const now = new Date(); await db @@ -769,7 +915,8 @@ export function createDatabaseSeedDeps( approvedVersion !== item.version && item.kind !== "design_option" && item.kind !== "design" && - item.kind !== "app" + item.kind !== "app" && + item.kind !== "approval_package" ) { awaitingApproval.push(item.kind); } @@ -931,6 +1078,7 @@ export async function main(argv: readonly string[] = process.argv.slice(2)) { shippedAt: project.shippedAt, artifactKinds: project.artifacts.map((item) => item.kind), awaitingApproval: project.awaitingApproval, + approvalRoundId: project.approvalRound?.roundId ?? null, })), }, null, diff --git a/test/integration/seed-projects.test.ts b/test/integration/seed-projects.test.ts index 0cbeb748..b699bd4a 100644 --- a/test/integration/seed-projects.test.ts +++ b/test/integration/seed-projects.test.ts @@ -1,8 +1,10 @@ import { describe, expect, test } from "bun:test"; import { existsSync } from "node:fs"; import { join } from "node:path"; +import { ApprovalRound } from "@corbits-builder/schemas/approval-round"; import { AppBundle, + ApprovalPackageBundle, DesignBundle, DocumentWrite, type ArtifactList, @@ -146,6 +148,37 @@ function createInMemorySeedDeps(): SeedDeps { }; }, + async seedApprovalRound(_actor, projectId, input) { + const roundId = `round-${projectId}`; + const triage = input.round.triage.map((item) => ({ + ...item, + roundId, + })); + return ApprovalRound.assert({ + roundId, + projectId, + artifactId: input.artifactId, + packageVersion: input.packageVersion, + packageSha256: input.packageSha256, + expiry: input.round.expiry, + status: "open", + createdAt: "2026-11-01T10:00:00.000Z", + expiresAt: "2026-11-08T10:00:00.000Z", + reviewers: input.round.reviewers.map((reviewer) => ({ + id: reviewer.id, + name: reviewer.name, + persona: reviewer.persona, + ...(reviewer.email === undefined ? {} : { email: reviewer.email }), + ...(reviewer.goal === undefined ? {} : { goal: reviewer.goal }), + state: reviewer.decision === undefined ? "sent" : "decided", + ...(reviewer.decision === undefined + ? {} + : { decision: reviewer.decision }), + })), + triage, + }); + }, + async shipProject(_actor, projectId) { const state = projects.get(projectId); if (!state) { @@ -177,7 +210,8 @@ function createInMemorySeedDeps(): SeedDeps { !entry.approved && entry.kind !== "design_option" && entry.kind !== "design" && - entry.kind !== "app" + entry.kind !== "app" && + entry.kind !== "approval_package" ) { awaitingApproval.push(entry.kind); } @@ -247,6 +281,17 @@ describe("test/seed fixtures on disk", () => { expect(option.bundle.files["designs/screens/home.html"]).toBeDefined(); } AppBundle.assert(fixture.app); + + if (fixtureId === "clinical-trial-intake") { + expect(existsSync(join(dir, "approval-package", "deck.json"))).toBe( + true, + ); + expect(fixture.approvalPackage).toBeDefined(); + ApprovalPackageBundle.assert(fixture.approvalPackage!.bundle); + expect(fixture.approvalRound).toBeDefined(); + expect(fixture.approvalRound!.reviewers).toHaveLength(2); + expect(fixture.approvalRound!.triage).toHaveLength(3); + } } }); @@ -279,6 +324,28 @@ describe("test/seed fixtures on disk", () => { "approaches", "product_requirements", ]); + + const clinicalDeps = createInMemorySeedDeps(); + const clinicalSeeded = await seedDevelopmentProjects(clinicalDeps, { + fixture: "clinical-trial-intake", + stage: "ready_to_build", + approve: true, + }); + const clinicalProject = clinicalSeeded.projects[0]!; + expect(clinicalProject.awaitingApproval).toEqual([]); + expect(clinicalProject.artifacts.map((item) => item.kind)).toContain( + "approval_package", + ); + const clinicalPackage = clinicalProject.artifacts.find( + (item) => item.kind === "approval_package", + ); + expect(clinicalPackage?.approvedVersion).toBe(1); + expect(clinicalProject.approvalRound).toBeDefined(); + expect(clinicalProject.approvalRound!.status).toBe("open"); + expect( + clinicalProject.approvalRound!.reviewers.map((r) => r.state), + ).toEqual(["decided", "sent"]); + expect(clinicalProject.approvalRound!.triage).toHaveLength(3); }); test("parses CLI arguments and renders help usage", () => { diff --git a/test/seed/clinical-trial-intake/approval-package/deck.json b/test/seed/clinical-trial-intake/approval-package/deck.json new file mode 100644 index 00000000..ac5dbb75 --- /dev/null +++ b/test/seed/clinical-trial-intake/approval-package/deck.json @@ -0,0 +1,117 @@ +{ + "theme": { + "colors": { + "primary": "#1D5BD6", + "secondary": "#334155", + "accent": "#0284C7", + "background": "#F8FAFC", + "text": "#0F172A", + "muted": "#475569" + }, + "fonts": { + "heading": "Inter", + "body": "Inter" + } + }, + "footer": { + "text": "Clinical Trial Patient Intake & Consent Portal — Stakeholder Review", + "slideNumbers": true + }, + "slides": [ + { + "layout": "title", + "title": "Clinical Trial Intake & Consent", + "subtitle": "IRB Protocol Gate, Eligibility Screening & Immutable Audit Ledger" + }, + { + "layout": "content", + "title": "Problem & Regulatory Constraints", + "body": [ + { + "type": "paragraph", + "text": "Multi-site oncology trials lose enrollment velocity and risk audit findings when screening checklists and IRB consent versions are tracked in spreadsheets." + }, + { + "type": "list", + "ordered": false, + "items": [ + "Zero enrollments permitted when any exclusion criterion is triggered.", + "Every e-consent must match the currently active IRB protocol version.", + "Every candidate state transition writes an append-only audit entry." + ] + } + ] + }, + { + "layout": "comparison", + "title": "Chosen Approach: Coordinator Workbench", + "left": { + "heading": "Chosen: Coordinator Workbench", + "body": [ + { + "type": "list", + "ordered": false, + "items": [ + "Criterion-level inclusion and exclusion validation.", + "Automatic outdated IRB consent detection.", + "Dedicated Principal Investigator sign-off queue." + ] + } + ] + }, + "right": { + "heading": "Rejected: Static Form Wizard", + "body": [ + { + "type": "list", + "ordered": false, + "items": [ + "Free-text notes allow accidental protocol deviations.", + "No automatic check against active IRB version.", + "Manual status overrides bypass PI sign-off." + ] + } + ] + } + }, + { + "layout": "two-column", + "title": "Design & Build Plan Summary", + "left": [ + { + "type": "paragraph", + "text": "Rounded Enterprise Visual System" + }, + { + "type": "list", + "ordered": false, + "items": [ + "Calm clinical blue (#1D5BD6) primary actions.", + "Semantic status badges for eligibility and consent.", + "3-column coordinator workbench layout." + ] + } + ], + "right": [ + { + "type": "paragraph", + "text": "Verification & Delivery Plan" + }, + { + "type": "list", + "ordered": true, + "items": [ + "SQLite schema for candidates, criteria, consents, and audit events.", + "Server-side eligibility and IRB version enforcement.", + "End-to-end Playwright verification of gate refusals." + ] + } + ] + }, + { + "layout": "section", + "title": "Decision: Approve Build Plan & Gates", + "subtitle": "Sign off on IRB consent version enforcement and PI review queue before implementation." + } + ] +} diff --git a/test/seed/clinical-trial-intake/project.json b/test/seed/clinical-trial-intake/project.json index c6efb841..6c4feef4 100644 --- a/test/seed/clinical-trial-intake/project.json +++ b/test/seed/clinical-trial-intake/project.json @@ -99,5 +99,70 @@ ], "app": { "path": "app" + }, + "approvalPackage": { + "title": "IRB & Clinical Operations Approval Package", + "audience": "Principal Investigator, IRB Chair & Clinical Operations Lead", + "decisionNeeded": "Approve protocol eligibility gate rules, IRB e-consent version enforcement, and the coordinator workbench build plan.", + "tone": "Regulatory, rigorous, and concise", + "length": "5 slides", + "deckPath": "approval-package/deck.json" + }, + "approvalRound": { + "expiry": "7d", + "reviewers": [ + { + "id": "reviewer-elena-vance", + "name": "Dr. Elena Vance", + "persona": "Principal Investigator & IRB Chair", + "email": "elena.vance@clinical.example", + "goal": "Verify IRB protocol version enforcement, exclusion criteria gates, and audit log immutability.", + "decision": { + "outcome": "request_changes", + "note": "- Require a mandatory clinical rationale note whenever a coordinator flags a borderline screening criterion\n- Show the IRB approval effective date next to the protocol version badge", + "slideNotes": [ + { + "slide": 2, + "text": "Specify that any active IRB protocol amendment locks enrollment until re-consent is recorded." + } + ], + "decidedAt": "2026-11-01T10:30:00.000Z" + } + }, + { + "id": "reviewer-marcus-thorne", + "name": "Marcus Thorne", + "persona": "Clinical Operations Director", + "email": "marcus.thorne@clinical.example", + "goal": "Confirm coordinator intake throughput and multi-site screening queue visibility." + } + ], + "triage": [ + { + "itemId": "triage-item-1", + "reviewerId": "reviewer-elena-vance", + "reviewerName": "Dr. Elena Vance", + "persona": "Principal Investigator & IRB Chair", + "text": "Require a mandatory clinical rationale note whenever a coordinator flags a borderline screening criterion", + "status": "pending" + }, + { + "itemId": "triage-item-2", + "reviewerId": "reviewer-elena-vance", + "reviewerName": "Dr. Elena Vance", + "persona": "Principal Investigator & IRB Chair", + "text": "Show the IRB approval effective date next to the protocol version badge", + "status": "pending" + }, + { + "itemId": "triage-item-3", + "reviewerId": "reviewer-elena-vance", + "reviewerName": "Dr. Elena Vance", + "persona": "Principal Investigator & IRB Chair", + "slide": 2, + "text": "Specify that any active IRB protocol amendment locks enrollment until re-consent is recorded.", + "status": "pending" + } + ] } } diff --git a/test/seed/index.ts b/test/seed/index.ts index bc8e675b..a6a6e004 100644 --- a/test/seed/index.ts +++ b/test/seed/index.ts @@ -1,10 +1,26 @@ +import { createHash } from "node:crypto"; import { readFile, readdir } from "node:fs/promises"; import { join, relative, resolve } from "node:path"; import type { ApproachesMeta } from "@corbits-builder/schemas/approaches"; import type { - AppBundle, - DesignBundle as DesignBundleType, + ExpiryPreset, + ReviewerDecision, + TriageItemStatus, +} from "@corbits-builder/schemas/approval-round"; +import { + ApprovalPackageBundle, + type AppBundle, + type ApprovalPackageBundle as ApprovalPackageBundleType, + type ApprovalPackageSource, + type DesignBundle as DesignBundleType, } from "@corbits-builder/schemas/artifact"; +import { base64Encode } from "@intx/types"; +import { + checkDeck, + validateDeck, + writeDeckPdf, + writePptx, +} from "@corbits/slidedeck"; export const SEED_STAGES = [ "idea", @@ -35,6 +51,41 @@ export type SeedDesignOptionMetadata = Readonly<{ path: string; }>; +export type SeedApprovalPackageMetadata = Readonly<{ + title: string; + audience: string; + decisionNeeded: string; + tone: string; + length: string; + deckPath: string; +}>; + +export type SeedApprovalReviewerMetadata = Readonly<{ + id: string; + name: string; + persona: string; + email?: string; + goal?: string; + decision?: ReviewerDecision; +}>; + +export type SeedApprovalTriageMetadata = Readonly<{ + itemId: string; + reviewerId: string; + reviewerName: string; + persona: string; + slide?: number; + text: string; + status: TriageItemStatus; + reply?: string; +}>; + +export type SeedApprovalRoundMetadata = Readonly<{ + expiry: ExpiryPreset; + reviewers: readonly SeedApprovalReviewerMetadata[]; + triage: readonly SeedApprovalTriageMetadata[]; +}>; + export type SeedProjectMetadata = Readonly<{ slug: SeedFixtureId; id: SeedFixtureId; @@ -59,6 +110,8 @@ export type SeedProjectMetadata = Readonly<{ }>; designOptions: readonly [SeedDesignOptionMetadata, SeedDesignOptionMetadata]; app: Readonly<{ path: string }>; + approvalPackage?: SeedApprovalPackageMetadata; + approvalRound?: SeedApprovalRoundMetadata; }>; export type SeedDesignOptionFixture = Readonly<{ @@ -68,6 +121,11 @@ export type SeedDesignOptionFixture = Readonly<{ bundle: DesignBundleType; }>; +export type SeedApprovalPackageFixture = Readonly<{ + title: string; + bundle: ApprovalPackageBundleType; +}>; + export type SeedProjectFixture = Readonly<{ id: SeedFixtureId; slug: SeedFixtureId; @@ -92,6 +150,8 @@ export type SeedProjectFixture = Readonly<{ }>; designOptions: readonly [SeedDesignOptionFixture, SeedDesignOptionFixture]; app: AppBundle; + approvalPackage?: SeedApprovalPackageFixture; + approvalRound?: SeedApprovalRoundMetadata; }>; export const DEFAULT_SEED_ROOT = resolve(import.meta.dir); @@ -154,6 +214,104 @@ async function loadDesignOptionBundle( return { files: bundleFiles }; } +function sha256Hex(content: string): string { + return createHash("sha256").update(content).digest("hex"); +} + +async function loadApprovalPackageFixture( + projectDir: string, + metadata: SeedProjectMetadata, + pkgMeta: SeedApprovalPackageMetadata, + docs: SeedProjectFixture["documents"], +): Promise { + const deckRaw = await readFile(resolve(projectDir, pkgMeta.deckPath), "utf8"); + const validated = validateDeck(JSON.parse(deckRaw)); + if (!validated.ok) { + throw new Error( + `Invalid seed slide deck in ${projectDir}: ${JSON.stringify(validated.issues)}`, + ); + } + const issues = checkDeck(validated.deck, new Map()); + if (issues.length > 0) { + throw new Error( + `Seed slide deck check failed in ${projectDir}: ${JSON.stringify(issues)}`, + ); + } + const pptxBytes = await writePptx(validated.deck, new Map()); + const pdfBytes = writeDeckPdf(validated.deck, new Map()); + + const defaultSources: ApprovalPackageSource[] = [ + { + artifactId: `seed-${metadata.id}-problem_brief`, + kind: "problem_brief", + title: docs.problem_brief.title, + version: 1, + sha256: sha256Hex(docs.problem_brief.content), + }, + { + artifactId: `seed-${metadata.id}-constraints`, + kind: "constraints", + title: docs.constraints.title, + version: 1, + sha256: sha256Hex(docs.constraints.content), + }, + { + artifactId: `seed-${metadata.id}-approaches`, + kind: "approaches", + title: docs.approaches.title, + version: 1, + sha256: sha256Hex(docs.approaches.content), + }, + { + artifactId: `seed-${metadata.id}-product_requirements`, + kind: "product_requirements", + title: docs.product_requirements.title, + version: 1, + sha256: sha256Hex(docs.product_requirements.content), + }, + { + artifactId: `seed-${metadata.id}-design`, + kind: "design", + title: docs.design.title, + version: 1, + sha256: sha256Hex(docs.design.content), + }, + { + artifactId: `seed-${metadata.id}-build_plan`, + kind: "build_plan", + title: docs.build_plan.title, + version: 1, + sha256: sha256Hex(docs.build_plan.content), + }, + ]; + + const bundle = ApprovalPackageBundle.assert({ + manifest: { + audience: pkgMeta.audience, + decisionNeeded: pkgMeta.decisionNeeded, + tone: pkgMeta.tone, + length: pkgMeta.length, + sources: defaultSources, + }, + deck: validated.deck as unknown as Record, + pptxBase64: base64Encode(pptxBytes), + pdfBase64: base64Encode(pdfBytes), + documents: { + problem_brief: docs.problem_brief.content, + constraints: docs.constraints.content, + approaches: docs.approaches.content, + product_requirements: docs.product_requirements.content, + design: docs.design.content, + build_plan: docs.build_plan.content, + }, + }); + + return { + title: pkgMeta.title, + bundle, + }; +} + export async function loadSeedProjectFixture( id: SeedFixtureId, seedRootDir: string = DEFAULT_SEED_ROOT, @@ -190,6 +348,44 @@ export async function loadSeedProjectFixture( readDirectoryFiles(resolve(projectDir, metadata.app.path)), ]); + const documents: SeedProjectFixture["documents"] = { + problem_brief: { + title: metadata.documents.problem_brief.title, + content: problemBriefContent, + }, + constraints: { + title: metadata.documents.constraints.title, + content: constraintsContent, + }, + approaches: { + title: metadata.documents.approaches.title, + content: approachesContent, + approaches: metadata.documents.approaches.approaches, + }, + product_requirements: { + title: metadata.documents.product_requirements.title, + content: productRequirementsContent, + }, + design: { + title: metadata.documents.design.title, + content: designContent, + }, + build_plan: { + title: metadata.documents.build_plan.title, + content: buildPlanContent, + }, + }; + + const approvalPackage = + metadata.approvalPackage === undefined + ? undefined + : await loadApprovalPackageFixture( + projectDir, + metadata, + metadata.approvalPackage, + documents, + ); + return { id: metadata.id, slug: metadata.slug, @@ -200,33 +396,7 @@ export async function loadSeedProjectFixture( problemStatement: metadata.problemStatement, chosenConceptId: metadata.chosenConceptId, chosenDesignOption: metadata.chosenDesignOption, - documents: { - problem_brief: { - title: metadata.documents.problem_brief.title, - content: problemBriefContent, - }, - constraints: { - title: metadata.documents.constraints.title, - content: constraintsContent, - }, - approaches: { - title: metadata.documents.approaches.title, - content: approachesContent, - approaches: metadata.documents.approaches.approaches, - }, - product_requirements: { - title: metadata.documents.product_requirements.title, - content: productRequirementsContent, - }, - design: { - title: metadata.documents.design.title, - content: designContent, - }, - build_plan: { - title: metadata.documents.build_plan.title, - content: buildPlanContent, - }, - }, + documents, designOptions: [ { conceptId: metadata.designOptions[0].conceptId, @@ -244,6 +414,10 @@ export async function loadSeedProjectFixture( app: { files: appFiles, }, + ...(approvalPackage === undefined ? {} : { approvalPackage }), + ...(metadata.approvalRound === undefined + ? {} + : { approvalRound: metadata.approvalRound }), }; }