mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +01:00
Implement pipeline learnings UI
This commit is contained in:
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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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 />} />
|
||||
|
||||
@@ -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,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} />
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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.`,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user