Implement pipeline learnings UI

This commit is contained in:
Dotta committed 2026-06-10 20:39:58 +00:00
1 parent db9f071889
commit a236029aeb
25 files changed
+4422 -14

No files matched your search

@@ -0,0 +1,141 @@
CREATE TABLE IF NOT EXISTS "pipelines" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"company_id" uuid NOT NULL REFERENCES "companies"("id") ON DELETE CASCADE,
"name" text NOT NULL,
"description" text,
"status" text NOT NULL DEFAULT 'active',
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now()
);--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "pipelines_company_name_idx"
ON "pipelines" ("company_id", "name");--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "pipeline_stages" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"pipeline_id" uuid NOT NULL REFERENCES "pipelines"("id") ON DELETE CASCADE,
"name" text NOT NULL,
"kind" text NOT NULL DEFAULT 'open',
"position" integer NOT NULL DEFAULT 0,
"config" jsonb NOT NULL DEFAULT '{"variables":[]}'::jsonb,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now()
);--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "pipeline_stage_transitions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"pipeline_id" uuid NOT NULL REFERENCES "pipelines"("id") ON DELETE CASCADE,
"from_stage_id" uuid NOT NULL REFERENCES "pipeline_stages"("id") ON DELETE CASCADE,
"to_stage_id" uuid NOT NULL REFERENCES "pipeline_stages"("id") ON DELETE CASCADE,
"config" jsonb NOT NULL DEFAULT '{}'::jsonb,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
CONSTRAINT "pipeline_stage_transitions_pipeline_from_to_uq"
UNIQUE ("pipeline_id","from_stage_id","to_stage_id")
);--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "pipeline_stages_pipeline_idx"
ON "pipeline_stages" ("pipeline_id","position");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "pipeline_stage_transitions_pipeline_idx"
ON "pipeline_stage_transitions" ("pipeline_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "pipeline_stage_transitions_from_idx"
ON "pipeline_stage_transitions" ("from_stage_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "pipeline_stage_transitions_to_idx"
ON "pipeline_stage_transitions" ("to_stage_id");--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "pipeline_cases" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"company_id" uuid NOT NULL REFERENCES "companies"("id") ON DELETE CASCADE,
"pipeline_id" uuid NOT NULL REFERENCES "pipelines"("id") ON DELETE CASCADE,
"stage_id" uuid REFERENCES "pipeline_stages"("id") ON DELETE SET NULL,
"parent_case_id" uuid REFERENCES "pipeline_cases"("id") ON DELETE SET NULL,
"title" text NOT NULL,
"status" text NOT NULL DEFAULT 'open',
"fields" jsonb NOT NULL DEFAULT '{}'::jsonb,
"created_by_actor_id" text,
"created_by_actor_type" text,
"last_activity_at" timestamptz,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now()
);--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "pipeline_cases_pipeline_idx"
ON "pipeline_cases" ("pipeline_id","stage_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "pipeline_cases_stage_idx"
ON "pipeline_cases" ("stage_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "pipeline_cases_company_idx"
ON "pipeline_cases" ("company_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "pipeline_cases_stage_status_idx"
ON "pipeline_cases" ("pipeline_id","status");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "pipeline_cases_parent_idx"
ON "pipeline_cases" ("parent_case_id");--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "pipeline_case_events" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"company_id" uuid NOT NULL REFERENCES "companies"("id") ON DELETE CASCADE,
"pipeline_id" uuid NOT NULL REFERENCES "pipelines"("id") ON DELETE CASCADE,
"case_id" uuid NOT NULL REFERENCES "pipeline_cases"("id") ON DELETE CASCADE,
"kind" text NOT NULL,
"summary" text,
"payload" jsonb,
"created_at" timestamptz NOT NULL DEFAULT now()
);--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "pipeline_case_events_pipeline_idx"
ON "pipeline_case_events" ("pipeline_id","case_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "pipeline_case_events_case_idx"
ON "pipeline_case_events" ("case_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "pipeline_case_events_kind_idx"
ON "pipeline_case_events" ("kind");--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "pipeline_guidance_documents" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"pipeline_id" uuid NOT NULL REFERENCES "pipelines"("id") ON DELETE CASCADE,
"key" text NOT NULL,
"title" text NOT NULL,
"body" text NOT NULL,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
CONSTRAINT "pipeline_guidance_documents_pipeline_key_uq" UNIQUE ("pipeline_id", "key")
);--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "pipeline_case_issue_links" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"case_id" uuid NOT NULL REFERENCES "pipeline_cases"("id") ON DELETE CASCADE,
"issue_id" uuid NOT NULL REFERENCES "issues"("id") ON DELETE CASCADE,
"role" text NOT NULL DEFAULT 'automation',
"created_at" timestamptz NOT NULL DEFAULT now(),
CONSTRAINT "pipeline_case_issue_links_case_issue_uq" UNIQUE ("case_id","issue_id")
);--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "pipeline_case_issue_links_case_idx"
ON "pipeline_case_issue_links" ("case_id","issue_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "pipeline_case_issue_links_issue_idx"
ON "pipeline_case_issue_links" ("issue_id","case_id");--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "pipeline_case_blockers" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"case_id" uuid NOT NULL REFERENCES "pipeline_cases"("id") ON DELETE CASCADE,
"blocked_by_case_id" uuid NOT NULL REFERENCES "pipeline_cases"("id") ON DELETE CASCADE,
"reason" text,
"resolved" boolean NOT NULL DEFAULT false,
"created_at" timestamptz NOT NULL DEFAULT now(),
"resolved_at" timestamptz,
CONSTRAINT "pipeline_case_blockers_pair_uq" UNIQUE ("case_id","blocked_by_case_id")
);--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "pipeline_case_blockers_case_idx"
ON "pipeline_case_blockers" ("case_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "pipeline_case_blockers_blocked_by_idx"
ON "pipeline_case_blockers" ("blocked_by_case_id");
@@ -694,6 +694,13 @@
"when": 1780534200000,
"tag": "0098_project_icon",
"breakpoints": true
},
{
"idx": 99,
"version": "7",
"when": 1781123714191,
"tag": "0099_pipeline_foundation",
"breakpoints": true
}
]
}
+8
View File
@@ -24,6 +24,14 @@ export { agentWakeupRequests } from "./agent_wakeup_requests.js";
export { projects } from "./projects.js";
export { projectMemberships } from "./project_memberships.js";
export { projectWorkspaces } from "./project_workspaces.js";
export { pipelineCaseBlockers } from "./pipelines.js";
export { pipelineCaseEvents } from "./pipelines.js";
export { pipelineCaseIssueLinks } from "./pipelines.js";
export { pipelineCases } from "./pipelines.js";
export { pipelineGuidanceDocuments } from "./pipelines.js";
export { pipelineStages } from "./pipelines.js";
export { pipelineTransitions } from "./pipelines.js";
export { pipelines } from "./pipelines.js";
export { executionWorkspaces } from "./execution_workspaces.js";
export { environments } from "./environments.js";
export { environmentLeases } from "./environment_leases.js";
+165
View File
@@ -0,0 +1,165 @@
import {
type AnyPgColumn,
boolean,
index,
integer,
jsonb,
pgTable,
text,
timestamp,
uniqueIndex,
uuid,
} from "drizzle-orm/pg-core";
import type { PipelineStageConfig } from "@paperclipai/shared";
import { companies } from "./companies.js";
import { issues } from "./issues.js";
export const pipelines = pgTable(
"pipelines",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
name: text("name").notNull(),
description: text("description"),
status: text("status").notNull().default("active"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyNameIdx: index("pipelines_company_name_idx").on(table.companyId, table.name),
}),
);
export const pipelineStages = pgTable(
"pipeline_stages",
{
id: uuid("id").primaryKey().defaultRandom(),
pipelineId: uuid("pipeline_id").notNull().references(() => pipelines.id, { onDelete: "cascade" }),
name: text("name").notNull(),
kind: text("kind").notNull().default("open"),
position: integer("position").notNull().default(0),
config: jsonb("config").$type<PipelineStageConfig>().notNull().default({ variables: [] }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
pipelineIdx: index("pipeline_stages_pipeline_idx").on(table.pipelineId, table.position),
pipelinePositionUq: uniqueIndex("pipeline_stages_pipeline_position_uq").on(table.pipelineId, table.position),
}),
);
export const pipelineTransitions = pgTable(
"pipeline_stage_transitions",
{
id: uuid("id").primaryKey().defaultRandom(),
pipelineId: uuid("pipeline_id").notNull().references(() => pipelines.id, { onDelete: "cascade" }),
fromStageId: uuid("from_stage_id").notNull().references(() => pipelineStages.id, { onDelete: "cascade" }),
toStageId: uuid("to_stage_id").notNull().references(() => pipelineStages.id, { onDelete: "cascade" }),
config: jsonb("config").notNull().default({}),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
pipelineIdx: index("pipeline_stage_transitions_pipeline_idx").on(table.pipelineId),
fromIdx: index("pipeline_stage_transitions_from_idx").on(table.fromStageId),
toIdx: index("pipeline_stage_transitions_to_idx").on(table.toStageId),
uniqueTransition: uniqueIndex("pipeline_stage_transitions_pipeline_from_to_uq")
.on(table.pipelineId, table.fromStageId, table.toStageId),
}),
);
export const pipelineCases = pgTable(
"pipeline_cases",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
pipelineId: uuid("pipeline_id").notNull().references(() => pipelines.id, { onDelete: "cascade" }),
stageId: uuid("stage_id").references((): AnyPgColumn => pipelineStages.id, { onDelete: "set null" }),
parentCaseId: uuid("parent_case_id").references((): AnyPgColumn => pipelineCases.id, { onDelete: "set null" }),
title: text("title").notNull(),
status: text("status").notNull().default("open"),
fields: jsonb("fields").$type<Record<string, unknown>>().notNull().default({}),
createdByActorId: text("created_by_actor_id"),
createdByActorType: text("created_by_actor_type"),
lastActivityAt: timestamp("last_activity_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
pipelineIdx: index("pipeline_cases_pipeline_idx").on(table.pipelineId, table.stageId),
stageIdx: index("pipeline_cases_stage_idx").on(table.stageId),
companyIdx: index("pipeline_cases_company_idx").on(table.companyId),
stageStatusIdx: index("pipeline_cases_stage_status_idx").on(table.pipelineId, table.status),
parentIdx: index("pipeline_cases_parent_idx").on(table.parentCaseId),
}),
);
export const pipelineCaseEvents = pgTable(
"pipeline_case_events",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
pipelineId: uuid("pipeline_id").notNull().references(() => pipelines.id, { onDelete: "cascade" }),
caseId: uuid("case_id").notNull().references((): AnyPgColumn => pipelineCases.id, { onDelete: "cascade" }),
kind: text("kind").notNull(),
summary: text("summary"),
payload: jsonb("payload").$type<Record<string, unknown>>(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
pipelineIdx: index("pipeline_case_events_pipeline_idx").on(table.pipelineId, table.caseId),
caseIdx: index("pipeline_case_events_case_idx").on(table.caseId),
kindIdx: index("pipeline_case_events_kind_idx").on(table.kind),
}),
);
export const pipelineGuidanceDocuments = pgTable(
"pipeline_guidance_documents",
{
id: uuid("id").primaryKey().defaultRandom(),
pipelineId: uuid("pipeline_id").notNull().references(() => pipelines.id, { onDelete: "cascade" }),
key: text("key").notNull(),
title: text("title").notNull(),
body: text("body").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
pipelineIdx: index("pipeline_guidance_documents_pipeline_idx").on(table.pipelineId),
pipelineKeyUq: uniqueIndex("pipeline_guidance_documents_pipeline_key_uq").on(table.pipelineId, table.key),
}),
);
export const pipelineCaseIssueLinks = pgTable(
"pipeline_case_issue_links",
{
id: uuid("id").primaryKey().defaultRandom(),
caseId: uuid("case_id").notNull().references(() => pipelineCases.id, { onDelete: "cascade" }),
issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
role: text("role").notNull().default("automation"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
caseIdx: index("pipeline_case_issue_links_case_idx").on(table.caseId, table.issueId),
issueIdx: index("pipeline_case_issue_links_issue_idx").on(table.issueId, table.caseId),
caseIssueUq: uniqueIndex("pipeline_case_issue_links_case_issue_uq").on(table.caseId, table.issueId),
}),
);
export const pipelineCaseBlockers = pgTable(
"pipeline_case_blockers",
{
id: uuid("id").primaryKey().defaultRandom(),
caseId: uuid("case_id").notNull().references((): AnyPgColumn => pipelineCases.id, { onDelete: "cascade" }),
blockedByCaseId: uuid("blocked_by_case_id").notNull().references((): AnyPgColumn => pipelineCases.id, { onDelete: "cascade" }),
reason: text("reason"),
resolved: boolean("resolved").notNull().default(false),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
resolvedAt: timestamp("resolved_at", { withTimezone: true }),
},
(table) => ({
caseIdx: index("pipeline_case_blockers_case_idx").on(table.caseId),
blockedByIdx: index("pipeline_case_blockers_blocked_by_idx").on(table.blockedByCaseId),
casePairUq: uniqueIndex("pipeline_case_blockers_pair_uq").on(table.caseId, table.blockedByCaseId),
}),
);
+51
View File
@@ -1144,6 +1144,32 @@ export {
runRoutineIntakeFormSchema,
pipelineStageConfigSchema,
pipelineStageVariableSchema,
pipelineStageRunRoutineSchema,
pipelineStatusSchema,
pipelineStageKindSchema,
pipelineCaseIssueLinkRoleSchema,
createPipelineSchema,
updatePipelineSchema,
pipelineListQuerySchema,
createPipelineStageSchema,
updatePipelineStageSchema,
pipelineTransitionConfigSchema,
pipelineTransitionSchema,
setPipelineTransitionsSchema,
createPipelineGuidanceDocumentSchema,
listPipelineCasesQuerySchema,
listCompanyCaseEventsQuerySchema,
pipelineCaseIngestSchema,
pipelineCaseBatchIngestSchema,
updatePipelineCaseSchema,
transitionCaseSchema,
resolveCaseSuggestionSchema,
reviewCaseSchema,
reviewCasesQuerySchema,
bulkReviewCasesSchema,
createPipelineCaseBlockerSchema,
createPipelineCaseIssueLinkSchema,
openConversationPayloadSchema,
rotateRoutineTriggerSecretSchema,
routineRevisionSnapshotRoutineV1Schema,
routineRevisionSnapshotTriggerV1Schema,
@@ -1166,6 +1192,31 @@ export {
type RunRoutineIntakeForm,
type PipelineStageConfig,
type PipelineStageVariable,
type PipelineStageRunRoutineConfig,
type PipelineStatus,
type PipelineStageKind,
type PipelineCaseIssueLinkRole,
type CreatePipeline,
type UpdatePipeline,
type PipelineListQuery,
type CreatePipelineStage,
type UpdatePipelineStage,
type PipelineTransition,
type SetPipelineTransitions,
type PipelineGuidanceDocumentUpsert,
type ListPipelineCasesQuery,
type ListCompanyCaseEventsQuery,
type PipelineCaseIngest,
type PipelineCaseBatchIngest,
type UpdatePipelineCase,
type TransitionCaseInput,
type ResolveSuggestionInput,
type ReviewCaseInput,
type ReviewCasesQuery,
type BulkReviewCasesInput,
type CreatePipelineCaseBlocker,
type CreatePipelineCaseIssueLink,
type OpenConversationPayload,
type RotateRoutineTriggerSecret,
createCostEventSchema,
createFinanceEventSchema,
+53
View File
@@ -468,8 +468,61 @@ export {
export {
pipelineStageConfigSchema,
pipelineStageVariableSchema,
pipelineStageRunRoutineSchema,
pipelineStatusSchema,
pipelineStageKindSchema,
pipelineCaseIssueLinkRoleSchema,
createPipelineSchema,
updatePipelineSchema,
pipelineListQuerySchema,
createPipelineStageSchema,
updatePipelineStageSchema,
pipelineTransitionConfigSchema,
pipelineTransitionSchema,
setPipelineTransitionsSchema,
createPipelineGuidanceDocumentSchema,
listPipelineCasesQuerySchema,
listCompanyCaseEventsQuerySchema,
pipelineCaseIngestSchema,
pipelineCaseBatchIngestSchema,
updatePipelineCaseSchema,
transitionCaseSchema,
resolveCaseSuggestionSchema,
reviewCaseSchema,
reviewCasesQuerySchema,
bulkReviewCasesSchema,
createPipelineCaseBlockerSchema,
createPipelineCaseIssueLinkSchema,
openConversationPayloadSchema,
type PipelineStageConfig,
type PipelineStageVariable,
type PipelineStatus,
type PipelineStageKind,
type PipelineCaseIssueLinkRole,
type PipelineStageRunRoutineConfig,
type CreatePipeline,
type UpdatePipeline,
type PipelineListQuery,
type CreatePipelineStage,
type UpdatePipelineStage,
type PipelineTransition,
type SetPipelineTransitions,
type PipelineGuidanceDocumentUpsert,
type ListPipelineCasesQuery,
type ListCompanyCaseEventsQuery,
type PipelineCaseIngest,
type PipelineCaseBatchIngest,
type UpdatePipelineCase,
type TransitionCaseInput,
type ResolveSuggestionInput,
type ReviewCaseInput,
type ReviewCasesQuery,
type BulkReviewCasesInput,
type CreatePipelineCaseBlocker,
type CreatePipelineCaseIssueLink,
type OpenConversationPayload,
type PipelineCaseTransitionConfig,
type PipelineCaseConfig,
} from "./pipeline.js";
export {
@@ -26,4 +26,33 @@ describe("pipeline stage variable schema", () => {
}).success,
).toBe(false);
});
it("accepts run_routine onEnter action", () => {
expect(
pipelineStageConfigSchema.safeParse({
variables: [],
onEnter: {
run_routine: {
routineId: "11111111-1111-4111-8111-111111111111",
variables: { issue_title: "title" },
payload: { source: "pipeline" },
caseFields: { requestedBy: "requester" },
},
},
}).success,
).toBe(true);
});
it("rejects invalid onEnter run_routine configuration", () => {
expect(
pipelineStageConfigSchema.safeParse({
variables: [],
onEnter: {
run_routine: {
routineId: "not-a-uuid",
},
},
}).success,
).toBe(false);
});
});
+234 -1
View File
@@ -2,6 +2,22 @@ import { z } from "zod";
const routineVariableLikeNameSchema = z.string().trim().regex(/^[A-Za-z][A-Za-z0-9_]*$/);
const pipelineStageRunRoutineVariableMapSchema = z.record(
z.string().trim().min(1).max(120),
z.string().trim().max(120),
);
export const pipelineStageRunRoutineSchema = z.object({
routineId: z.string().uuid(),
variables: pipelineStageRunRoutineVariableMapSchema.optional().default({}),
payload: z.record(z.string(), z.unknown()).optional().default({}),
caseFields: pipelineStageRunRoutineVariableMapSchema.optional().default({}),
}).strict();
const pipelineStageOnEnterSchema = z.object({
run_routine: pipelineStageRunRoutineSchema,
}).strict();
export const pipelineStageVariableSchema = z.object({
key: routineVariableLikeNameSchema,
label: z.string().trim().max(120),
@@ -28,6 +44,7 @@ export const pipelineStageVariableSchema = z.object({
export const pipelineStageConfigSchema = z.object({
variables: z.array(pipelineStageVariableSchema).default([]),
onEnter: pipelineStageOnEnterSchema.optional(),
}).passthrough().superRefine((value, ctx) => {
const keys = new Set<string>();
value.variables.forEach((variable, index) => {
@@ -42,5 +59,221 @@ export const pipelineStageConfigSchema = z.object({
});
});
export type PipelineStageVariable = z.infer<typeof pipelineStageVariableSchema>;
export const pipelineStatusSchema = z.enum(["active", "paused", "archived"]);
export const pipelineStageKindSchema = z.enum(["open", "working", "review", "done", "cancelled"]);
export const pipelineCaseIssueLinkRoleSchema = z.enum(["origin", "conversation", "work", "automation"]);
export const createPipelineSchema = z.object({
name: z.string().trim().min(1).max(180),
description: z.string().trim().max(5_000).nullable().optional(),
status: pipelineStatusSchema.optional().default("active"),
}).strict();
export const updatePipelineSchema = createPipelineSchema.partial().strict();
export const pipelineListQuerySchema = z.object({
includeConnections: z.preprocess((value) => {
if (value === undefined) return undefined;
if (typeof value === "boolean") return value;
if (typeof value === "string") return ["1", "true", "yes", "y"].includes(value.toLowerCase());
return undefined;
}, z.boolean().optional()),
includeCounts: z.preprocess((value) => {
if (value === undefined) return undefined;
if (typeof value === "boolean") return value;
if (typeof value === "string") return ["1", "true", "yes", "y"].includes(value.toLowerCase());
return undefined;
}, z.boolean().optional()),
q: z.string().trim().max(200).optional(),
}).strict();
export const createPipelineStageSchema = z.object({
name: z.string().trim().min(1).max(180),
kind: pipelineStageKindSchema.optional().default("open"),
position: z.number().int().nonnegative().optional(),
config: pipelineStageConfigSchema.optional(),
}).strict();
export const updatePipelineStageSchema = createPipelineStageSchema.partial().strict();
export const pipelineTransitionConfigSchema = z.record(z.unknown()).default({});
export const pipelineTransitionSchema = z.object({
fromStageId: z.string().uuid(),
toStageId: z.string().uuid(),
config: pipelineTransitionConfigSchema,
}).strict();
export const setPipelineTransitionsSchema = z.object({
transitions: z.array(pipelineTransitionSchema),
enforceTransitions: z.boolean().optional().default(true),
}).strict().superRefine((value, ctx) => {
const transitionsByPair = new Map<string, number>();
value.transitions.forEach((transition, index) => {
const key = `${transition.fromStageId}:${transition.toStageId}`;
const indexOfFirst = transitionsByPair.get(key);
if (indexOfFirst !== undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["transitions", index],
message: `Duplicate transition from ${transition.fromStageId} to ${transition.toStageId}`,
});
return;
}
transitionsByPair.set(key, index);
if (transition.fromStageId === transition.toStageId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["transitions", index],
message: "Transition destination must differ from source",
});
}
});
});
export const createPipelineGuidanceDocumentSchema = z.object({
title: z.string().trim().min(1).max(180),
body: z.string().trim().min(1).max(200_000),
}).strict();
export const listPipelineCasesQuerySchema = z.object({
stageId: z.string().uuid().optional(),
status: z.string().trim().max(80).optional(),
q: z.string().trim().max(200).optional(),
limit: z.preprocess((value) => {
if (value === undefined || value === null || value === "") return undefined;
if (typeof value === "number") return value;
if (typeof value === "string") return Number(value);
return undefined;
}, z.number().int().positive().max(500).default(100)),
offset: z.preprocess((value) => {
if (value === undefined || value === null || value === "") return undefined;
if (typeof value === "number") return value;
if (typeof value === "string") return Number(value);
return undefined;
}, z.number().int().min(0).default(0)),
}).strict();
export const listCompanyCaseEventsQuerySchema = z.object({
pipelineId: z.string().uuid().optional(),
types: z.string().trim().max(500).optional(),
limit: z.preprocess((value) => {
if (value === undefined || value === null || value === "") return undefined;
if (typeof value === "number") return value;
if (typeof value === "string") return Number(value);
return undefined;
}, z.number().int().positive().max(500).default(100)),
offset: z.preprocess((value) => {
if (value === undefined || value === null || value === "") return undefined;
if (typeof value === "number") return value;
if (typeof value === "string") return Number(value);
return undefined;
}, z.number().int().min(0).default(0)),
}).strict();
export const pipelineCaseIngestSchema = z.object({
title: z.string().trim().min(1).max(300),
status: z.string().trim().max(80).optional().default("open"),
stageId: z.string().uuid().optional().nullable(),
parentCaseId: z.string().uuid().optional().nullable(),
fields: z.record(z.string(), z.unknown()).optional().default({}),
}).strict().passthrough();
export const pipelineCaseBatchIngestSchema = z.object({
items: z.array(pipelineCaseIngestSchema).min(1).max(100),
}).strict();
export const updatePipelineCaseSchema = z.object({
title: z.string().trim().min(1).max(300).optional(),
status: z.string().trim().max(80).optional(),
stageId: z.string().uuid().optional().nullable(),
fields: z.record(z.string(), z.unknown()).optional(),
}).strict().passthrough();
export const transitionCaseSchema = z.object({
toStageId: z.string().uuid(),
reason: z.string().trim().max(1_000).optional().nullable().default(null),
}).strict();
export const resolveCaseSuggestionSchema = z.object({
decision: z.enum(["accept", "decline"]),
note: z.string().trim().max(2_000).optional().nullable().default(null),
}).strict();
export const reviewCaseSchema = z.object({
decision: z.enum(["approve", "request_changes", "drop"]),
note: z.string().trim().max(2_000).optional().nullable().default(null),
}).strict();
export const reviewCasesQuerySchema = z.object({
pipelineId: z.string().uuid().optional(),
kind: z.string().trim().max(80).optional(),
limit: z.preprocess((value) => {
if (value === undefined || value === null || value === "") return undefined;
if (typeof value === "number") return value;
if (typeof value === "string") return Number(value);
return undefined;
}, z.number().int().positive().max(500).default(100)),
offset: z.preprocess((value) => {
if (value === undefined || value === null || value === "") return undefined;
if (typeof value === "number") return value;
if (typeof value === "string") return Number(value);
return undefined;
}, z.number().int().min(0).default(0)),
}).strict();
export const bulkReviewCasesSchema = z.object({
caseIds: z.array(z.string().uuid()).min(1).max(100),
decision: z.enum(["approve", "request_changes", "drop"]),
note: z.string().trim().max(2_000).optional().nullable().default(null),
}).strict();
export const createPipelineCaseBlockerSchema = z.object({
blockedByCaseId: z.string().uuid(),
reason: z.string().trim().max(1_000).optional().nullable().default(null),
}).strict();
export const createPipelineCaseIssueLinkSchema = z.object({
issueId: z.string().uuid(),
role: pipelineCaseIssueLinkRoleSchema,
}).strict();
export const openConversationPayloadSchema = z.object({
title: z.string().trim().max(300).optional(),
description: z.string().trim().max(20_000).optional(),
status: z.string().trim().max(80).optional().default("todo"),
assigneeAgentId: z.string().uuid().optional().nullable(),
assigneeUserId: z.string().trim().max(200).optional().nullable(),
projectId: z.string().uuid().optional().nullable(),
role: pipelineCaseIssueLinkRoleSchema.optional().default("conversation"),
}).strict();
export type PipelineStatus = z.infer<typeof pipelineStatusSchema>;
export type PipelineStageKind = z.infer<typeof pipelineStageKindSchema>;
export type PipelineCaseIssueLinkRole = z.infer<typeof pipelineCaseIssueLinkRoleSchema>;
export type CreatePipeline = z.infer<typeof createPipelineSchema>;
export type UpdatePipeline = z.infer<typeof updatePipelineSchema>;
export type PipelineListQuery = z.infer<typeof pipelineListQuerySchema>;
export type CreatePipelineStage = z.infer<typeof createPipelineStageSchema>;
export type UpdatePipelineStage = z.infer<typeof updatePipelineStageSchema>;
export type PipelineTransition = z.infer<typeof pipelineTransitionSchema>;
export type SetPipelineTransitions = z.infer<typeof setPipelineTransitionsSchema>;
export type PipelineGuidanceDocumentUpsert = z.infer<typeof createPipelineGuidanceDocumentSchema>;
export type ListPipelineCasesQuery = z.infer<typeof listPipelineCasesQuerySchema>;
export type ListCompanyCaseEventsQuery = z.infer<typeof listCompanyCaseEventsQuerySchema>;
export type PipelineCaseIngest = z.infer<typeof pipelineCaseIngestSchema>;
export type PipelineCaseBatchIngest = z.infer<typeof pipelineCaseBatchIngestSchema>;
export type UpdatePipelineCase = z.infer<typeof updatePipelineCaseSchema>;
export type TransitionCaseInput = z.infer<typeof transitionCaseSchema>;
export type ResolveSuggestionInput = z.infer<typeof resolveCaseSuggestionSchema>;
export type ReviewCaseInput = z.infer<typeof reviewCaseSchema>;
export type ReviewCasesQuery = z.infer<typeof reviewCasesQuerySchema>;
export type BulkReviewCasesInput = z.infer<typeof bulkReviewCasesSchema>;
export type CreatePipelineCaseBlocker = z.infer<typeof createPipelineCaseBlockerSchema>;
export type CreatePipelineCaseIssueLink = z.infer<typeof createPipelineCaseIssueLinkSchema>;
export type OpenConversationPayload = z.infer<typeof openConversationPayloadSchema>;
export type PipelineCaseTransitionConfig = Record<string, unknown>;
export type PipelineCaseConfig = Record<string, unknown>;
export type PipelineStageConfig = z.infer<typeof pipelineStageConfigSchema>;
export type PipelineStageVariable = z.infer<typeof pipelineStageVariableSchema>;
export type PipelineStageRunRoutineConfig = z.infer<typeof pipelineStageRunRoutineSchema>;
+2
View File
@@ -39,6 +39,7 @@ import {
type InstanceDatabaseBackupService,
} from "./routes/instance-database-backups.js";
import { llmRoutes } from "./routes/llms.js";
import { pipelineRoutes } from "./routes/pipelines.js";
import { authRoutes } from "./routes/auth.js";
import { assetRoutes } from "./routes/assets.js";
import { accessRoutes } from "./routes/access.js";
@@ -228,6 +229,7 @@ export async function createApp(
api.use(environmentRoutes(db, { pluginWorkerManager: workerManager }));
api.use(executionWorkspaceRoutes(db));
api.use(goalRoutes(db));
api.use(pipelineRoutes(db));
api.use(approvalRoutes(db, { pluginWorkerManager: workerManager }));
api.use(secretRoutes(db));
api.use(costRoutes(db, { pluginWorkerManager: workerManager }));
+1
View File
@@ -19,6 +19,7 @@ export { sidebarPreferenceRoutes } from "./sidebar-preferences.js";
export { resourceMembershipRoutes } from "./resource-memberships.js";
export { inboxDismissalRoutes } from "./inbox-dismissals.js";
export { llmRoutes } from "./llms.js";
export { pipelineRoutes } from "./pipelines.js";
export { accessRoutes } from "./access.js";
export { instanceSettingsRoutes } from "./instance-settings.js";
export { instanceDatabaseBackupRoutes } from "./instance-database-backups.js";
+886
View File
@@ -0,0 +1,886 @@
import { Router } from "express";
import type { Db } from "@paperclipai/db";
import {
createPipelineCaseBlockerSchema,
createPipelineCaseIssueLinkSchema,
createPipelineGuidanceDocumentSchema,
openConversationPayloadSchema,
createPipelineSchema,
createPipelineStageSchema,
bulkReviewCasesSchema,
listCompanyCaseEventsQuerySchema,
listPipelineCasesQuerySchema,
pipelineCaseBatchIngestSchema,
pipelineCaseIngestSchema,
pipelineListQuerySchema,
resolveCaseSuggestionSchema,
reviewCaseSchema,
reviewCasesQuerySchema,
setPipelineTransitionsSchema,
transitionCaseSchema,
updatePipelineCaseSchema,
updatePipelineSchema,
updatePipelineStageSchema,
} from "@paperclipai/shared";
import { extractPipelineIntakeFormFields } from "../services/pipelines.js";
import { validate } from "../middleware/validate.js";
import { pipelineService, issueService, logActivity } from "../services/index.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
export function pipelineRoutes(db: Db) {
const router = Router();
const svc = pipelineService(db);
async function assertPipelineAccess(pipelineId: string, req: Parameters<typeof assertCompanyAccess>[0]) {
const pipeline = await svc.getPipeline(pipelineId);
if (!pipeline) {
return { pipeline: null as null };
}
assertCompanyAccess(req, pipeline.companyId);
return { pipeline };
}
async function assertCaseAccess(caseId: string, req: Parameters<typeof assertCompanyAccess>[0]) {
const pipelineCase = await svc.getPipelineCase(caseId);
if (!pipelineCase) {
return { pipelineCase: null as null };
}
const pipelineResult = await assertPipelineAccess(pipelineCase.pipelineId, req);
if (!pipelineResult.pipeline) {
return { pipelineCase: null as null };
}
return { pipelineCase, pipeline: pipelineResult.pipeline };
}
router.get("/companies/:companyId/pipelines", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const query = pipelineListQuerySchema.parse(req.query);
const pipelines = await svc.listPipelines(companyId, query);
res.json(pipelines);
});
router.get("/companies/:companyId/case-events", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const query = listCompanyCaseEventsQuerySchema.parse(req.query);
const events = await svc.listCompanyCaseEvents(companyId, query);
res.json(events);
});
router.get("/pipelines/:pipelineId", async (req, res) => {
const pipelineId = req.params.pipelineId as string;
const pipeline = await svc.getPipelineWithRelations(pipelineId);
if (!pipeline) {
res.status(404).json({ error: "Pipeline not found" });
return;
}
assertCompanyAccess(req, pipeline.companyId);
res.json(pipeline);
});
router.post("/companies/:companyId/pipelines", validate(createPipelineSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const pipeline = await svc.createPipeline(companyId, req.body);
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "pipeline.created",
entityType: "pipeline",
entityId: pipeline.id,
details: req.body,
});
res.status(201).json(pipeline);
});
router.patch("/pipelines/:pipelineId", validate(updatePipelineSchema), async (req, res) => {
const pipelineId = req.params.pipelineId as string;
const existing = await svc.getPipeline(pipelineId);
if (!existing) {
res.status(404).json({ error: "Pipeline not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const pipeline = await svc.updatePipeline(pipelineId, req.body);
if (!pipeline) {
res.status(404).json({ error: "Pipeline not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: pipeline.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "pipeline.updated",
entityType: "pipeline",
entityId: pipeline.id,
details: req.body,
});
res.json(pipeline);
});
router.delete("/pipelines/:pipelineId", async (req, res) => {
const pipelineId = req.params.pipelineId as string;
const existing = await svc.getPipeline(pipelineId);
if (!existing) {
res.status(404).json({ error: "Pipeline not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const pipeline = await svc.removePipeline(pipelineId);
if (!pipeline) {
res.status(404).json({ error: "Pipeline not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: pipeline.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "pipeline.deleted",
entityType: "pipeline",
entityId: pipeline.id,
});
res.json(pipeline);
});
router.get("/pipelines/:pipelineId/stages", async (req, res) => {
const pipelineId = req.params.pipelineId as string;
const access = await assertPipelineAccess(pipelineId, req);
if (!access.pipeline) {
res.status(404).json({ error: "Pipeline not found" });
return;
}
const stages = await svc.listPipelineStages(pipelineId);
res.json(stages);
});
router.post("/pipelines/:pipelineId/stages", validate(createPipelineStageSchema), async (req, res) => {
const pipelineId = req.params.pipelineId as string;
const access = await assertPipelineAccess(pipelineId, req);
if (!access.pipeline) {
res.status(404).json({ error: "Pipeline not found" });
return;
}
const stage = await svc.createPipelineStage(pipelineId, req.body);
const actor = getActorInfo(req);
await logActivity(db, {
companyId: access.pipeline.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "pipeline_stage.created",
entityType: "pipeline_stage",
entityId: stage.id,
details: { pipelineId, input: req.body },
});
res.status(201).json(stage);
});
router.patch("/pipelines/:pipelineId/stages/:stageId", validate(updatePipelineStageSchema), async (req, res) => {
const pipelineId = req.params.pipelineId as string;
const stageId = req.params.stageId as string;
const access = await assertPipelineAccess(pipelineId, req);
if (!access.pipeline) {
res.status(404).json({ error: "Pipeline not found" });
return;
}
const stage = await svc.updatePipelineStage(pipelineId, stageId, req.body);
if (!stage) {
res.status(404).json({ error: "Pipeline stage not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: access.pipeline.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "pipeline_stage.updated",
entityType: "pipeline_stage",
entityId: stage.id,
details: { pipelineId, stageId, input: req.body },
});
res.json(stage);
});
router.delete("/pipelines/:pipelineId/stages/:stageId", async (req, res) => {
const pipelineId = req.params.pipelineId as string;
const stageId = req.params.stageId as string;
const access = await assertPipelineAccess(pipelineId, req);
if (!access.pipeline) {
res.status(404).json({ error: "Pipeline not found" });
return;
}
const stage = await svc.removePipelineStage(pipelineId, stageId);
if (!stage) {
res.status(404).json({ error: "Pipeline stage not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: access.pipeline.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "pipeline_stage.deleted",
entityType: "pipeline_stage",
entityId: stage.id,
details: { pipelineId, stageId },
});
res.json(stage);
});
router.get("/pipelines/:pipelineId/transitions", async (req, res) => {
const pipelineId = req.params.pipelineId as string;
const access = await assertPipelineAccess(pipelineId, req);
if (!access.pipeline) {
res.status(404).json({ error: "Pipeline not found" });
return;
}
const transitions = await svc.listPipelineTransitions(pipelineId);
res.json(transitions);
});
router.put("/pipelines/:pipelineId/transitions", validate(setPipelineTransitionsSchema), async (req, res) => {
const pipelineId = req.params.pipelineId as string;
const access = await assertPipelineAccess(pipelineId, req);
if (!access.pipeline) {
res.status(404).json({ error: "Pipeline not found" });
return;
}
const transitions = await svc.setPipelineTransitions(pipelineId, req.body);
const actor = getActorInfo(req);
await logActivity(db, {
companyId: access.pipeline.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "pipeline.transitions_updated",
entityType: "pipeline",
entityId: pipelineId,
details: req.body,
});
res.json({ transitions });
});
router.get("/pipelines/:pipelineId/guidance-documents", async (req, res) => {
const pipelineId = req.params.pipelineId as string;
const access = await assertPipelineAccess(pipelineId, req);
if (!access.pipeline) {
res.status(404).json({ error: "Pipeline not found" });
return;
}
const docs = await svc.listGuidanceDocuments(pipelineId);
res.json(docs);
});
router.get("/pipelines/:pipelineId/guidance-documents/:key", async (req, res) => {
const pipelineId = req.params.pipelineId as string;
const key = req.params.key as string;
const access = await assertPipelineAccess(pipelineId, req);
if (!access.pipeline) {
res.status(404).json({ error: "Pipeline not found" });
return;
}
const doc = await svc.getGuidanceDocument(pipelineId, key);
if (!doc) {
res.status(404).json({ error: "Guidance document not found" });
return;
}
res.json(doc);
});
router.put(
"/pipelines/:pipelineId/guidance-documents/:key",
validate(createPipelineGuidanceDocumentSchema),
async (req, res) => {
const pipelineId = req.params.pipelineId as string;
const key = req.params.key as string;
const access = await assertPipelineAccess(pipelineId, req);
if (!access.pipeline) {
res.status(404).json({ error: "Pipeline not found" });
return;
}
const doc = await svc.upsertGuidanceDocument(pipelineId, key, req.body);
const actor = getActorInfo(req);
await logActivity(db, {
companyId: access.pipeline.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "pipeline_guidance_document.upserted",
entityType: "pipeline_guidance_document",
entityId: doc.id,
details: { pipelineId, key, ...req.body },
});
res.json(doc);
},
);
router.delete("/pipelines/:pipelineId/guidance-documents/:key", async (req, res) => {
const pipelineId = req.params.pipelineId as string;
const key = req.params.key as string;
const access = await assertPipelineAccess(pipelineId, req);
if (!access.pipeline) {
res.status(404).json({ error: "Pipeline not found" });
return;
}
const doc = await svc.deleteGuidanceDocument(pipelineId, key);
if (!doc) {
res.status(404).json({ error: "Guidance document not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: access.pipeline.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "pipeline_guidance_document.deleted",
entityType: "pipeline_guidance_document",
entityId: doc.id,
details: { pipelineId, key },
});
res.json(doc);
});
router.get("/pipelines/:pipelineId/cases", async (req, res) => {
const pipelineId = req.params.pipelineId as string;
const access = await assertPipelineAccess(pipelineId, req);
if (!access.pipeline) {
res.status(404).json({ error: "Pipeline not found" });
return;
}
const query = listPipelineCasesQuerySchema.parse(req.query);
const cases = await svc.listPipelineCases(pipelineId, query);
res.json(cases);
});
router.post("/pipelines/:pipelineId/cases/ingest", validate(pipelineCaseIngestSchema), async (req, res) => {
const pipelineId = req.params.pipelineId as string;
const access = await assertPipelineAccess(pipelineId, req);
if (!access.pipeline) {
res.status(404).json({ error: "Pipeline not found" });
return;
}
const actor = getActorInfo(req);
const created = await svc.ingestPipelineCase(pipelineId, req.body, {
actorId: actor.actorId,
actorType: actor.actorType,
});
await logActivity(db, {
companyId: access.pipeline.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "pipeline_case.ingested",
entityType: "pipeline_case",
entityId: created.id,
details: req.body,
});
res.status(201).json(created);
});
router.post(
"/pipelines/:pipelineId/cases/batch",
validate(pipelineCaseBatchIngestSchema),
async (req, res) => {
const pipelineId = req.params.pipelineId as string;
const access = await assertPipelineAccess(pipelineId, req);
if (!access.pipeline) {
res.status(404).json({ error: "Pipeline not found" });
return;
}
const actor = getActorInfo(req);
const cases = await svc.ingestPipelineCases(pipelineId, req.body.items, {
actorId: actor.actorId,
actorType: actor.actorType,
});
await logActivity(db, {
companyId: access.pipeline.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "pipeline_case.batch_ingested",
entityType: "pipeline",
entityId: pipelineId,
details: { caseIds: cases.map((item) => item.id), count: cases.length },
});
res.status(201).json({ cases });
},
);
router.get("/cases/:caseId", async (req, res) => {
const caseId = req.params.caseId as string;
const caseAccess = await assertCaseAccess(caseId, req);
if (!caseAccess.pipelineCase || !caseAccess.pipeline) {
res.status(404).json({ error: "Case not found" });
return;
}
res.json(caseAccess.pipelineCase);
});
router.patch("/cases/:caseId", validate(updatePipelineCaseSchema), async (req, res) => {
const caseId = req.params.caseId as string;
const caseAccess = await assertCaseAccess(caseId, req);
if (!caseAccess.pipelineCase || !caseAccess.pipeline) {
res.status(404).json({ error: "Case not found" });
return;
}
const updated = await svc.updatePipelineCase(caseId, req.body);
if (!updated) {
res.status(404).json({ error: "Case not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: caseAccess.pipeline.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "pipeline_case.updated",
entityType: "pipeline_case",
entityId: caseId,
details: req.body,
});
res.json(updated);
});
router.post("/cases/:caseId/transition", validate(transitionCaseSchema), async (req, res) => {
const caseId = req.params.caseId as string;
const caseAccess = await assertCaseAccess(caseId, req);
if (!caseAccess.pipelineCase || !caseAccess.pipeline) {
res.status(404).json({ error: "Case not found" });
return;
}
const actor = getActorInfo(req);
const updated = await svc.transitionCaseStage(caseId, req.body.toStageId, {
actorId: actor.actorId,
actorType: actor.actorType,
reason: req.body.reason,
});
if (!updated) {
res.status(404).json({ error: "Case not found" });
return;
}
await logActivity(db, {
companyId: caseAccess.pipeline.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "pipeline_case.transitioned",
entityType: "pipeline_case",
entityId: caseId,
details: req.body,
});
res.json(updated);
});
router.post("/cases/:caseId/suggest-transition", validate(transitionCaseSchema), async (req, res) => {
const caseId = req.params.caseId as string;
const caseAccess = await assertCaseAccess(caseId, req);
if (!caseAccess.pipelineCase || !caseAccess.pipeline) {
res.status(404).json({ error: "Case not found" });
return;
}
const updated = await svc.suggestTransition(caseId, req.body.toStageId, req.body.reason);
if (!updated) {
res.status(404).json({ error: "Case not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: caseAccess.pipeline.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "pipeline_case.transition_suggested",
entityType: "pipeline_case",
entityId: caseId,
details: req.body,
});
res.json(updated);
});
router.post("/cases/:caseId/resolve-suggestion", validate(resolveCaseSuggestionSchema), async (req, res) => {
const caseId = req.params.caseId as string;
const caseAccess = await assertCaseAccess(caseId, req);
if (!caseAccess.pipelineCase || !caseAccess.pipeline) {
res.status(404).json({ error: "Case not found" });
return;
}
const updated = await svc.resolveSuggestion(caseId, req.body.decision, req.body.note);
if (!updated) {
res.status(404).json({ error: "Case not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: caseAccess.pipeline.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "pipeline_case.suggestion_resolved",
entityType: "pipeline_case",
entityId: caseId,
details: req.body,
});
res.json(updated);
});
router.post("/cases/:caseId/review", validate(reviewCaseSchema), async (req, res) => {
const caseId = req.params.caseId as string;
const caseAccess = await assertCaseAccess(caseId, req);
if (!caseAccess.pipelineCase || !caseAccess.pipeline) {
res.status(404).json({ error: "Case not found" });
return;
}
const updated = await svc.reviewCase(caseId, req.body.decision, req.body.note);
if (!updated) {
res.status(404).json({ error: "Case not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: caseAccess.pipeline.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "pipeline_case.reviewed",
entityType: "pipeline_case",
entityId: caseId,
details: req.body,
});
res.json(updated);
});
router.get("/companies/:companyId/review-cases", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const query = reviewCasesQuerySchema.parse(req.query);
const rows = await svc.listReviewCases(companyId, query);
res.json(rows);
});
router.post("/companies/:companyId/review-cases/bulk", validate(bulkReviewCasesSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const rows = await svc.bulkReviewCases(companyId, req.body.caseIds, req.body.decision, req.body.note);
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "pipeline_case.reviewed_bulk",
entityType: "pipeline_case",
entityId: companyId,
details: {
count: rows.length,
decision: req.body.decision,
},
});
res.json({ cases: rows });
});
router.get("/cases/:caseId/blockers", async (req, res) => {
const caseId = req.params.caseId as string;
const caseAccess = await assertCaseAccess(caseId, req);
if (!caseAccess.pipelineCase || !caseAccess.pipeline) {
res.status(404).json({ error: "Case not found" });
return;
}
const blockers = await svc.listCaseBlockers(caseId);
res.json(blockers);
});
router.post("/cases/:caseId/blockers", validate(createPipelineCaseBlockerSchema), async (req, res) => {
const caseId = req.params.caseId as string;
const caseAccess = await assertCaseAccess(caseId, req);
if (!caseAccess.pipelineCase || !caseAccess.pipeline) {
res.status(404).json({ error: "Case not found" });
return;
}
const blocker = await svc.createCaseBlocker(caseId, req.body.blockedByCaseId, req.body.reason);
if (!blocker) {
res.status(404).json({ error: "Case not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: caseAccess.pipeline.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "pipeline_case_blocker.created",
entityType: "pipeline_case_blocker",
entityId: blocker.id,
details: req.body,
});
res.status(201).json(blocker);
});
router.post("/cases/:caseId/blockers/:blockerId/resolve", async (req, res) => {
const caseId = req.params.caseId as string;
const blockerId = req.params.blockerId as string;
const caseAccess = await assertCaseAccess(caseId, req);
if (!caseAccess.pipelineCase || !caseAccess.pipeline) {
res.status(404).json({ error: "Case not found" });
return;
}
const blocker = await svc.resolveCaseBlocker(caseId, blockerId);
if (!blocker) {
res.status(404).json({ error: "Blocker not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: caseAccess.pipeline.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "pipeline_case_blocker.resolved",
entityType: "pipeline_case_blocker",
entityId: blocker.id,
});
res.json(blocker);
});
router.get("/cases/:caseId/issue-links", async (req, res) => {
const caseId = req.params.caseId as string;
const caseAccess = await assertCaseAccess(caseId, req);
if (!caseAccess.pipelineCase || !caseAccess.pipeline) {
res.status(404).json({ error: "Case not found" });
return;
}
const links = await svc.listCaseIssueLinks(caseId);
res.json(links);
});
router.post("/cases/:caseId/issue-links", validate(createPipelineCaseIssueLinkSchema), async (req, res) => {
const caseId = req.params.caseId as string;
const caseAccess = await assertCaseAccess(caseId, req);
if (!caseAccess.pipelineCase || !caseAccess.pipeline) {
res.status(404).json({ error: "Case not found" });
return;
}
const link = await svc.linkCaseIssue(caseId, req.body.issueId, req.body.role);
if (!link) {
res.status(404).json({ error: "Case not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: caseAccess.pipeline.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "pipeline_case_issue_link.created",
entityType: "pipeline_case_issue_link",
entityId: link.id,
details: req.body,
});
res.status(201).json(link);
});
router.delete("/cases/:caseId/issue-links/:linkId", async (req, res) => {
const caseId = req.params.caseId as string;
const linkId = req.params.linkId as string;
const caseAccess = await assertCaseAccess(caseId, req);
if (!caseAccess.pipelineCase || !caseAccess.pipeline) {
res.status(404).json({ error: "Case not found" });
return;
}
const link = await svc.unlinkCaseIssue(linkId, caseId);
if (!link) {
res.status(404).json({ error: "Issue link not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: caseAccess.pipeline.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "pipeline_case_issue_link.deleted",
entityType: "pipeline_case_issue_link",
entityId: link.id,
details: { caseId, linkId },
});
res.json(link);
});
router.get("/pipelines/:pipelineId/events", async (req, res) => {
const pipelineId = req.params.pipelineId as string;
const access = await assertPipelineAccess(pipelineId, req);
if (!access.pipeline) {
res.status(404).json({ error: "Pipeline not found" });
return;
}
const events = await svc.listPipelineEvents(pipelineId);
res.json(events);
});
router.get("/cases/:caseId/events", async (req, res) => {
const caseId = req.params.caseId as string;
const caseAccess = await assertCaseAccess(caseId, req);
if (!caseAccess.pipelineCase || !caseAccess.pipeline) {
res.status(404).json({ error: "Case not found" });
return;
}
const events = await svc.listCaseEvents(caseId);
res.json(events);
});
router.get("/cases/:caseId/children", async (req, res) => {
const caseId = req.params.caseId as string;
const caseAccess = await assertCaseAccess(caseId, req);
if (!caseAccess.pipelineCase || !caseAccess.pipeline) {
res.status(404).json({ error: "Case not found" });
return;
}
const children = await svc.listCaseChildren(caseId);
res.json(children);
});
router.get("/pipelines/:pipelineId/intake-form", async (req, res) => {
const pipelineId = req.params.pipelineId as string;
const access = await assertPipelineAccess(pipelineId, req);
if (!access.pipeline) {
res.status(404).json({ error: "Pipeline not found" });
return;
}
const pipeline = await svc.getPipelineWithRelations(pipelineId);
const firstStage = pipeline?.stages?.[0] ?? null;
const fields = firstStage ? extractPipelineIntakeFormFields([firstStage]) : [];
res.json({
pipelineId,
stageId: firstStage?.id ?? null,
fields,
});
});
router.get("/companies/:companyId/pipelines-attention", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const rows = await svc.listPipelinesAttention(companyId);
res.json(rows);
});
// Optional compatibility for pipeline issue conversion.
router.post("/cases/:caseId/open-conversation", validate(openConversationPayloadSchema), async (req, res) => {
const caseId = req.params.caseId as string;
const caseAccess = await assertCaseAccess(caseId, req);
if (!caseAccess.pipelineCase || !caseAccess.pipeline) {
res.status(404).json({ error: "Case not found" });
return;
}
const payload = req.body;
const issuePayload = {
title: payload.title ?? caseAccess.pipelineCase.title,
description: payload.description ?? null,
status: payload.status,
projectId: payload.projectId ?? null,
assigneeAgentId: payload.assigneeAgentId ?? null,
assigneeUserId: payload.assigneeUserId ?? null,
};
const issue = await issueService(db).create(caseAccess.pipeline.companyId, {
...issuePayload,
});
const link = await svc.linkCaseIssue(caseId, issue.id, payload.role);
const actorInfo = getActorInfo(req);
await logActivity(db, {
companyId: caseAccess.pipeline.companyId,
actorType: actorInfo.actorType,
actorId: actorInfo.actorId,
agentId: actorInfo.agentId,
action: "pipeline_case.open_conversation",
entityType: "issue",
entityId: issue.id,
details: { caseId },
});
res.status(201).json({
...link,
issueId: issue.id,
caseId,
});
});
return router;
}
+14 -2
View File
@@ -114,6 +114,16 @@ export function routineRoutes(
return { payload, control };
}
function pickRoutineValueFields(input: Record<string, unknown>) {
const picked: Record<string, string | number | boolean> = {};
for (const [key, value] of Object.entries(input)) {
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
picked[key] = value;
}
}
return picked;
}
router.get("/companies/:companyId/routines", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
@@ -370,12 +380,14 @@ export function routineRoutes(
const caseFields = Object.hasOwn(control, "caseFields")
? (control.caseFields as Record<string, unknown> | null)
: payload;
const variablePayload = pickRoutineValueFields(payload);
const normalizedCaseFields = caseFields ? pickRoutineValueFields(caseFields) : null;
const run = await svc.runRoutine(routine.id, {
...control,
source: "api",
payload: runPayload,
variables: payload,
caseFields,
variables: variablePayload,
caseFields: normalizedCaseFields,
}, {
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
userId: req.actor.type === "board" ? req.actor.userId ?? null : null,
+1
View File
@@ -63,6 +63,7 @@ export type {
export { boardAuthService } from "./board-auth.js";
export { instanceSettingsService } from "./instance-settings.js";
export { cloudUpstreamService, reconcileCloudUpstreamRunsOnStartup } from "./cloud-upstreams.js";
export { pipelineService } from "./pipelines.js";
export { companyPortabilityService } from "./company-portability.js";
export { teamsCatalogService } from "./teams-catalog.js";
export { environmentService } from "./environments.js";
+127
View File
@@ -0,0 +1,127 @@
import { describe, expect, it, vi } from "vitest";
import { extractPipelineIntakeFormFields, mapConfiguredFields, triggerStageOnEnter, validateStageRequiredFields } from "./pipelines.js";
describe("pipeline service helpers", () => {
it("derives intake fields from first stage variables", () => {
const stageRows = [
{
pipelineId: "pipeline-1",
config: {
variables: [
{ key: "customer", label: "Customer", type: "text", required: true, options: [], showInAddForm: false },
{ key: "urgency", label: "Urgency", type: "select", options: ["low", "high"], required: false, showInAddForm: false },
],
},
},
{
pipelineId: "pipeline-1",
config: {
variables: [
{ key: "ignored", label: "Ignored", type: "text", required: true, options: [], showInAddForm: false },
],
},
},
] as Array<{
id: string;
pipelineId: string;
name: string;
kind: string;
position: number;
config: {
variables: Array<{
key: string;
label: string;
type: "text" | "select" | "multiline";
required: boolean;
options: string[];
showInAddForm: boolean;
}>;
};
createdAt: Date;
updatedAt: Date;
}>;
expect(extractPipelineIntakeFormFields(stageRows)).toEqual([
{ key: "customer", label: "Customer", type: "text", required: true, options: [] },
{ key: "urgency", label: "Urgency", type: "select", required: false, options: ["low", "high"] },
]);
});
it("validates required intake fields for pipeline stage variables", () => {
const variables = [
{ key: "customer", label: "Customer", type: "text" as const, required: true, options: [], showInAddForm: false },
{ key: "notes", label: "Notes", type: "multiline" as const, required: false, options: [], showInAddForm: false },
];
expect(() =>
validateStageRequiredFields({ customer: "Acme" }, variables),
).not.toThrow();
expect(() =>
validateStageRequiredFields({ notes: "Urgent follow-up" }, variables),
).toThrowError(/Missing required field\(s\): customer/);
});
it("maps configured fields and triggers run_routine with resolved payload and actor", async () => {
const runRoutine = vi.fn().mockResolvedValue({ id: "run-1" });
const routineSvc = { runRoutine };
const caseRow = {
id: "case-1",
title: "Follow up issue",
pipelineId: "pipeline-1",
} as { id: string; title: string; pipelineId: string };
const stage = {
id: "stage-1",
pipelineId: "pipeline-1",
config: {
variables: [],
onEnter: {
run_routine: {
routineId: "11111111-1111-4111-8111-111111111111",
variables: { urgency: "priority" },
payload: { source: "pipeline" },
caseFields: { customer: "customer_name" },
},
},
},
} as {
id: string;
pipelineId: string;
config: { variables: unknown[]; onEnter: { run_routine: Record<string, unknown> } };
};
const fields = {
customer_name: "Acme",
priority: "high",
notes: "Escalate quickly",
};
await triggerStageOnEnter(null as any, routineSvc as never, stage as never, caseRow as never, fields, {
actorId: "agent-1",
actorType: "agent",
});
expect(runRoutine).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
{
source: "api",
payload: {
source: "pipeline",
pipelineId: "pipeline-1",
caseId: "case-1",
caseTitle: "Follow up issue",
customer: "Acme",
},
variables: { urgency: "high" },
caseFields: { customer: "Acme" },
},
{
agentId: "agent-1",
userId: null,
},
);
});
it("maps configured fields without unknown entries", () => {
expect(mapConfiguredFields({ destination: "source" }, { source: "value", other: "ignore" })).toEqual({
destination: "value",
});
});
});
File diff suppressed because it is too large. Load diff
+14
View File
@@ -13,6 +13,14 @@ import { Projects } from "./pages/Projects";
import { ProjectDetail } from "./pages/ProjectDetail";
import { ProjectWorkspaceDetail } from "./pages/ProjectWorkspaceDetail";
import { Workspaces } from "./pages/Workspaces";
import {
LearningsStub,
PipelineBoardStub,
PipelineItemStub,
Pipelines,
PipelineSettingsStub,
ReviewQueueStub,
} from "./pages/Pipelines";
import { Issues } from "./pages/Issues";
import { Search } from "./pages/Search";
import { IssueDetail } from "./pages/IssueDetail";
@@ -117,6 +125,12 @@ function boardRoutes() {
<Route path="projects/:projectId/configuration" element={<ProjectDetail />} />
<Route path="projects/:projectId/budget" element={<ProjectDetail />} />
<Route path="workspaces" element={<Workspaces />} />
<Route path="review-queue" element={<ReviewQueueStub />} />
<Route path="pipelines" element={<Pipelines />} />
<Route path="pipelines/:pipelineId" element={<PipelineBoardStub />} />
<Route path="pipelines/:pipelineId/items/:caseId" element={<PipelineItemStub />} />
<Route path="pipelines/:pipelineId/settings" element={<PipelineSettingsStub />} />
<Route path="learnings" element={<LearningsStub />} />
<Route path="issues" element={<Issues />} />
<Route path="search" element={<Search />} />
<Route path="issues/all" element={<Navigate to="/issues" replace />} />
+1
View File
@@ -77,6 +77,7 @@ export interface PipelineCase {
id: string;
pipelineId: string;
stageId: string | null;
parentCaseId?: string | null;
title: string;
activeWork?: boolean | number | null;
status?: string | null;
+6
View File
@@ -6,6 +6,9 @@ import {
DollarSign,
History,
Search,
ListChecks,
FolderKanban,
BookOpenText,
SquarePen,
Network,
Boxes,
@@ -164,6 +167,9 @@ export function Sidebar() {
</div>
<SidebarSection label="Work">
<SidebarNavItem to="/review-queue" label="Review queue" icon={ListChecks} />
<SidebarNavItem to="/pipelines" label="Pipelines" icon={FolderKanban} />
<SidebarNavItem to="/learnings" label="Learnings" icon={BookOpenText} />
<SidebarNavItem to="/issues" label="Tasks" icon={CircleDot} />
<SidebarNavItem to="/routines" label="Routines" icon={Repeat} />
<SidebarNavItem to="/goals" label="Goals" icon={Target} />
+211
View File
@@ -0,0 +1,211 @@
import type { PipelineCase, PipelineCaseEvent, PipelineStage } from "../api/pipelines";
export type PendingTransitionBannerState =
| { visible: false }
| {
visible: true;
toStageId: string;
toStageName: string;
reason: string | null;
};
export type PipelineItemEventPresentation = {
sentence: string;
tone: "neutral" | "positive" | "attention";
};
export type PipelineItemFieldEntry = {
key: string;
label: string;
value: string;
};
const INTERNAL_FIELD_KEYS = new Set([
"activeWork",
"blockedByCaseIds",
"childrenSummary",
"nextSuggestedStageId",
"nextSuggestedStageName",
"nextSuggestedStageReason",
"review",
"suggestionResolution",
"thisChanged",
"upstreamChanged",
"upstreamDrift",
"changeAcknowledgedAt",
]);
function asRecord(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function asString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function stageNameById(stages: PipelineStage[]): Map<string, string> {
return new Map(stages.map((stage) => [stage.id, stage.name]));
}
function payloadText(event: PipelineCaseEvent, ...keys: string[]): string | null {
const payload = asRecord(event.payload);
for (const key of keys) {
const value = asString(payload[key]);
if (value) return value;
}
return null;
}
function sentenceSubject(event: PipelineCaseEvent): string {
return event.caseTitle ?? payloadText(event, "itemTitle", "title") ?? "This item";
}
export function humanizePipelineItemStatus(status: string | null | undefined): string {
const normalized = status?.trim().toLowerCase();
if (!normalized || normalized === "open") return "Open";
if (normalized === "done" || normalized === "finished" || normalized === "complete") return "Finished";
if (normalized === "cancelled" || normalized === "canceled" || normalized === "dropped") return "Removed";
if (normalized === "in_review" || normalized === "review") return "Needs review";
if (normalized === "in_progress" || normalized === "working") return "In motion";
return normalized
.split(/[_\s-]+/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
export function getPendingTransitionBannerState(
item: PipelineCase | null | undefined,
stages: PipelineStage[],
): PendingTransitionBannerState {
if (!item) return { visible: false };
const fields = asRecord(item.fields);
if (asRecord(fields.suggestionResolution).decision) return { visible: false };
const toStageId = asString(fields.nextSuggestedStageId);
if (!toStageId) return { visible: false };
return {
visible: true,
toStageId,
toStageName:
asString(fields.nextSuggestedStageName) ??
stageNameById(stages).get(toStageId) ??
"the next stage",
reason: asString(fields.nextSuggestedStageReason),
};
}
export function itemHasChangedNotice(item: PipelineCase | null | undefined): boolean {
if (!item) return false;
const fields = asRecord(item.fields);
if (fields.changeAcknowledgedAt) return false;
return item.thisChanged === true || fields.thisChanged === true || fields.upstreamChanged === true || !!fields.upstreamDrift;
}
function formatReviewDecision(decision: string | null): string {
if (decision === "request_changes") return "requested changes";
if (decision === "drop") return "removed this item";
return "approved this item";
}
export function formatPipelineItemEvent(event: PipelineCaseEvent): PipelineItemEventPresentation {
const title = sentenceSubject(event);
if (event.kind === "case.ingested") {
const stage = event.toStageName ?? payloadText(event, "toStageName", "stageName");
return {
tone: "neutral",
sentence: stage ? `${title} was added to ${stage}.` : `${title} was added.`,
};
}
if (event.kind === "case.updated") {
return {
tone: "neutral",
sentence: `${title} was updated.`,
};
}
if (event.kind === "case.transitioned") {
const fromStage = event.fromStageName ?? payloadText(event, "fromStageName");
const toStage = event.toStageName ?? payloadText(event, "toStageName", "stageName", "targetStageName");
const fromCopy = fromStage ? ` from ${fromStage}` : "";
const toCopy = toStage ? ` to ${toStage}` : "";
return {
tone: "positive",
sentence: `${title} moved${fromCopy}${toCopy}.`,
};
}
if (event.kind === "case.suggested") {
const toStage = event.toStageName ?? payloadText(event, "toStageName", "stageName", "targetStageName");
const reason = payloadText(event, "reason", "note");
const reasonCopy = reason ? ` ${reason}` : "";
return {
tone: "attention",
sentence: toStage
? `An agent suggested moving ${title} to ${toStage}.${reasonCopy}`
: `An agent suggested moving ${title}.${reasonCopy}`,
};
}
if (event.kind === "case.suggestion_resolved") {
const decision = payloadText(event, "decision");
return {
tone: decision === "accept" ? "positive" : "neutral",
sentence: decision === "accept"
? `You approved the suggestion for ${title}.`
: `You kept ${title} where it is.`,
};
}
if (event.kind === "case.reviewed") {
return {
tone: "neutral",
sentence: `You ${formatReviewDecision(payloadText(event, "decision"))}.`,
};
}
return {
tone: "neutral",
sentence: `${title} changed.`,
};
}
function humanizeFieldLabel(key: string): string {
return key
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/[_-]+/g, " ")
.trim()
.split(/\s+/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
function formatFieldValue(value: unknown): string | null {
if (value === null || value === undefined || value === "") return null;
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
if (Array.isArray(value)) {
const values = value.map(formatFieldValue).filter((entry): entry is string => Boolean(entry));
return values.length > 0 ? values.join(", ") : null;
}
const record = asRecord(value);
return asString(record.label) ?? asString(record.name) ?? asString(record.title) ?? "Added details";
}
export function displayPipelineItemFields(fields: Record<string, unknown> | null | undefined): PipelineItemFieldEntry[] {
const record = asRecord(fields);
return Object.entries(record)
.filter(([key]) => !INTERNAL_FIELD_KEYS.has(key))
.map(([key, value]) => ({
key,
label: humanizeFieldLabel(key),
value: formatFieldValue(value),
}))
.filter((entry): entry is PipelineItemFieldEntry => Boolean(entry.value));
}
+66
View File
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import type { PipelineCaseEvent } from "../api/pipelines";
import { formatLearningEvent } from "./pipeline-learnings";
function event(overrides: Partial<PipelineCaseEvent>): PipelineCaseEvent {
return {
id: "event-1",
companyId: "company-1",
pipelineId: "pipeline-1",
caseId: "item-1",
kind: "review_decided",
createdAt: "2026-06-10T12:00:00.000Z",
payload: {},
caseTitle: "Launch tweet",
pipelineName: "Content production",
...overrides,
};
}
describe("formatLearningEvent", () => {
it("translates review decisions with a target stage", () => {
const result = formatLearningEvent(event({
kind: "review_decided",
toStageName: "Publish",
payload: { actorName: "Dotta", decision: "approve" },
}));
expect(result.kind).toBe("review");
expect(result.sentence).toBe("Dotta approved 'Launch tweet' moving to Publish.");
expect(result.sentence).not.toContain("review_decided");
});
it("translates review decisions without optional context", () => {
const result = formatLearningEvent(event({
kind: "case.reviewed",
payload: { decision: "request_changes" },
}));
expect(result.sentence).toBe("Someone sent back 'Launch tweet'.");
expect(result.sentence).not.toContain("case.reviewed");
});
it("translates forced moves with a reason", () => {
const result = formatLearningEvent(event({
kind: "transition_forced",
fromStageName: "Drafting",
toStageName: "Done",
payload: { reason: "Ready for the campaign" },
}));
expect(result.kind).toBe("forced_move");
expect(result.sentence).toBe("'Launch tweet' was moved by hand from Drafting to Done - reason: Ready for the campaign.");
expect(result.sentence).not.toContain("transition_forced");
});
it("translates forced moves without a reason", () => {
const result = formatLearningEvent(event({
kind: "case.transitioned",
toStageName: "Done",
payload: {},
}));
expect(result.sentence).toBe("'Launch tweet' was moved by hand to Done.");
expect(result.sentence).not.toContain("case.transitioned");
});
});
+88
View File
@@ -0,0 +1,88 @@
import type { PipelineCaseEvent } from "../api/pipelines";
export type LearningEventPresentation = {
sentence: string;
kind: "review" | "forced_move" | "unknown";
};
function asRecord(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function asString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function eventItemTitle(event: PipelineCaseEvent): string {
const payload = asRecord(event.payload);
return (
event.caseTitle ??
asString(payload.itemTitle) ??
asString(payload.caseTitle) ??
asString(payload.title) ??
"Untitled item"
);
}
function eventActorName(event: PipelineCaseEvent): string {
const payload = asRecord(event.payload);
return (
asString(payload.actorName) ??
asString(payload.reviewerName) ??
asString(payload.decidedByName) ??
"Someone"
);
}
function payloadText(event: PipelineCaseEvent, ...keys: string[]): string | null {
const payload = asRecord(event.payload);
for (const key of keys) {
const value = asString(payload[key]);
if (value) return value;
}
return null;
}
function reviewVerb(decision: string | null): string {
if (decision === "request_changes") return "sent back";
if (decision === "drop") return "dropped";
return "approved";
}
export function formatLearningEvent(event: PipelineCaseEvent): LearningEventPresentation {
const payload = asRecord(event.payload);
const title = eventItemTitle(event);
if (event.kind === "review_decided" || event.kind === "case.reviewed") {
const actor = eventActorName(event);
const decision = asString(payload.decision);
const toStageName = event.toStageName ?? payloadText(event, "toStageName", "stageName", "targetStageName");
const stageCopy = toStageName ? ` moving to ${toStageName}` : "";
const note = payloadText(event, "reason", "note");
const noteCopy = note ? ` - note: ${note}` : "";
return {
kind: "review",
sentence: `${actor} ${reviewVerb(decision)} '${title}'${stageCopy}${noteCopy}.`,
};
}
if (event.kind === "transition_forced" || event.kind === "case.transitioned") {
const fromStageName = event.fromStageName ?? payloadText(event, "fromStageName");
const toStageName = event.toStageName ?? payloadText(event, "toStageName", "stageName", "targetStageName");
const fromCopy = fromStageName ? ` from ${fromStageName}` : "";
const toCopy = toStageName ? ` to ${toStageName}` : "";
const reason = payloadText(event, "reason", "note");
const reasonCopy = reason ? ` - reason: ${reason}` : "";
return {
kind: "forced_move",
sentence: `'${title}' was moved by hand${fromCopy}${toCopy}${reasonCopy}.`,
};
}
return {
kind: "unknown",
sentence: `'${title}' changed.`,
};
}
+13
View File
@@ -136,6 +136,19 @@ export const queryKeys = {
list: (companyId: string) => ["projects", companyId] as const,
detail: (id: string) => ["projects", "detail", id] as const,
},
pipelines: {
list: (companyId: string) => ["pipelines", companyId] as const,
detail: (id: string) => ["pipelines", "detail", id] as const,
cases: (id: string) => ["pipelines", "cases", id] as const,
caseDetail: (id: string) => ["pipelines", "case", id] as const,
caseChildren: (id: string) => ["pipelines", "case", id, "children"] as const,
caseEvents: (id: string) => ["pipelines", "case", id, "events"] as const,
caseIssueLinks: (id: string) => ["pipelines", "case", id, "issue-links"] as const,
attention: (companyId: string) => ["pipelines", companyId, "attention"] as const,
reviewCases: (companyId: string) => ["pipelines", companyId, "review-cases"] as const,
learnings: (companyId: string, pipelineId: string, offset: number) =>
["pipelines", companyId, "learnings", pipelineId, offset] as const,
},
goals: {
list: (companyId: string) => ["goals", companyId] as const,
detail: (id: string) => ["goals", "detail", id] as const,
+355
View File
@@ -0,0 +1,355 @@
// @vitest-environment jsdom
import { act, useState } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Pipeline, PipelineAttentionItem, PipelineCase } from "../api/pipelines";
import { pipelinesApi } from "../api/pipelines";
import { buildReviewQueueRows, PipelinesIndexTable, ReviewQueueStub } from "./Pipelines";
const mockCompany = vi.hoisted(() => ({ selectedCompanyId: "company-1" as string | null }));
vi.mock("@/lib/router", () => ({
Link: ({
children,
to,
...props
}: React.AnchorHTMLAttributes<HTMLAnchorElement> & { to: string }) => (
<a href={to} {...props}>{children}</a>
),
useNavigate: () => vi.fn(),
useParams: () => ({}),
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({ selectedCompanyId: mockCompany.selectedCompanyId }),
}));
vi.mock("../context/BreadcrumbContext", () => ({
useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function makePipeline(overrides: Partial<Pipeline>): Pipeline {
return {
id: "pipeline-1",
companyId: "company-1",
name: "Pipeline",
description: null,
status: "active",
attentionCount: 0,
inMotionCount: 0,
openItemCount: 0,
lastActivityAt: null,
...overrides,
};
}
function connectedPipelines() {
return [
makePipeline({
id: "release",
name: "Release",
description: "the launch this work is building toward",
openItemCount: 1,
connections: {},
}),
makePipeline({
id: "features",
name: "Features",
attentionCount: 1,
openItemCount: 4,
connections: { feedsIntoPipelineId: "release" },
}),
makePipeline({
id: "content",
name: "Content production",
attentionCount: 2,
inMotionCount: 3,
openItemCount: 7,
connections: { feedsIntoPipelineId: "features" },
}),
];
}
function renderTable({
pipelines,
connectionsAvailable,
search = "",
}: {
pipelines: Pipeline[];
connectionsAvailable: boolean;
search?: string;
}) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
function Harness() {
const [viewMode, setViewMode] = useState<"nested" | "flat">("nested");
const [query, setQuery] = useState(search);
return (
<PipelinesIndexTable
pipelines={pipelines}
viewMode={viewMode}
onViewModeChange={setViewMode}
connectionsAvailable={connectionsAvailable}
search={query}
onSearchChange={setQuery}
/>
);
}
act(() => {
root.render(<Harness />);
});
return { container, root };
}
describe("PipelinesIndexTable", () => {
beforeEach(() => {
document.body.innerHTML = "";
});
afterEach(() => {
document.body.innerHTML = "";
});
it("nests connected pipelines under the work they feed", () => {
const { container, root } = renderTable({
pipelines: connectedPipelines(),
connectionsAvailable: true,
});
const content = container.textContent ?? "";
expect(content.indexOf("Release")).toBeLessThan(content.indexOf("Features"));
expect(content.indexOf("Features")).toBeLessThan(content.indexOf("Content production"));
expect(content).toContain("feeds into Release");
expect(content).toContain("feeds into Features");
const collapse = container.querySelector<HTMLButtonElement>('button[aria-label="Collapse Release"]');
expect(collapse).not.toBeNull();
act(() => {
root.unmount();
});
});
it("switches between nested and flat views when connection data exists", () => {
const { container, root } = renderTable({
pipelines: connectedPipelines(),
connectionsAvailable: true,
});
expect(container.textContent).toContain("feeds into Release");
const flatButton = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Flat list"),
);
expect(flatButton).toBeTruthy();
act(() => {
flatButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).not.toContain("feeds into Release");
expect(container.textContent).not.toContain("feeds into Features");
act(() => {
root.unmount();
});
});
it("disables the nested toggle until the connections field lands", () => {
const { container, root } = renderTable({
pipelines: [
makePipeline({ id: "support", name: "Support knowledge base" }),
makePipeline({ id: "sales", name: "Sales decks" }),
],
connectionsAvailable: false,
});
const nestedButton = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Nested"),
) as HTMLButtonElement | undefined;
expect(nestedButton?.disabled).toBe(true);
expect(container.textContent).toContain("Support knowledge base");
expect(container.textContent).toContain("Sales decks");
act(() => {
root.unmount();
});
});
it("renders attention and in-motion copy only when nonzero", () => {
const { container, root } = renderTable({
pipelines: [
makePipeline({
id: "hiring",
name: "Hiring pipeline",
attentionCount: 3,
inMotionCount: 2,
openItemCount: 18,
}),
makePipeline({
id: "recap",
name: "Quarterly board recap",
attentionCount: 0,
inMotionCount: 0,
openItemCount: 0,
status: "paused",
}),
],
connectionsAvailable: false,
});
const content = container.textContent ?? "";
expect(content).toContain("3 to review");
expect(content).toContain("2 in motion");
expect(content).toContain("18 open");
expect(content).toContain("Paused");
expect(content).not.toContain("0 to review");
expect(content).not.toContain("0 in motion");
act(() => {
root.unmount();
});
});
it("shows an empty state when search filters out every pipeline", () => {
const { container, root } = renderTable({
pipelines: [makePipeline({ id: "press", name: "Press outreach" })],
connectionsAvailable: false,
search: "customer",
});
expect(container.textContent).toContain("No pipelines match your search.");
act(() => {
root.unmount();
});
});
});
function makeCase(overrides: Partial<PipelineCase>): PipelineCase {
return {
id: "item-1",
pipelineId: "pipeline-1",
stageId: "stage-1",
title: "Launch blog post",
status: "open",
fields: {},
createdAt: "2026-06-10T10:00:00.000Z",
updatedAt: "2026-06-10T10:00:00.000Z",
lastActivityAt: "2026-06-10T10:00:00.000Z",
...overrides,
};
}
function renderReviewQueue() {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
act(() => {
root.render(
<QueryClientProvider client={queryClient}>
<ReviewQueueStub />
</QueryClientProvider>,
);
});
return { container, root, queryClient };
}
async function flushQueries() {
await Promise.resolve();
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
}
describe("ReviewQueue", () => {
beforeEach(() => {
document.body.innerHTML = "";
mockCompany.selectedCompanyId = "company-1";
vi.spyOn(pipelinesApi, "list").mockResolvedValue([
makePipeline({ id: "pipeline-1", name: "Content production" }),
]);
vi.spyOn(pipelinesApi, "listAttention").mockResolvedValue([]);
vi.spyOn(pipelinesApi, "listReviewCases").mockResolvedValue([]);
vi.spyOn(pipelinesApi, "reviewCase").mockResolvedValue(makeCase({ id: "review-1" }));
vi.spyOn(pipelinesApi, "resolveSuggestion").mockResolvedValue(makeCase({ id: "suggestion-1" }));
vi.spyOn(pipelinesApi, "bulkReviewCases").mockResolvedValue({ cases: [] });
});
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = "";
});
it("groups attention and review rows into the daily triage sections", () => {
const rows = buildReviewQueueRows({
pipelines: [makePipeline({ id: "pipeline-1", name: "Content production" })],
reviewCases: [makeCase({ id: "review-1", title: "Final launch post" })],
attentionItems: [
{
id: "suggestion-1",
pipelineId: "pipeline-1",
caseId: "suggestion-1",
kind: "suggestion",
title: "Draft launch post",
summary: "Drafting agent thinks Draft launch post is ready to move forward.",
createdAt: "2026-06-10T11:00:00.000Z",
} satisfies PipelineAttentionItem,
{
id: "heads-up-1",
pipelineId: "pipeline-1",
kind: "drift",
title: "Launch tweet",
summary: "Launch tweet changed upstream.",
createdAt: "2026-06-10T09:00:00.000Z",
} satisfies PipelineAttentionItem,
],
});
expect(rows.map((row) => row.kind)).toEqual(["suggestion", "review", "headsUp"]);
expect(rows.find((row) => row.kind === "review")?.pipelineName).toBe("Content production");
expect(rows.map((row) => row.prompt).join(" ")).not.toMatch(/\bcase\b/i);
});
it("approves a final-review row through the review endpoint", async () => {
vi.mocked(pipelinesApi.listReviewCases).mockResolvedValue([
makeCase({ id: "review-1", title: "Final launch post" }),
]);
const { container, root, queryClient } = renderReviewQueue();
await flushQueries();
expect(container.textContent).toContain("Final calls");
const approveButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "Approve",
);
expect(approveButton).toBeTruthy();
approveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flushQueries();
expect(pipelinesApi.reviewCase).toHaveBeenCalledWith("review-1", {
decision: "approve",
note: null,
});
act(() => {
root.unmount();
});
queryClient.clear();
});
});
+691 -9
View File
@@ -1,12 +1,21 @@
import { useEffect, useMemo, useState, type FormEvent } from "react";
import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
BookOpenText,
Check,
CheckCircle2,
ChevronDown,
ChevronRight,
ExternalLink,
GitBranch,
Hexagon,
Loader2,
MessageSquare,
MoreHorizontal,
Plus,
Search,
Trash2,
} from "lucide-react";
import {
DndContext,
@@ -27,14 +36,17 @@ import {
} from "@dnd-kit/sortable";
import type {
Pipeline,
PipelineAttentionItem,
PipelineCase,
PipelineCaseEvent,
PipelineConnectionRef,
PipelineConnections,
PipelineIssueLink,
PipelineStage,
PipelineTransition,
} from "../api/pipelines";
import { pipelinesApi } from "../api/pipelines";
import { issuesApi } from "../api/issues";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useToastActions } from "../context/ToastContext";
@@ -47,11 +59,26 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { EmptyState } from "../components/EmptyState";
import { IssueChatThread } from "../components/IssueChatThread";
import { PageSkeleton } from "../components/PageSkeleton";
import { hasBlockingShortcutDialog, isKeyboardShortcutTextInputTarget } from "../lib/keyboardShortcuts";
import {
displayPipelineItemFields,
formatPipelineItemEvent,
getPendingTransitionBannerState,
humanizePipelineItemStatus,
itemHasChangedNotice,
} from "../lib/pipeline-item-detail";
import { formatLearningEvent } from "../lib/pipeline-learnings";
import { queryKeys } from "../lib/queryKeys";
import { cn, formatNumber, formatShortDate, relativeTime } from "../lib/utils";
@@ -225,6 +252,127 @@ function formatPipelineActivity(value: string | Date | null) {
return new Date(value).toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
type ReviewQueueKind = "suggestion" | "review" | "headsUp";
export interface ReviewQueueRow {
id: string;
caseId: string | null;
pipelineId: string;
pipelineName: string;
title: string;
prompt: string;
kind: ReviewQueueKind;
createdAt: string | Date | null;
caseItem?: PipelineCase;
}
const REVIEW_QUEUE_SECTION_LABELS: Record<ReviewQueueKind, string> = {
suggestion: "Suggestions to review",
review: "Final calls",
headsUp: "Heads-up",
};
const REVIEW_QUEUE_SECTION_ORDER: ReviewQueueKind[] = ["suggestion", "review", "headsUp"];
function attentionItemKind(item: PipelineAttentionItem): ReviewQueueKind {
const rawKind = String(item.kind ?? "").toLowerCase();
if (rawKind.includes("suggest")) return "suggestion";
if (rawKind.includes("review") || rawKind.includes("final")) return "review";
return "headsUp";
}
function hasPendingSuggestion(caseItem: PipelineCase) {
const fields = caseItem.fields ?? {};
return Boolean(fields.nextSuggestedStageId && !fields.suggestionResolution);
}
function humanizeFieldLabel(key: string) {
return key
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/[_-]+/g, " ")
.replace(/\b\w/g, (char) => char.toUpperCase());
}
function reviewCasePrompt(caseItem: PipelineCase) {
const fields = caseItem.fields ?? {};
if (typeof fields.decision === "string" && fields.decision.trim()) {
return fields.decision.trim();
}
return `Decide whether ${caseItem.title} is ready to move forward.`;
}
function suggestionPrompt(item: PipelineAttentionItem, pipelineName: string) {
return item.summary ?? `${pipelineName} thinks ${item.title} is ready to move forward.`;
}
function headsUpPrompt(item: PipelineAttentionItem) {
return item.summary ?? `${item.title} needs a quick look before work continues.`;
}
export function buildReviewQueueRows({
attentionItems,
reviewCases,
pipelines,
}: {
attentionItems: PipelineAttentionItem[];
reviewCases: PipelineCase[];
pipelines: Pipeline[];
}): ReviewQueueRow[] {
const pipelineNameById = new Map(pipelines.map((pipeline) => [pipeline.id, pipeline.name]));
const reviewCaseById = new Map(reviewCases.map((caseItem) => [caseItem.id, caseItem]));
const rows = new Map<string, ReviewQueueRow>();
for (const item of attentionItems) {
const caseId = item.caseId ?? item.id ?? null;
const caseItem = caseId ? reviewCaseById.get(caseId) : undefined;
const kind = caseItem ? "review" : attentionItemKind(item);
const pipelineName = pipelineNameById.get(item.pipelineId) ?? "Pipeline";
rows.set(`${kind}:${caseId ?? item.id}`, {
id: `${kind}:${caseId ?? item.id}`,
caseId,
pipelineId: item.pipelineId,
pipelineName,
title: item.title,
prompt:
kind === "suggestion"
? suggestionPrompt(item, pipelineName)
: kind === "review" && caseItem
? reviewCasePrompt(caseItem)
: headsUpPrompt(item),
kind,
createdAt: item.createdAt ?? caseItem?.lastActivityAt ?? caseItem?.updatedAt ?? null,
caseItem,
});
}
for (const caseItem of reviewCases) {
const kind: ReviewQueueKind = hasPendingSuggestion(caseItem) ? "suggestion" : "review";
const pipelineName = pipelineNameById.get(caseItem.pipelineId) ?? "Pipeline";
const id = `${kind}:${caseItem.id}`;
if (rows.has(id)) continue;
rows.set(id, {
id,
caseId: caseItem.id,
pipelineId: caseItem.pipelineId,
pipelineName,
title: caseItem.title,
prompt:
kind === "suggestion"
? `${pipelineName} thinks ${caseItem.title} is ready to move forward.`
: reviewCasePrompt(caseItem),
kind,
createdAt: caseItem.lastActivityAt ?? caseItem.updatedAt ?? caseItem.createdAt ?? null,
caseItem,
});
}
return [...rows.values()].sort((left, right) => {
const leftTime = left.createdAt ? new Date(left.createdAt).getTime() : 0;
const rightTime = right.createdAt ? new Date(right.createdAt).getTime() : 0;
return rightTime - leftTime;
});
}
const LEARNINGS_PAGE_SIZE = 100;
const LEARNING_EVENT_TYPES = "review_decided,transition_forced";
@@ -596,7 +744,7 @@ function PipelineCaseCard({
const isWorking = isWorkingCase(caseItem);
const blockerCount = getOpenBlockerCount(caseItem);
const hasNeedsAttention = blockerCount > 0;
const hasThisChanged = hasThisChanged(caseItem);
const hasChangedNotice = hasThisChanged(caseItem);
const childrenSummary = getChildrenSummaryCount(caseItem);
const {
attributes,
@@ -642,7 +790,7 @@ function PipelineCaseCard({
Needs attention
</span>
) : null}
{hasThisChanged ? (
{hasChangedNotice ? (
<span className="inline-flex items-center rounded-full border border-indigo-400/40 bg-indigo-50 px-2 py-0.5 text-[10px] font-medium text-indigo-700 dark:border-indigo-300/30 dark:bg-indigo-900/25 dark:text-indigo-300">
This changed
</span>
@@ -958,7 +1106,7 @@ export function PipelineBoardStub() {
onError: (error) => {
pushToast({
title: "Move blocked",
body: error instanceof Error ? error.message : "Unable to move case",
body: error instanceof Error ? error.message : "Unable to move item",
tone: "error",
});
queryClient.invalidateQueries({ queryKey: queryKeys.pipelines.detail(pipelineId!) });
@@ -1072,7 +1220,7 @@ export function PipelineBoardStub() {
<div>
<h1 className="text-2xl font-semibold tracking-normal text-foreground">{pipeline.name}</h1>
{pipeline.description ? <p className="mt-1 text-sm text-muted-foreground">{pipeline.description}</p> : null}
<p className="mt-1 text-xs text-muted-foreground">{cases.length} total case{cases.length === 1 ? "" : "s"}</p>
<p className="mt-1 text-xs text-muted-foreground">{cases.length} total item{cases.length === 1 ? "" : "s"}</p>
</div>
<Button variant="outline" onClick={() => navigate(`/pipelines/${pipeline.id}/settings`)}>
Settings
@@ -1097,7 +1245,7 @@ export function PipelineBoardStub() {
const items = boardColumns.byStage.get(stage.id) ?? [];
const activeSourceStageId = activeCaseId ? boardColumns.caseToColumn.get(activeCaseId) ?? null : null;
const activeTargetStageId = activeOverId
? resolvePipelineTargetStageId(activeOverId, columnsById, boardColumns.caseById)
? resolvePipelineTargetStageId(activeOverId, columnsById, boardColumns.caseToColumn)
: null;
const isDragTargeted = activeTargetStageId === stage.id;
const isDragBlocked = isDragTargeted
@@ -1116,8 +1264,8 @@ export function PipelineBoardStub() {
isDragBlocked={isDragBlocked}
onColumnEmpty={(columnStage) =>
columnStage.id === UNASSIGNED_STAGE_ID
? "Unassigned cases"
: "Drop cases here"
? "Unassigned items"
: "Drop items here"
}
/>
);
@@ -1157,8 +1305,542 @@ export function PipelineSettingsStub() {
return <StubPage title="Pipeline settings" />;
}
function ReviewQueueStatusChip({ failed }: { failed: boolean }) {
if (!failed) return null;
return (
<span className="inline-flex items-center gap-1 rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-xs font-semibold text-amber-800 dark:border-amber-900/70 dark:bg-amber-950/30 dark:text-amber-300">
<AlertTriangle className="h-3 w-3" />
Needs attention
</span>
);
}
function reviewQueueFieldEntries(caseItem: PipelineCase | undefined) {
const fields = caseItem?.fields ?? {};
const hidden = new Set([
"nextSuggestedStageId",
"nextSuggestedStageReason",
"suggestionResolution",
"review",
]);
return Object.entries(fields)
.filter(([key, value]) => !hidden.has(key) && ["string", "number", "boolean"].includes(typeof value))
.slice(0, 6);
}
function ReviewQueueDetailDialog({
row,
open,
pending,
onOpenChange,
onApprove,
onRequestChanges,
}: {
row: ReviewQueueRow | null;
open: boolean;
pending: boolean;
onOpenChange: (open: boolean) => void;
onApprove: (note: string) => void;
onRequestChanges: (note: string) => void;
}) {
const [note, setNote] = useState("");
useEffect(() => {
if (!open) setNote("");
}, [open]);
const fields = reviewQueueFieldEntries(row?.caseItem);
const trimmedNote = note.trim();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{row?.title ?? "Review item"}</DialogTitle>
<DialogDescription>
{row ? `${row.pipelineName} is waiting for your decision.` : "Review the item and decide what happens next."}
</DialogDescription>
</DialogHeader>
<div className="space-y-5">
<section className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">What is being decided</p>
<p className="text-sm text-foreground">{row?.prompt}</p>
</section>
<section className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">Item preview</p>
{fields.length > 0 ? (
<div className="divide-y divide-border rounded-md border border-border">
{fields.map(([key, value]) => (
<div key={key} className="grid grid-cols-[160px_1fr] gap-3 px-3 py-2 text-sm">
<span className="text-muted-foreground">{humanizeFieldLabel(key)}</span>
<span className="text-foreground">{String(value)}</span>
</div>
))}
</div>
) : (
<p className="rounded-md border border-border px-3 py-3 text-sm text-muted-foreground">
No preview details yet.
</p>
)}
</section>
<label className="block space-y-1.5 text-sm font-medium">
<span>Note</span>
<Textarea
value={note}
onChange={(event) => setNote(event.target.value)}
rows={3}
placeholder="Required when requesting changes."
/>
</label>
</div>
<DialogFooter className="gap-2 sm:gap-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>
Cancel
</Button>
<Button type="button" variant="outline" onClick={() => onRequestChanges(trimmedNote)} disabled={pending || !trimmedNote}>
Request changes
</Button>
<Button type="button" onClick={() => onApprove(trimmedNote)} disabled={pending}>
{pending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
Approve
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function ReviewQueueSection({
title,
rows,
activeRowId,
failedRowIds,
selectedRowIds,
pendingRowIds,
showSelection,
onActivate,
onToggleSelected,
onApprove,
onDecline,
onRequestChanges,
onOpen,
}: {
title: string;
rows: ReviewQueueRow[];
activeRowId: string | null;
failedRowIds: Set<string>;
selectedRowIds: Set<string>;
pendingRowIds: Set<string>;
showSelection: boolean;
onActivate: (rowId: string) => void;
onToggleSelected: (rowId: string) => void;
onApprove: (row: ReviewQueueRow) => void;
onDecline: (row: ReviewQueueRow) => void;
onRequestChanges: (row: ReviewQueueRow) => void;
onOpen: (row: ReviewQueueRow) => void;
}) {
if (rows.length === 0) return null;
return (
<section className="space-y-2">
<div className="flex items-baseline justify-between border-b border-border pb-2">
<h2 className="text-sm font-semibold text-foreground">{title}</h2>
<span className="text-xs text-muted-foreground">{formatNumber(rows.length)} item{rows.length === 1 ? "" : "s"}</span>
</div>
<div className="divide-y divide-border">
{rows.map((row) => {
const pending = pendingRowIds.has(row.id);
const failed = failedRowIds.has(row.id);
const selected = selectedRowIds.has(row.id);
const active = activeRowId === row.id;
const selectable = row.kind !== "headsUp" && Boolean(row.caseId);
return (
<div
key={row.id}
role="button"
tabIndex={0}
aria-current={active ? "true" : undefined}
className={cn(
"grid min-h-10 grid-cols-[minmax(0,1fr)_auto] items-center gap-3 px-2 py-2 text-sm outline-none transition-colors",
active ? "bg-accent/60" : "hover:bg-accent/40",
)}
onMouseEnter={() => onActivate(row.id)}
onFocus={() => onActivate(row.id)}
onClick={() => onOpen(row)}
onKeyDown={(event) => {
if (event.key === "Enter") onOpen(row);
}}
>
<div className="flex min-w-0 items-center gap-3">
{showSelection ? (
<input
type="checkbox"
aria-label={`Select ${row.title}`}
checked={selected}
disabled={!selectable || pending}
onClick={(event) => event.stopPropagation()}
onChange={() => onToggleSelected(row.id)}
className="h-4 w-4 rounded border-border"
/>
) : null}
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<p className="truncate font-semibold text-foreground">{row.title}</p>
<span className="shrink-0 rounded-full border border-border px-2 py-0.5 text-[11px] font-semibold text-muted-foreground">
{row.pipelineName}
</span>
<ReviewQueueStatusChip failed={failed} />
</div>
<p className="truncate text-muted-foreground">{row.prompt}</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className="hidden whitespace-nowrap text-xs text-muted-foreground sm:inline">
{row.createdAt ? relativeTime(row.createdAt) : "recently"}
</span>
{row.kind === "suggestion" ? (
<>
<Button type="button" size="sm" disabled={pending || !row.caseId} onClick={(event) => {
event.stopPropagation();
onApprove(row);
}}>
Approve
</Button>
<Button type="button" size="sm" variant="outline" disabled={pending || !row.caseId} onClick={(event) => {
event.stopPropagation();
onDecline(row);
}}>
Not yet
</Button>
</>
) : row.kind === "review" ? (
<>
<Button type="button" size="sm" disabled={pending || !row.caseId} onClick={(event) => {
event.stopPropagation();
onApprove(row);
}}>
Approve
</Button>
<Button type="button" size="sm" variant="outline" disabled={pending || !row.caseId} onClick={(event) => {
event.stopPropagation();
onRequestChanges(row);
}}>
Request changes
</Button>
</>
) : null}
</div>
</div>
);
})}
</div>
</section>
);
}
export function ReviewQueueStub() {
return <StubPage title="Review queue" />;
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const [selectedRowIds, setSelectedRowIds] = useState<Set<string>>(() => new Set());
const [hiddenRowIds, setHiddenRowIds] = useState<Set<string>>(() => new Set());
const [failedRowIds, setFailedRowIds] = useState<Set<string>>(() => new Set());
const [pendingRowIds, setPendingRowIds] = useState<Set<string>>(() => new Set());
const [activeRowId, setActiveRowId] = useState<string | null>(null);
const [detailRow, setDetailRow] = useState<ReviewQueueRow | null>(null);
useEffect(() => {
setBreadcrumbs([{ label: "Review queue" }]);
}, [setBreadcrumbs]);
const pipelinesQuery = useQuery({
queryKey: selectedCompanyId ? queryKeys.pipelines.list(selectedCompanyId) : ["pipelines", "none"],
queryFn: () => pipelinesApi.list(selectedCompanyId!, { includeCounts: true }),
enabled: !!selectedCompanyId,
});
const attentionQuery = useQuery({
queryKey: selectedCompanyId ? queryKeys.pipelines.attention(selectedCompanyId) : ["pipelines", "attention", "none"],
queryFn: () => pipelinesApi.listAttention(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const reviewCasesQuery = useQuery({
queryKey: selectedCompanyId ? queryKeys.pipelines.reviewCases(selectedCompanyId) : ["pipelines", "review-cases", "none"],
queryFn: () => pipelinesApi.listReviewCases(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const rows = useMemo(
() =>
buildReviewQueueRows({
attentionItems: attentionQuery.data ?? [],
reviewCases: reviewCasesQuery.data ?? [],
pipelines: pipelinesQuery.data ?? [],
}),
[attentionQuery.data, pipelinesQuery.data, reviewCasesQuery.data],
);
const visibleRows = rows.filter((row) => !hiddenRowIds.has(row.id));
const actionableRows = visibleRows.filter((row) => row.kind !== "headsUp" && row.caseId);
const selectedRows = visibleRows.filter((row) => selectedRowIds.has(row.id) && row.kind !== "headsUp" && row.caseId);
const groupedRows = REVIEW_QUEUE_SECTION_ORDER.map((kind) => ({
kind,
rows: visibleRows.filter((row) => row.kind === kind),
}));
useEffect(() => {
if (visibleRows.length === 0) {
setActiveRowId(null);
return;
}
if (!activeRowId || !visibleRows.some((row) => row.id === activeRowId)) {
setActiveRowId(visibleRows[0].id);
}
}, [activeRowId, visibleRows]);
const invalidateReviewQueue = async () => {
if (!selectedCompanyId) return;
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.pipelines.attention(selectedCompanyId) }),
queryClient.invalidateQueries({ queryKey: queryKeys.pipelines.reviewCases(selectedCompanyId) }),
queryClient.invalidateQueries({ queryKey: queryKeys.pipelines.list(selectedCompanyId) }),
]);
};
const decideRow = useMutation({
mutationFn: async ({ row, decision, note }: { row: ReviewQueueRow; decision: "approve" | "decline" | "request_changes"; note?: string }) => {
if (!row.caseId) throw new Error("This item is not ready for a decision.");
if (row.kind === "suggestion") {
await pipelinesApi.resolveSuggestion(row.caseId, {
decision: decision === "decline" ? "decline" : "accept",
note: note || null,
});
return;
}
await pipelinesApi.reviewCase(row.caseId, {
decision: decision === "request_changes" ? "request_changes" : "approve",
note: note || null,
});
},
onMutate: ({ row }) => {
setPendingRowIds((current) => new Set(current).add(row.id));
setHiddenRowIds((current) => new Set(current).add(row.id));
setFailedRowIds((current) => {
const next = new Set(current);
next.delete(row.id);
return next;
});
setSelectedRowIds((current) => {
const next = new Set(current);
next.delete(row.id);
return next;
});
},
onError: (_error, { row }) => {
setHiddenRowIds((current) => {
const next = new Set(current);
next.delete(row.id);
return next;
});
setFailedRowIds((current) => new Set(current).add(row.id));
},
onSettled: async (_data, _error, { row }) => {
setPendingRowIds((current) => {
const next = new Set(current);
next.delete(row.id);
return next;
});
await invalidateReviewQueue();
},
});
const bulkApprove = useMutation({
mutationFn: async (targetRows: ReviewQueueRow[]) => {
if (!selectedCompanyId) throw new Error("Select a company first.");
const reviewIds = targetRows
.filter((row) => row.kind === "review" && row.caseId)
.map((row) => row.caseId!);
const suggestionRows = targetRows.filter((row) => row.kind === "suggestion" && row.caseId);
await Promise.all([
reviewIds.length > 0
? pipelinesApi.bulkReviewCases(selectedCompanyId, { caseIds: reviewIds, decision: "approve" })
: Promise.resolve(),
...suggestionRows.map((row) =>
pipelinesApi.resolveSuggestion(row.caseId!, { decision: "accept" }),
),
]);
},
onMutate: (targetRows) => {
const ids = targetRows.map((row) => row.id);
setPendingRowIds((current) => new Set([...current, ...ids]));
setHiddenRowIds((current) => new Set([...current, ...ids]));
setSelectedRowIds(new Set());
setFailedRowIds((current) => {
const next = new Set(current);
for (const id of ids) next.delete(id);
return next;
});
},
onError: (_error, targetRows) => {
const ids = targetRows.map((row) => row.id);
setHiddenRowIds((current) => {
const next = new Set(current);
for (const id of ids) next.delete(id);
return next;
});
setFailedRowIds((current) => new Set([...current, ...ids]));
},
onSettled: async (_data, _error, targetRows) => {
const ids = targetRows.map((row) => row.id);
setPendingRowIds((current) => {
const next = new Set(current);
for (const id of ids) next.delete(id);
return next;
});
await invalidateReviewQueue();
},
});
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
if (
event.defaultPrevented ||
event.metaKey ||
event.ctrlKey ||
event.altKey ||
hasBlockingShortcutDialog() ||
isKeyboardShortcutTextInputTarget(event.target) ||
visibleRows.length === 0
) {
return;
}
const currentIndex = Math.max(0, visibleRows.findIndex((row) => row.id === activeRowId));
const key = event.key.toLowerCase();
if (event.key === "ArrowDown" || key === "j") {
event.preventDefault();
setActiveRowId(visibleRows[Math.min(visibleRows.length - 1, currentIndex + 1)].id);
return;
}
if (event.key === "ArrowUp" || key === "k") {
event.preventDefault();
setActiveRowId(visibleRows[Math.max(0, currentIndex - 1)].id);
return;
}
const activeRow = visibleRows[currentIndex];
if (!activeRow) return;
if (event.key === "Enter") {
event.preventDefault();
setDetailRow(activeRow);
return;
}
if (key === "a" && activeRow.kind !== "headsUp" && activeRow.caseId) {
event.preventDefault();
decideRow.mutate({ row: activeRow, decision: "approve" });
}
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [activeRowId, decideRow, visibleRows]);
if (!selectedCompanyId) {
return <EmptyState icon={Hexagon} message="Select a company to view the review queue." />;
}
if (pipelinesQuery.isLoading || attentionQuery.isLoading || reviewCasesQuery.isLoading) {
return <PageSkeleton variant="list" />;
}
const selectedCount = selectedRows.length;
return (
<div className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-normal text-foreground">Review queue</h1>
<p className="mt-1 text-sm text-muted-foreground">
Needs your attention ({formatNumber(visibleRows.length)})
</p>
</div>
<Button
type="button"
disabled={selectedCount === 0 || bulkApprove.isPending}
onClick={() => bulkApprove.mutate(selectedRows)}
>
{bulkApprove.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
Approve {formatNumber(selectedCount)} item{selectedCount === 1 ? "" : "s"}
</Button>
</div>
{attentionQuery.error || reviewCasesQuery.error ? (
<p className="text-sm text-amber-700 dark:text-amber-300">Some items need attention. Try again in a moment.</p>
) : null}
{visibleRows.length === 0 ? (
<EmptyState icon={Check} message="All caught up." />
) : (
<div className="space-y-6">
{groupedRows.map((group) => (
<ReviewQueueSection
key={group.kind}
title={REVIEW_QUEUE_SECTION_LABELS[group.kind]}
rows={group.rows}
activeRowId={activeRowId}
failedRowIds={failedRowIds}
selectedRowIds={selectedRowIds}
pendingRowIds={pendingRowIds}
showSelection={actionableRows.length > 1}
onActivate={setActiveRowId}
onToggleSelected={(rowId) => {
setSelectedRowIds((current) => {
const next = new Set(current);
if (next.has(rowId)) next.delete(rowId);
else next.add(rowId);
return next;
});
}}
onApprove={(row) => decideRow.mutate({ row, decision: "approve" })}
onDecline={(row) => decideRow.mutate({ row, decision: "decline" })}
onRequestChanges={(row) => setDetailRow(row)}
onOpen={(row) => setDetailRow(row)}
/>
))}
</div>
)}
<p className="text-xs text-muted-foreground">
Shortcuts: <span className="font-semibold">j</span>/<span className="font-semibold">k</span> or arrow keys move, <span className="font-semibold">Enter</span> opens, <span className="font-semibold">a</span> approves.
</p>
<ReviewQueueDetailDialog
row={detailRow}
open={Boolean(detailRow)}
pending={decideRow.isPending}
onOpenChange={(open) => {
if (!open) setDetailRow(null);
}}
onApprove={(note) => {
if (!detailRow) return;
decideRow.mutate({ row: detailRow, decision: "approve", note });
setDetailRow(null);
}}
onRequestChanges={(note) => {
if (!detailRow) return;
decideRow.mutate({ row: detailRow, decision: "request_changes", note });
setDetailRow(null);
}}
/>
</div>
);
}
export function LearningsStub() {
+21 -2
View File
@@ -23,12 +23,31 @@ export default defineConfig(({ mode }) => ({
},
},
server: {
port: 5173,
port: 5174,
host: "0.0.0.0",
allowedHosts: true,
watch: createUiDevWatchOptions(process.cwd()),
proxy: {
"/api": {
target: "http://localhost:3100",
target: "http://paperclip-dev:3100",
ws: true,
changeOrigin: true,
configure(proxy) {
proxy.on("proxyReq", (proxyReq) => {
proxyReq.setHeader("Origin", "http://paperclip-dev:3100");
proxyReq.setHeader("Referer", "http://paperclip-dev:3100/");
});
},
},
"/socket.io": {
target: "http://paperclip-dev:3100",
ws: true,
changeOrigin: true,
configure(proxy) {
proxy.on("proxyReq", (proxyReq) => {
proxyReq.setHeader("Origin", "http://paperclip-dev:3100");
});
},
},
},
},