From 3e36711b1662830b25b258808b5fed4840e7c2e8 Mon Sep 17 00:00:00 2001 From: Timmyy3000 Date: Tue, 12 May 2026 11:49:14 +0100 Subject: [PATCH 1/9] feat(stages): add color column, won/lost CHECK constraint, color palette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema: - stages.color text (nullable). NULL means "use the existing default style" via the legacy name-keyed lookup in StagePill. - CHECK (NOT (is_won AND is_lost)) constraint on stages so a single stage can't be both flags simultaneously. Seed data is compliant (Won has only is_won=true; Lost has only is_lost=true), so the constraint adds cleanly. Palette: - lib/stage-colors.ts: 10-swatch curated palette (slate, blue, indigo, violet, pink, rose, amber, lime, emerald, teal) with oklch tokens matching the project's design tokens. STAGE_COLORS const + StageColor type + STAGE_COLOR_TOKENS map + isStageColor type guard. UI: - StagePill now accepts an optional color prop. Resolution order: (1) explicit color → STAGE_COLOR_TOKENS lookup (2) name-keyed STAGE_CONFIG (existing behavior, unchanged) (3) prospecting default Tier 2 keeps seeded stages (Lead/Qualified/Discovery/.../Won/Lost) visually identical without a backfill migration. Co-authored-by: Claude <81847+claude@users.noreply.github.com> --- components/pills.tsx | 11 + .../0004_stage-color-and-flag-check.sql | 2 + db/migrations/meta/0004_snapshot.json | 3005 +++++++++++++++++ db/migrations/meta/_journal.json | 7 + db/schema/deals.ts | 19 +- lib/stage-colors.ts | 33 + 6 files changed, 3075 insertions(+), 2 deletions(-) create mode 100644 db/migrations/0004_stage-color-and-flag-check.sql create mode 100644 db/migrations/meta/0004_snapshot.json create mode 100644 lib/stage-colors.ts diff --git a/components/pills.tsx b/components/pills.tsx index 6cdd381..990b155 100644 --- a/components/pills.tsx +++ b/components/pills.tsx @@ -1,3 +1,4 @@ +import { isStageColor, STAGE_COLOR_TOKENS, type StageColor } from "@/lib/stage-colors"; import { cn } from "@/lib/utils"; const TEMP_CONFIG: Record< @@ -126,14 +127,24 @@ export function PersonaPill({ value, className }: { value: string; className?: s export function StagePill({ value, + color, className, }: { value: string | null | undefined; + color?: StageColor | string | null; className?: string; }) { if (!value) { return ; } + if (isStageColor(color)) { + const tokens = STAGE_COLOR_TOKENS[color]; + return ( + + {value} + + ); + } const key = value.toLowerCase().replace(/\s+/g, "_"); const cfg = STAGE_CONFIG[key] ?? STAGE_CONFIG.prospecting!; return ( diff --git a/db/migrations/0004_stage-color-and-flag-check.sql b/db/migrations/0004_stage-color-and-flag-check.sql new file mode 100644 index 0000000..7e05bbe --- /dev/null +++ b/db/migrations/0004_stage-color-and-flag-check.sql @@ -0,0 +1,2 @@ +ALTER TABLE "stages" ADD COLUMN "color" text;--> statement-breakpoint +ALTER TABLE "stages" ADD CONSTRAINT "stage_not_both_won_lost" CHECK (NOT ("stages"."is_won" AND "stages"."is_lost")); \ No newline at end of file diff --git a/db/migrations/meta/0004_snapshot.json b/db/migrations/meta/0004_snapshot.json new file mode 100644 index 0000000..d9a6418 --- /dev/null +++ b/db/migrations/meta/0004_snapshot.json @@ -0,0 +1,3005 @@ +{ + "id": "9595afde-85cb-40f4-93f5-7c3da07d1b02", + "prevId": "6a475fa4-e330-4d99-903f-43c4d9f0b6c8", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.companies": { + "name": "companies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "industry": { + "name": "industry", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "employee_count": { + "name": "employee_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "temperature": { + "name": "temperature", + "type": "temperature", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "temperature_updated_at": { + "name": "temperature_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_signal_at": { + "name": "last_signal_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "companies_org_domain_unique": { + "name": "companies_org_domain_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "companies_organization_id_organization_id_fk": { + "name": "companies_organization_id_organization_id_fk", + "tableFrom": "companies", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.people": { + "name": "people", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linkedin_url": { + "name": "linkedin_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "persona": { + "name": "persona", + "type": "persona", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "engagement": { + "name": "engagement", + "type": "temperature", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "last_interaction_at": { + "name": "last_interaction_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "people_organization_id_organization_id_fk": { + "name": "people_organization_id_organization_id_fk", + "tableFrom": "people", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "people_company_id_companies_id_fk": { + "name": "people_company_id_companies_id_fk", + "tableFrom": "people", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.research": { + "name": "research", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "section": { + "name": "section", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "research_company_section_unique": { + "name": "research_company_section_unique", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "section", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "research_organization_id_organization_id_fk": { + "name": "research_organization_id_organization_id_fk", + "tableFrom": "research", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "research_company_id_companies_id_fk": { + "name": "research_company_id_companies_id_fk", + "tableFrom": "research", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.signals": { + "name": "signals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "person_id": { + "name": "person_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "signals_org_company_occurred_idx": { + "name": "signals_org_company_occurred_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "signals_organization_id_organization_id_fk": { + "name": "signals_organization_id_organization_id_fk", + "tableFrom": "signals", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "signals_company_id_companies_id_fk": { + "name": "signals_company_id_companies_id_fk", + "tableFrom": "signals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "signals_person_id_people_id_fk": { + "name": "signals_person_id_people_id_fk", + "tableFrom": "signals", + "tableTo": "people", + "columnsFrom": [ + "person_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deals": { + "name": "deals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pipeline_id": { + "name": "pipeline_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "stage_id": { + "name": "stage_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": false + }, + "expected_close_date": { + "name": "expected_close_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stage_entered_at": { + "name": "stage_entered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "deals_organization_id_organization_id_fk": { + "name": "deals_organization_id_organization_id_fk", + "tableFrom": "deals", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deals_company_id_companies_id_fk": { + "name": "deals_company_id_companies_id_fk", + "tableFrom": "deals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deals_pipeline_id_pipelines_id_fk": { + "name": "deals_pipeline_id_pipelines_id_fk", + "tableFrom": "deals", + "tableTo": "pipelines", + "columnsFrom": [ + "pipeline_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "deals_stage_id_stages_id_fk": { + "name": "deals_stage_id_stages_id_fk", + "tableFrom": "deals", + "tableTo": "stages", + "columnsFrom": [ + "stage_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "deals_owner_user_id_user_id_fk": { + "name": "deals_owner_user_id_user_id_fk", + "tableFrom": "deals", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pipelines": { + "name": "pipelines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pipelines_org_name_unique": { + "name": "pipelines_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pipelines_organization_id_organization_id_fk": { + "name": "pipelines_organization_id_organization_id_fk", + "tableFrom": "pipelines", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stages": { + "name": "stages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "pipeline_id": { + "name": "pipeline_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_won": { + "name": "is_won", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_lost": { + "name": "is_lost", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "stages_pipeline_name_unique": { + "name": "stages_pipeline_name_unique", + "columns": [ + { + "expression": "pipeline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "stages_pipeline_id_pipelines_id_fk": { + "name": "stages_pipeline_id_pipelines_id_fk", + "tableFrom": "stages", + "tableTo": "pipelines", + "columnsFrom": [ + "pipeline_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "stage_not_both_won_lost": { + "name": "stage_not_both_won_lost", + "value": "NOT (\"stages\".\"is_won\" AND \"stages\".\"is_lost\")" + } + }, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "person_id": { + "name": "person_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "deal_id": { + "name": "deal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reasoning": { + "name": "reasoning", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "task_priority", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "type": { + "name": "type", + "type": "task_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'other'" + }, + "status": { + "name": "status", + "type": "task_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "due_date": { + "name": "due_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "assignee_user_id": { + "name": "assignee_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tasks_org_status_priority_idx": { + "name": "tasks_org_status_priority_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_organization_id_organization_id_fk": { + "name": "tasks_organization_id_organization_id_fk", + "tableFrom": "tasks", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_company_id_companies_id_fk": { + "name": "tasks_company_id_companies_id_fk", + "tableFrom": "tasks", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_person_id_people_id_fk": { + "name": "tasks_person_id_people_id_fk", + "tableFrom": "tasks", + "tableTo": "people", + "columnsFrom": [ + "person_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_deal_id_deals_id_fk": { + "name": "tasks_deal_id_deals_id_fk", + "tableFrom": "tasks", + "tableTo": "deals", + "columnsFrom": [ + "deal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_assignee_user_id_user_id_fk": { + "name": "tasks_assignee_user_id_user_id_fk", + "tableFrom": "tasks", + "tableTo": "user", + "columnsFrom": [ + "assignee_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.meeting_attendees": { + "name": "meeting_attendees", + "schema": "", + "columns": { + "meeting_id": { + "name": "meeting_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "person_id": { + "name": "person_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "meeting_attendees_meeting_id_meetings_id_fk": { + "name": "meeting_attendees_meeting_id_meetings_id_fk", + "tableFrom": "meeting_attendees", + "tableTo": "meetings", + "columnsFrom": [ + "meeting_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "meeting_attendees_person_id_people_id_fk": { + "name": "meeting_attendees_person_id_people_id_fk", + "tableFrom": "meeting_attendees", + "tableTo": "people", + "columnsFrom": [ + "person_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "meeting_attendees_meeting_id_person_id_pk": { + "name": "meeting_attendees_meeting_id_person_id_pk", + "columns": [ + "meeting_id", + "person_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.meetings": { + "name": "meetings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "duration_minutes": { + "name": "duration_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "recording_url": { + "name": "recording_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "meetings_organization_id_organization_id_fk": { + "name": "meetings_organization_id_organization_id_fk", + "tableFrom": "meetings", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "meetings_company_id_companies_id_fk": { + "name": "meetings_company_id_companies_id_fk", + "tableFrom": "meetings", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notes": { + "name": "notes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "person_id": { + "name": "person_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "deal_id": { + "name": "deal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "author": { + "name": "author", + "type": "note_author", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "notes_organization_id_organization_id_fk": { + "name": "notes_organization_id_organization_id_fk", + "tableFrom": "notes", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notes_company_id_companies_id_fk": { + "name": "notes_company_id_companies_id_fk", + "tableFrom": "notes", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notes_person_id_people_id_fk": { + "name": "notes_person_id_people_id_fk", + "tableFrom": "notes", + "tableTo": "people", + "columnsFrom": [ + "person_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notes_deal_id_deals_id_fk": { + "name": "notes_deal_id_deals_id_fk", + "tableFrom": "notes", + "tableTo": "deals", + "columnsFrom": [ + "deal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notes_author_user_id_user_id_fk": { + "name": "notes_author_user_id_user_id_fk", + "tableFrom": "notes", + "tableTo": "user", + "columnsFrom": [ + "author_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_settings": { + "name": "app_settings", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "app_settings_org_key_unique": { + "name": "app_settings_org_key_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_settings_organization_id_organization_id_fk": { + "name": "app_settings_organization_id_organization_id_fk", + "tableFrom": "app_settings", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.service_tokens": { + "name": "service_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "service_tokens_organization_id_organization_id_fk": { + "name": "service_tokens_organization_id_organization_id_fk", + "tableFrom": "service_tokens", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "service_tokens_created_by_user_id_user_id_fk": { + "name": "service_tokens_created_by_user_id_user_id_fk", + "tableFrom": "service_tokens", + "tableTo": "user", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "service_tokens_token_hash_unique": { + "name": "service_tokens_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_access_token_client_id_oauth_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": { + "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_refresh_token", + "columnsFrom": [ + "refresh_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "require_pkce": { + "name": "require_pkce", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "nullsNotDistinct": false, + "columns": [ + "client_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_refresh_token": { + "name": "oauth_refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked": { + "name": "revoked", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_refresh_token_client_id_oauth_client_id_fk": { + "name": "oauth_refresh_token_client_id_oauth_client_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_consent_scope": { + "name": "oauth_consent_scope", + "schema": "", + "columns": { + "consent_code": { + "name": "consent_code", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_consent_scope_user_id_user_id_fk": { + "name": "oauth_consent_scope_user_id_user_id_fk", + "tableFrom": "oauth_consent_scope", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_scope_organization_id_organization_id_fk": { + "name": "oauth_consent_scope_organization_id_organization_id_fk", + "tableFrom": "oauth_consent_scope", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_user_name": { + "name": "actor_user_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_token_id": { + "name": "actor_token_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_token_name": { + "name": "actor_token_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_client_id": { + "name": "actor_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "changes": { + "name": "changes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_org_created_idx": { + "name": "audit_log_org_created_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_org_actor_user_idx": { + "name": "audit_log_org_actor_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_org_entity_created_idx": { + "name": "audit_log_org_entity_created_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_organization_id_organization_id_fk": { + "name": "audit_log_organization_id_organization_id_fk", + "tableFrom": "audit_log", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_log_actor_user_id_user_id_fk": { + "name": "audit_log_actor_user_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_token_id_service_tokens_id_fk": { + "name": "audit_log_actor_token_id_service_tokens_id_fk", + "tableFrom": "audit_log", + "tableTo": "service_tokens", + "columnsFrom": [ + "actor_token_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.note_author": { + "name": "note_author", + "schema": "public", + "values": [ + "user", + "agent" + ] + }, + "public.persona": { + "name": "persona", + "schema": "public", + "values": [ + "champion", + "decision_maker", + "technical_evaluator", + "end_user", + "unknown" + ] + }, + "public.task_priority": { + "name": "task_priority", + "schema": "public", + "values": [ + "urgent", + "high", + "medium", + "low" + ] + }, + "public.task_status": { + "name": "task_status", + "schema": "public", + "values": [ + "open", + "done", + "dismissed" + ] + }, + "public.task_type": { + "name": "task_type", + "schema": "public", + "values": [ + "call", + "email", + "linkedin", + "research", + "other" + ] + }, + "public.temperature": { + "name": "temperature", + "schema": "public", + "values": [ + "cold", + "warm", + "hot", + "on_fire" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/db/migrations/meta/_journal.json b/db/migrations/meta/_journal.json index 649b958..c5518cc 100644 --- a/db/migrations/meta/_journal.json +++ b/db/migrations/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1778516299266, "tag": "0003_add-audit-log", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1778582834098, + "tag": "0004_stage-color-and-flag-check", + "breakpoints": true } ] } \ No newline at end of file diff --git a/db/schema/deals.ts b/db/schema/deals.ts index 465309a..166dbd8 100644 --- a/db/schema/deals.ts +++ b/db/schema/deals.ts @@ -1,4 +1,15 @@ -import { boolean, integer, numeric, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core"; +import { sql } from "drizzle-orm"; +import { + boolean, + check, + integer, + numeric, + pgTable, + text, + timestamp, + uniqueIndex, + uuid, +} from "drizzle-orm/pg-core"; import { organization, user } from "./auth"; import { companies } from "./companies"; @@ -25,11 +36,15 @@ export const stages = pgTable( .references(() => pipelines.id, { onDelete: "cascade" }), name: text("name").notNull(), order: integer("order").notNull(), + color: text("color"), isWon: boolean("is_won").notNull().default(false), isLost: boolean("is_lost").notNull().default(false), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }, - (table) => [uniqueIndex("stages_pipeline_name_unique").on(table.pipelineId, table.name)], + (table) => [ + uniqueIndex("stages_pipeline_name_unique").on(table.pipelineId, table.name), + check("stage_not_both_won_lost", sql`NOT (${table.isWon} AND ${table.isLost})`), + ], ); export const deals = pgTable("deals", { diff --git a/lib/stage-colors.ts b/lib/stage-colors.ts new file mode 100644 index 0000000..0e7ee90 --- /dev/null +++ b/lib/stage-colors.ts @@ -0,0 +1,33 @@ +export const STAGE_COLORS = [ + "slate", + "blue", + "indigo", + "violet", + "pink", + "rose", + "amber", + "lime", + "emerald", + "teal", +] as const; + +export type StageColor = (typeof STAGE_COLORS)[number]; + +export type StageColorTokens = { bg: string; text: string }; + +export const STAGE_COLOR_TOKENS: Record = { + slate: { bg: "oklch(0.19 0.01 250 / 0.6)", text: "oklch(0.55 0.04 250)" }, + blue: { bg: "oklch(0.20 0.04 220 / 0.6)", text: "oklch(0.65 0.08 220)" }, + indigo: { bg: "oklch(0.21 0.06 270 / 0.6)", text: "oklch(0.68 0.13 270)" }, + violet: { bg: "oklch(0.22 0.06 295 / 0.6)", text: "oklch(0.70 0.13 295)" }, + pink: { bg: "oklch(0.22 0.07 340 / 0.6)", text: "oklch(0.72 0.14 340)" }, + rose: { bg: "oklch(0.22 0.07 15 / 0.6)", text: "oklch(0.70 0.14 15)" }, + amber: { bg: "oklch(0.22 0.07 65 / 0.6)", text: "oklch(0.72 0.14 65)" }, + lime: { bg: "oklch(0.21 0.07 125 / 0.6)", text: "oklch(0.70 0.14 125)" }, + emerald: { bg: "oklch(0.20 0.06 155 / 0.6)", text: "oklch(0.65 0.14 155)" }, + teal: { bg: "oklch(0.20 0.05 195 / 0.6)", text: "oklch(0.65 0.10 195)" }, +}; + +export function isStageColor(value: unknown): value is StageColor { + return typeof value === "string" && (STAGE_COLORS as readonly string[]).includes(value); +} From ceef17cf6cdde33256be0ebf0d1b31cf9c69bdee Mon Sep 17 00:00:00 2001 From: Timmyy3000 Date: Tue, 12 May 2026 11:51:17 +0100 Subject: [PATCH 2/9] feat(stages): lib/stages business logic with assertStageInOrg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure functions taking (db, orgId, ...) so server actions and MCP tools can be thin shells, and tests can hit the lib directly without mocking Better Auth. - assertStageInOrg(db, stageId, orgId) → Stage | null. Single source of truth for the org-scoping check; INNER JOIN through pipelines. - createStage → always inserts at end (max(order)+1). Banning a position arg keeps the swap-reorder mechanic safe by preventing tied orders. - updateStage → patch semantics; color: null clears, omitted leaves alone. - reorderStage(direction "up" | "down") → swap order values with the adjacent sibling inside a transaction. Boundary call returns unchanged before/after. - deleteStage(stageId, destinationStageId) → tx that bulk-migrates deals to the destination then deletes the stage. Refuses self-as-destination, cross-pipeline destination, and the only-stage-in-pipeline case. - previewStageFlagToggle → count of deals on the stage; cheap pre-flight for the UI confirm dialog. - StageOpError discriminates the five failure cases by `code` so server actions and MCP tools can map them to user-facing messages without string-matching. Co-authored-by: Claude <81847+claude@users.noreply.github.com> --- lib/stages.ts | 271 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 271 insertions(+) create mode 100644 lib/stages.ts diff --git a/lib/stages.ts b/lib/stages.ts new file mode 100644 index 0000000..87a025e --- /dev/null +++ b/lib/stages.ts @@ -0,0 +1,271 @@ +import { and, asc, count, eq, sql } from "drizzle-orm"; +import type { Database } from "@/db/client"; +import { deals, pipelines, stages } from "@/db/schema/deals"; +import { isStageColor, type StageColor } from "@/lib/stage-colors"; + +export type Stage = typeof stages.$inferSelect; +export type StageInsert = typeof stages.$inferInsert; + +export type StageFlagField = "isWon" | "isLost"; + +export class StageOpError extends Error { + readonly code: + | "not_found" + | "wrong_pipeline" + | "last_stage" + | "name_in_use" + | "invalid_color" + | "won_lost_conflict"; + constructor(code: StageOpError["code"], message: string) { + super(message); + this.code = code; + this.name = "StageOpError"; + } +} + +export async function assertStageInOrg( + db: Database, + stageId: string, + organizationId: string, +): Promise { + const rows = await db + .select({ stage: stages }) + .from(stages) + .innerJoin(pipelines, eq(stages.pipelineId, pipelines.id)) + .where(and(eq(stages.id, stageId), eq(pipelines.organizationId, organizationId))) + .limit(1); + return rows[0]?.stage ?? null; +} + +async function assertPipelineInOrg( + db: Database, + pipelineId: string, + organizationId: string, +): Promise { + const rows = await db + .select({ id: pipelines.id }) + .from(pipelines) + .where(and(eq(pipelines.id, pipelineId), eq(pipelines.organizationId, organizationId))) + .limit(1); + return Boolean(rows[0]); +} + +export type CreateStageInput = { + pipelineId: string; + name: string; + color?: StageColor | null; + isWon?: boolean; + isLost?: boolean; +}; + +export async function createStage( + db: Database, + organizationId: string, + input: CreateStageInput, +): Promise<{ before: null; after: Stage }> { + if (input.color != null && !isStageColor(input.color)) { + throw new StageOpError("invalid_color", "Unknown stage color"); + } + if (input.isWon && input.isLost) { + throw new StageOpError("won_lost_conflict", "Stage cannot be both won and lost"); + } + const ok = await assertPipelineInOrg(db, input.pipelineId, organizationId); + if (!ok) throw new StageOpError("not_found", "Pipeline not found"); + + const maxRows = await db + .select({ max: sql`MAX(${stages.order})` }) + .from(stages) + .where(eq(stages.pipelineId, input.pipelineId)); + const nextOrder = (maxRows[0]?.max ?? -1) + 1; + + try { + const [row] = await db + .insert(stages) + .values({ + pipelineId: input.pipelineId, + name: input.name, + order: nextOrder, + color: input.color ?? null, + isWon: input.isWon ?? false, + isLost: input.isLost ?? false, + }) + .returning(); + return { before: null, after: row! }; + } catch (err) { + if (isUniqueViolation(err)) { + throw new StageOpError("name_in_use", "A stage with that name already exists"); + } + throw err; + } +} + +export type UpdateStageInput = { + id: string; + name?: string; + color?: StageColor | null; + isWon?: boolean; + isLost?: boolean; +}; + +export async function updateStage( + db: Database, + organizationId: string, + input: UpdateStageInput, +): Promise<{ before: Stage; after: Stage } | null> { + const before = await assertStageInOrg(db, input.id, organizationId); + if (!before) return null; + + if (input.color !== undefined && input.color !== null && !isStageColor(input.color)) { + throw new StageOpError("invalid_color", "Unknown stage color"); + } + const nextWon = input.isWon ?? before.isWon; + const nextLost = input.isLost ?? before.isLost; + if (nextWon && nextLost) { + throw new StageOpError("won_lost_conflict", "Stage cannot be both won and lost"); + } + + const patch: Partial = {}; + if (input.name !== undefined) patch.name = input.name; + if (input.color !== undefined) patch.color = input.color; + if (input.isWon !== undefined) patch.isWon = input.isWon; + if (input.isLost !== undefined) patch.isLost = input.isLost; + if (Object.keys(patch).length === 0) return { before, after: before }; + + try { + const [row] = await db + .update(stages) + .set(patch) + .where(eq(stages.id, input.id)) + .returning(); + if (!row) return null; + return { before, after: row }; + } catch (err) { + if (isUniqueViolation(err)) { + throw new StageOpError("name_in_use", "A stage with that name already exists"); + } + throw err; + } +} + +export type ReorderDirection = "up" | "down"; + +export async function reorderStage( + db: Database, + organizationId: string, + stageId: string, + direction: ReorderDirection, +): Promise<{ before: Stage; after: Stage; partner: Stage } | null> { + const current = await assertStageInOrg(db, stageId, organizationId); + if (!current) return null; + + const siblings = await db + .select() + .from(stages) + .where(eq(stages.pipelineId, current.pipelineId)) + .orderBy(asc(stages.order), asc(stages.id)); + + const idx = siblings.findIndex((s) => s.id === stageId); + if (idx < 0) return null; + const targetIdx = direction === "up" ? idx - 1 : idx + 1; + if (targetIdx < 0 || targetIdx >= siblings.length) return { before: current, after: current, partner: current }; + + const partner = siblings[targetIdx]!; + const currentOrder = current.order; + const partnerOrder = partner.order; + + const [updatedCurrent, updatedPartner] = await db.transaction(async (tx) => { + const [a] = await tx + .update(stages) + .set({ order: partnerOrder }) + .where(eq(stages.id, current.id)) + .returning(); + const [b] = await tx + .update(stages) + .set({ order: currentOrder }) + .where(eq(stages.id, partner.id)) + .returning(); + return [a!, b!]; + }); + + return { before: current, after: updatedCurrent, partner: updatedPartner }; +} + +export async function deleteStage( + db: Database, + organizationId: string, + stageId: string, + destinationStageId: string, +): Promise<{ before: Stage; migratedDealIds: string[] } | null> { + if (stageId === destinationStageId) { + throw new StageOpError("wrong_pipeline", "Destination must be a different stage"); + } + const current = await assertStageInOrg(db, stageId, organizationId); + if (!current) return null; + + const dest = await assertStageInOrg(db, destinationStageId, organizationId); + if (!dest) throw new StageOpError("not_found", "Destination stage not found"); + if (dest.pipelineId !== current.pipelineId) { + throw new StageOpError("wrong_pipeline", "Destination must be in the same pipeline"); + } + + const siblingCount = await db + .select({ n: count() }) + .from(stages) + .where(eq(stages.pipelineId, current.pipelineId)); + if ((siblingCount[0]?.n ?? 0) <= 1) { + throw new StageOpError("last_stage", "Cannot delete the only stage in a pipeline"); + } + + const migratedDealIds = await db.transaction(async (tx) => { + const affected = await tx + .select({ id: deals.id }) + .from(deals) + .where(eq(deals.stageId, stageId)); + const ids = affected.map((d) => d.id); + if (ids.length > 0) { + await tx + .update(deals) + .set({ stageId: destinationStageId, stageEnteredAt: new Date(), updatedAt: new Date() }) + .where(eq(deals.stageId, stageId)); + } + await tx.delete(stages).where(eq(stages.id, stageId)); + return ids; + }); + + return { before: current, migratedDealIds }; +} + +export async function previewStageFlagToggle( + db: Database, + organizationId: string, + stageId: string, +): Promise<{ affectedDealCount: number } | null> { + const stage = await assertStageInOrg(db, stageId, organizationId); + if (!stage) return null; + const rows = await db + .select({ n: count() }) + .from(deals) + .where(eq(deals.stageId, stageId)); + return { affectedDealCount: rows[0]?.n ?? 0 }; +} + +export async function listStagesForPipeline( + db: Database, + organizationId: string, + pipelineId: string, +): Promise { + const ok = await assertPipelineInOrg(db, pipelineId, organizationId); + if (!ok) return []; + return db + .select() + .from(stages) + .where(eq(stages.pipelineId, pipelineId)) + .orderBy(asc(stages.order), asc(stages.id)); +} + +function isUniqueViolation(err: unknown): boolean { + if (typeof err !== "object" || err === null) return false; + const code = (err as { code?: unknown }).code; + return code === "23505"; +} + From d79bcf5d9930cfbe2d5a63ecf1562d865245d6f6 Mon Sep 17 00:00:00 2001 From: Timmyy3000 Date: Tue, 12 May 2026 11:53:40 +0100 Subject: [PATCH 3/9] feat(stages): UI server actions + extend ENTITY_TYPES with stage/pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lib/audit.ts: ENTITY_TYPES gains "stage" and "pipeline" so audit writes for these entities pass Zod validation in recordAudit. app/(app)/settings/pipelines/actions.ts: thin server-action shells over lib/stages. Each requires session, delegates to lib, audits the result, and revalidates /settings/pipelines + /deals + /companies (the latter is a layout-level revalidate so deal-stage views inside company tabs also refresh). - addStageAction: parse FormData, createStage(...), audit "create" - updateStageAction: patch semantics; only audits if a real change occurred (diffChangedFields) - reorderStageAction: boundary no-op skips audit + revalidate - deleteStageAction: writes one "update" audit row per migrated deal + one "delete" audit row on the stage. The migrated-deal audit rows give per-deal traceability ("why did my deal jump?") at the cost of N+1 audit rows for one user action — acceptable since deletes are rare and bulk migrations even rarer. - previewStageFlagToggleAction: cheap pre-flight returning the deal count for the UI confirm dialog. Errors from lib/stages bubble up as StageOpError and are mapped to { ok: false, code, message } discriminated-union returns so the UI can surface them inline without string-matching. Co-authored-by: Claude <81847+claude@users.noreply.github.com> --- app/(app)/settings/pipelines/actions.ts | 186 ++++++++++++++++++++++++ lib/audit.ts | 2 + 2 files changed, 188 insertions(+) create mode 100644 app/(app)/settings/pipelines/actions.ts diff --git a/app/(app)/settings/pipelines/actions.ts b/app/(app)/settings/pipelines/actions.ts new file mode 100644 index 0000000..503b23b --- /dev/null +++ b/app/(app)/settings/pipelines/actions.ts @@ -0,0 +1,186 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { db } from "@/db/client"; +import { diffChangedFields, recordAudit, userActor } from "@/lib/audit"; +import { requireOrgSession } from "@/lib/session"; +import { isStageColor, type StageColor } from "@/lib/stage-colors"; +import { + createStage, + deleteStage, + previewStageFlagToggle, + reorderStage, + StageOpError, + updateStage, + type ReorderDirection, +} from "@/lib/stages"; + +const NameSchema = z.string().trim().min(1).max(80); + +function normalizeColor(value: FormDataEntryValue | null): StageColor | null | undefined { + if (value === null) return undefined; + const s = String(value); + if (s === "") return null; + if (!isStageColor(s)) return undefined; + return s; +} + +function ok(data: T): { ok: true; data: T } { + return { ok: true, data }; +} +function err(code: StageOpError["code"], message: string): { ok: false; code: StageOpError["code"]; message: string } { + return { ok: false, code, message }; +} +function mapError(e: unknown): { ok: false; code: StageOpError["code"]; message: string } { + if (e instanceof StageOpError) return err(e.code, e.message); + throw e; +} + +function revalidateAll(): void { + revalidatePath("/settings/pipelines"); + revalidatePath("/deals"); + revalidatePath("/companies", "layout"); +} + +export async function addStageAction(formData: FormData) { + const session = await requireOrgSession(); + const pipelineId = z.string().uuid().parse(formData.get("pipelineId")); + const name = NameSchema.parse(formData.get("name")); + const color = normalizeColor(formData.get("color")); + const isWon = formData.get("isWon") === "true"; + const isLost = formData.get("isLost") === "true"; + + try { + const result = await createStage(db(), session.organizationId, { + pipelineId, + name, + color: color ?? null, + isWon, + isLost, + }); + await recordAudit(db(), { + organizationId: session.organizationId, + actor: userActor(session.user.id, session.user.name ?? null), + entityType: "stage", + entityId: result.after.id, + action: "create", + changes: { after: result.after }, + }); + revalidateAll(); + return ok(result.after); + } catch (e) { + return mapError(e); + } +} + +export async function updateStageAction(formData: FormData) { + const session = await requireOrgSession(); + const id = z.string().uuid().parse(formData.get("id")); + const nameRaw = formData.get("name"); + const colorRaw = formData.get("color"); + const isWonRaw = formData.get("isWon"); + const isLostRaw = formData.get("isLost"); + + try { + const result = await updateStage(db(), session.organizationId, { + id, + name: nameRaw === null ? undefined : NameSchema.parse(nameRaw), + color: colorRaw === null ? undefined : normalizeColor(colorRaw), + isWon: isWonRaw === null ? undefined : isWonRaw === "true", + isLost: isLostRaw === null ? undefined : isLostRaw === "true", + }); + if (!result) return err("not_found", "Stage not found"); + await recordAudit(db(), { + organizationId: session.organizationId, + actor: userActor(session.user.id, session.user.name ?? null), + entityType: "stage", + entityId: result.after.id, + action: "update", + changes: diffChangedFields(result.before, result.after), + }); + revalidateAll(); + return ok(result.after); + } catch (e) { + return mapError(e); + } +} + +const DirectionSchema = z.enum(["up", "down"]); + +export async function reorderStageAction(input: { + stageId: string; + direction: ReorderDirection; +}) { + const session = await requireOrgSession(); + const stageId = z.string().uuid().parse(input.stageId); + const direction = DirectionSchema.parse(input.direction); + + try { + const result = await reorderStage(db(), session.organizationId, stageId, direction); + if (!result) return err("not_found", "Stage not found"); + if (result.before.id === result.after.id && result.before.order === result.after.order) { + // boundary no-op; nothing to audit, nothing to revalidate + return ok(result.after); + } + await recordAudit(db(), { + organizationId: session.organizationId, + actor: userActor(session.user.id, session.user.name ?? null), + entityType: "stage", + entityId: result.after.id, + action: "update", + changes: { before: { order: result.before.order }, after: { order: result.after.order } }, + }); + revalidateAll(); + return ok(result.after); + } catch (e) { + return mapError(e); + } +} + +export async function deleteStageAction(input: { + stageId: string; + destinationStageId: string; +}) { + const session = await requireOrgSession(); + const stageId = z.string().uuid().parse(input.stageId); + const destinationStageId = z.string().uuid().parse(input.destinationStageId); + + try { + const result = await deleteStage(db(), session.organizationId, stageId, destinationStageId); + if (!result) return err("not_found", "Stage not found"); + + const actor = userActor(session.user.id, session.user.name ?? null); + for (const dealId of result.migratedDealIds) { + await recordAudit(db(), { + organizationId: session.organizationId, + actor, + entityType: "deal", + entityId: dealId, + action: "update", + changes: { before: { stageId }, after: { stageId: destinationStageId } }, + }); + } + await recordAudit(db(), { + organizationId: session.organizationId, + actor, + entityType: "stage", + entityId: stageId, + action: "delete", + changes: { before: result.before }, + }); + revalidateAll(); + return ok({ migratedDealCount: result.migratedDealIds.length }); + } catch (e) { + return mapError(e); + } +} + +export async function previewStageFlagToggleAction(input: { stageId: string }) { + const session = await requireOrgSession(); + const stageId = z.string().uuid().parse(input.stageId); + const result = await previewStageFlagToggle(db(), session.organizationId, stageId); + if (!result) return err("not_found", "Stage not found"); + return ok(result); +} + diff --git a/lib/audit.ts b/lib/audit.ts index 39abcdc..a44098e 100644 --- a/lib/audit.ts +++ b/lib/audit.ts @@ -14,6 +14,8 @@ export const ENTITY_TYPES = [ "signal", "research", "service_token", + "stage", + "pipeline", ] as const; export type EntityType = (typeof ENTITY_TYPES)[number]; From 8b1bda08c9abdd4329a1a603f128ed7753190cb0 Mon Sep 17 00:00:00 2001 From: Timmyy3000 Date: Tue, 12 May 2026 11:55:27 +0100 Subject: [PATCH 4/9] feat(mcp): stage CRUD tools (create, update, reorder, delete) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four MCP tools wrapping lib/stages, registered in server.ts: - create_stage(pipelineId, name, color?, isWon?, isLost?) — appends to the pipeline; no position arg by design (avoids tied-order bug in swap-reorder). - update_stage(id, name?, color?, isWon?, isLost?) — patch semantics; color is z.enum.nullable().optional() so callers can omit (leave as-is) or pass null (clear). - reorder_stage(id, direction "up"|"down") — boundary calls return { moved: false } and skip audit. - delete_stage(id, destinationStageId) — bulk-migrates deals then deletes; writes one audit row per migrated deal + one delete audit row on the stage so each deal's timeline shows the move and the workspace activity feed sees the stage removal. StageOpError from lib/stages is mapped to a structured { error: code, message } in jsonResult so Claude sees the failure reason without parsing free-form text. Co-authored-by: Claude <81847+claude@users.noreply.github.com> --- lib/mcp/server.ts | 2 + lib/mcp/tools/stages.ts | 182 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 lib/mcp/tools/stages.ts diff --git a/lib/mcp/server.ts b/lib/mcp/server.ts index 5f31208..a306e6e 100644 --- a/lib/mcp/server.ts +++ b/lib/mcp/server.ts @@ -7,6 +7,7 @@ import { registerNoteTools } from "./tools/notes"; import { registerPeopleTools } from "./tools/people"; import { registerResearchTools } from "./tools/research"; import { registerSignalTools } from "./tools/signals"; +import { registerStageTools } from "./tools/stages"; import { registerTaskTools } from "./tools/tasks"; import type { McpContext } from "./context"; @@ -24,6 +25,7 @@ export function createMcpServer(ctx: McpContext): McpServer { registerMeetingTools(server, ctx); registerDealTools(server, ctx); registerNoteTools(server, ctx); + registerStageTools(server, ctx); registerAuditTools(server, ctx); return server; diff --git a/lib/mcp/tools/stages.ts b/lib/mcp/tools/stages.ts new file mode 100644 index 0000000..118127e --- /dev/null +++ b/lib/mcp/tools/stages.ts @@ -0,0 +1,182 @@ +import { z } from "zod"; +import { diffChangedFields, recordAudit } from "@/lib/audit"; +import { STAGE_COLORS } from "@/lib/stage-colors"; +import { + createStage, + deleteStage, + reorderStage, + StageOpError, + updateStage, +} from "@/lib/stages"; +import type { McpContext } from "../context"; +import { jsonResult } from "../server"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +const ColorEnum = z.enum(STAGE_COLORS); + +function toolError(e: unknown): ReturnType | null { + if (e instanceof StageOpError) return jsonResult({ error: e.code, message: e.message }); + return null; +} + +export function registerStageTools(server: McpServer, ctx: McpContext): void { + server.registerTool( + "create_stage", + { + title: "Create a stage", + description: + "Add a new stage to the end of a pipeline. Reorder afterwards if a different position is wanted.", + inputSchema: { + pipelineId: z.string().uuid(), + name: z.string().min(1).max(80), + color: ColorEnum.optional(), + isWon: z.boolean().optional(), + isLost: z.boolean().optional(), + }, + }, + async (args) => { + try { + const result = await createStage(ctx.db, ctx.organizationId, { + pipelineId: args.pipelineId, + name: args.name, + color: args.color ?? null, + isWon: args.isWon, + isLost: args.isLost, + }); + await recordAudit(ctx.db, { + organizationId: ctx.organizationId, + actor: ctx.actor, + entityType: "stage", + entityId: result.after.id, + action: "create", + changes: { after: result.after }, + }); + return jsonResult({ stage: result.after }); + } catch (e) { + const handled = toolError(e); + if (handled) return handled; + throw e; + } + }, + ); + + server.registerTool( + "update_stage", + { + title: "Update a stage", + description: + "Patch a stage's fields. Pass `color: null` to clear the stage's colour. Omit a field to leave it unchanged.", + inputSchema: { + id: z.string().uuid(), + name: z.string().min(1).max(80).optional(), + color: ColorEnum.nullable().optional(), + isWon: z.boolean().optional(), + isLost: z.boolean().optional(), + }, + }, + async (args) => { + try { + const result = await updateStage(ctx.db, ctx.organizationId, { + id: args.id, + name: args.name, + color: args.color, + isWon: args.isWon, + isLost: args.isLost, + }); + if (!result) return jsonResult({ error: "not_found" }); + await recordAudit(ctx.db, { + organizationId: ctx.organizationId, + actor: ctx.actor, + entityType: "stage", + entityId: result.after.id, + action: "update", + changes: diffChangedFields(result.before, result.after), + }); + return jsonResult({ stage: result.after }); + } catch (e) { + const handled = toolError(e); + if (handled) return handled; + throw e; + } + }, + ); + + server.registerTool( + "reorder_stage", + { + title: "Move a stage up or down within its pipeline", + description: "Swap a stage's position with the adjacent stage in the same pipeline.", + inputSchema: { + id: z.string().uuid(), + direction: z.enum(["up", "down"]), + }, + }, + async ({ id, direction }) => { + try { + const result = await reorderStage(ctx.db, ctx.organizationId, id, direction); + if (!result) return jsonResult({ error: "not_found" }); + if (result.before.id === result.after.id && result.before.order === result.after.order) { + return jsonResult({ stage: result.after, moved: false }); + } + await recordAudit(ctx.db, { + organizationId: ctx.organizationId, + actor: ctx.actor, + entityType: "stage", + entityId: result.after.id, + action: "update", + changes: { + before: { order: result.before.order }, + after: { order: result.after.order }, + }, + }); + return jsonResult({ stage: result.after, moved: true }); + } catch (e) { + const handled = toolError(e); + if (handled) return handled; + throw e; + } + }, + ); + + server.registerTool( + "delete_stage", + { + title: "Delete a stage and migrate its deals", + description: + "Delete a stage. Any deals on the deleted stage are bulk-moved to `destinationStageId` (must be a different stage in the same pipeline). Refuses to delete the only stage in a pipeline.", + inputSchema: { + id: z.string().uuid(), + destinationStageId: z.string().uuid(), + }, + }, + async ({ id, destinationStageId }) => { + try { + const result = await deleteStage(ctx.db, ctx.organizationId, id, destinationStageId); + if (!result) return jsonResult({ error: "not_found" }); + for (const dealId of result.migratedDealIds) { + await recordAudit(ctx.db, { + organizationId: ctx.organizationId, + actor: ctx.actor, + entityType: "deal", + entityId: dealId, + action: "update", + changes: { before: { stageId: id }, after: { stageId: destinationStageId } }, + }); + } + await recordAudit(ctx.db, { + organizationId: ctx.organizationId, + actor: ctx.actor, + entityType: "stage", + entityId: id, + action: "delete", + changes: { before: result.before }, + }); + return jsonResult({ migrated_deal_count: result.migratedDealIds.length }); + } catch (e) { + const handled = toolError(e); + if (handled) return handled; + throw e; + } + }, + ); +} From 0573d1845b8422d62f27dbefab138537498032cd Mon Sep 17 00:00:00 2001 From: Timmyy3000 Date: Tue, 12 May 2026 12:01:12 +0100 Subject: [PATCH 5/9] feat(stages): interactive Settings UI for stage editing Settings page replaces the read-only stage list with an interactive editor. The page server-component fetches via getStages (now with color in the select) and renders per pipeline. Components: - stages-editor.tsx: list of + an inline "Add stage" form that toggles into focus, blurs to add. useTransition keeps the form responsive while the server action runs. - stage-row.tsx: per-stage row with up/down arrow reorder buttons (boundary disabled), a colour-swatch button that opens an inline popover of the 10 palette swatches + a clear option, click-to-edit inline rename (Enter saves, Escape cancels, blur saves), Won/Lost toggle buttons that fire previewStageFlagToggleAction first and surface a window.confirm() if any deals would be affected, and a hover-revealed delete button that opens the destination-picker dialog. - delete-stage-dialog.tsx: modal with deal count + sibling-stage dropdown for migration. Disables Delete when no destination exists. Toggling a flag explicitly sends the opposite flag as `false` when the opposite was previously true, to avoid the DB CHECK rejecting a transient both-true state during the patch. lib/data.ts: getStages select now includes the new `color` column so downstream consumers (companies overview, deals list, deals kanban) get the explicit colour for free. Co-authored-by: Claude <81847+claude@users.noreply.github.com> --- .../pipelines/delete-stage-dialog.tsx | 105 ++++++++ app/(app)/settings/pipelines/page.tsx | 74 +----- app/(app)/settings/pipelines/stage-row.tsx | 244 ++++++++++++++++++ .../settings/pipelines/stages-editor.tsx | 112 ++++++++ lib/data.ts | 1 + 5 files changed, 475 insertions(+), 61 deletions(-) create mode 100644 app/(app)/settings/pipelines/delete-stage-dialog.tsx create mode 100644 app/(app)/settings/pipelines/stage-row.tsx create mode 100644 app/(app)/settings/pipelines/stages-editor.tsx diff --git a/app/(app)/settings/pipelines/delete-stage-dialog.tsx b/app/(app)/settings/pipelines/delete-stage-dialog.tsx new file mode 100644 index 0000000..8ed1bda --- /dev/null +++ b/app/(app)/settings/pipelines/delete-stage-dialog.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { useEffect, useState, useTransition } from "react"; +import { deleteStageAction, previewStageFlagToggleAction } from "./actions"; +import type { StageEditorRow } from "./stages-editor"; + +export function DeleteStageDialog({ + stage, + siblings, + onClose, + onError, +}: { + stage: StageEditorRow; + siblings: StageEditorRow[]; + onClose: () => void; + onError: (msg: string) => void; +}) { + const others = siblings.filter((s) => s.id !== stage.id); + const [destinationId, setDestinationId] = useState(others[0]?.id ?? ""); + const [dealCount, setDealCount] = useState(null); + const [pending, startTransition] = useTransition(); + + useEffect(() => { + let cancelled = false; + void previewStageFlagToggleAction({ stageId: stage.id }).then((res) => { + if (cancelled) return; + if (res.ok) setDealCount(res.data.affectedDealCount); + }); + return () => { + cancelled = true; + }; + }, [stage.id]); + + function onConfirm() { + if (!destinationId) return; + startTransition(async () => { + const result = await deleteStageAction({ stageId: stage.id, destinationStageId: destinationId }); + if (!result.ok) { + onError(result.message); + return; + } + onClose(); + }); + } + + return ( +
+
+

+ Delete “{stage.name}” +

+

+ {dealCount === null + ? "Counting deals on this stage…" + : dealCount === 0 + ? "No deals are on this stage. Pick where future deals would land if they were." + : `${dealCount} deal${dealCount === 1 ? "" : "s"} will be moved to the chosen stage before this one is deleted.`} +

+ +
+ + +
+
+
+ ); +} diff --git a/app/(app)/settings/pipelines/page.tsx b/app/(app)/settings/pipelines/page.tsx index 4e013e5..ef1f4d4 100644 --- a/app/(app)/settings/pipelines/page.tsx +++ b/app/(app)/settings/pipelines/page.tsx @@ -4,6 +4,8 @@ import { pipelines } from "@/db/schema/deals"; import { Badge } from "@/components/pills"; import { getStages } from "@/lib/data"; import { requireOrgSession } from "@/lib/session"; +import { isStageColor, type StageColor } from "@/lib/stage-colors"; +import { StagesEditor, type StageEditorRow } from "./stages-editor"; export default async function PipelinesSettingsPage() { const session = await requireOrgSession(); @@ -16,22 +18,19 @@ export default async function PipelinesSettingsPage() { const out = await Promise.all( ps.map(async (p) => { const sgs = await getStages(p.id, orgId); - return { - id: p.id, - name: p.name, - isDefault: p.isDefault, - stages: sgs.map((s) => ({ - id: s.id, - name: s.name, - isWon: s.isWon, - isLost: s.isLost, - })), - }; + const rows: StageEditorRow[] = sgs.map((s) => ({ + id: s.id, + name: s.name, + color: isStageColor(s.color) ? (s.color as StageColor) : null, + isWon: s.isWon, + isLost: s.isLost, + })); + return { id: p.id, name: p.name, isDefault: p.isDefault, rows }; }), ); return ( -
+

{out.map((p) => ( -
+
{p.name} {p.isDefault ? Default : null}
-
    - {p.stages.map((s, i) => ( -
  • - - - - {s.name} - {s.isWon ? ( - - is_won - - ) : null} - {s.isLost ? ( - - is_lost - - ) : null} -
  • - ))} -
+
))} -

- Reorder, add, and rename stages lands in Phase 4. -

); } diff --git a/app/(app)/settings/pipelines/stage-row.tsx b/app/(app)/settings/pipelines/stage-row.tsx new file mode 100644 index 0000000..321e26d --- /dev/null +++ b/app/(app)/settings/pipelines/stage-row.tsx @@ -0,0 +1,244 @@ +"use client"; + +import { useState, useTransition, type KeyboardEvent } from "react"; +import { ChevronDown, ChevronUp, Trash2 } from "lucide-react"; +import { StagePill } from "@/components/pills"; +import { + STAGE_COLORS, + STAGE_COLOR_TOKENS, + type StageColor, +} from "@/lib/stage-colors"; +import { previewStageFlagToggleAction, reorderStageAction, updateStageAction } from "./actions"; +import { DeleteStageDialog } from "./delete-stage-dialog"; +import type { StageEditorRow } from "./stages-editor"; + +export function StageRow({ + stage, + isFirst, + isLast, + siblings, +}: { + stage: StageEditorRow; + isFirst: boolean; + isLast: boolean; + siblings: StageEditorRow[]; +}) { + const [editing, setEditing] = useState(false); + const [name, setName] = useState(stage.name); + const [colorPickerOpen, setColorPickerOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + const [error, setError] = useState(null); + const [pending, startTransition] = useTransition(); + + function commitName() { + const trimmed = name.trim(); + if (!trimmed || trimmed === stage.name) { + setName(stage.name); + setEditing(false); + return; + } + startTransition(async () => { + const fd = new FormData(); + fd.set("id", stage.id); + fd.set("name", trimmed); + const result = await updateStageAction(fd); + if (!result.ok) { + setError(result.message); + setName(stage.name); + } + setEditing(false); + }); + } + + function pickColor(next: StageColor | null) { + setColorPickerOpen(false); + setError(null); + startTransition(async () => { + const fd = new FormData(); + fd.set("id", stage.id); + fd.set("color", next ?? ""); + const result = await updateStageAction(fd); + if (!result.ok) setError(result.message); + }); + } + + function setFlag(flag: "isWon" | "isLost", value: boolean) { + setError(null); + startTransition(async () => { + if (value) { + const preview = await previewStageFlagToggleAction({ stageId: stage.id }); + if (preview.ok && preview.data.affectedDealCount > 0) { + const proceed = window.confirm( + `${preview.data.affectedDealCount} deal${ + preview.data.affectedDealCount === 1 ? "" : "s" + } currently in this stage will be re-classified. Continue?`, + ); + if (!proceed) return; + } + } + const fd = new FormData(); + fd.set("id", stage.id); + fd.set(flag, value ? "true" : "false"); + // Sending the other flag explicitly off when turning one on prevents the DB CHECK + // from rejecting a transient both-true state. + if (value) { + const other = flag === "isWon" ? "isLost" : "isWon"; + if ((other === "isWon" && stage.isWon) || (other === "isLost" && stage.isLost)) { + fd.set(other, "false"); + } + } + const result = await updateStageAction(fd); + if (!result.ok) setError(result.message); + }); + } + + function reorder(direction: "up" | "down") { + setError(null); + startTransition(async () => { + const result = await reorderStageAction({ stageId: stage.id, direction }); + if (!result.ok) setError(result.message); + }); + } + + function onNameKey(e: KeyboardEvent) { + if (e.key === "Enter") { + e.preventDefault(); + commitName(); + } else if (e.key === "Escape") { + e.preventDefault(); + setName(stage.name); + setEditing(false); + } + } + + return ( +
  • +
    + + +
    + +
    + +
    + ) : null} +
  • + + {editing ? ( + setName(e.target.value)} + onBlur={commitName} + onKeyDown={onNameKey} + className="flex-1 rounded border border-border bg-background px-1.5 py-0.5 text-[13px] text-text outline-none focus:border-accent" + disabled={pending} + /> + ) : ( + + )} + + + + + + + {deleteOpen ? ( + setDeleteOpen(false)} + onError={(msg) => setError(msg)} + /> + ) : null} + + {error ? ( + + {error} + + ) : null} + + ); +} diff --git a/app/(app)/settings/pipelines/stages-editor.tsx b/app/(app)/settings/pipelines/stages-editor.tsx new file mode 100644 index 0000000..7921f35 --- /dev/null +++ b/app/(app)/settings/pipelines/stages-editor.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { useState, useTransition, type FormEvent } from "react"; +import { Plus } from "lucide-react"; +import type { StageColor } from "@/lib/stage-colors"; +import { addStageAction } from "./actions"; +import { StageRow } from "./stage-row"; + +export type StageEditorRow = { + id: string; + name: string; + color: StageColor | null; + isWon: boolean; + isLost: boolean; +}; + +export function StagesEditor({ + pipelineId, + initialStages, +}: { + pipelineId: string; + initialStages: StageEditorRow[]; +}) { + const [adding, setAdding] = useState(false); + const [newName, setNewName] = useState(""); + const [error, setError] = useState(null); + const [pending, startTransition] = useTransition(); + + function onAdd(e: FormEvent) { + e.preventDefault(); + setError(null); + const trimmed = newName.trim(); + if (!trimmed) return; + startTransition(async () => { + const fd = new FormData(); + fd.set("pipelineId", pipelineId); + fd.set("name", trimmed); + const result = await addStageAction(fd); + if (!result.ok) { + setError(result.message); + return; + } + setNewName(""); + setAdding(false); + }); + } + + return ( +
    +
      + {initialStages.map((s, i) => ( + + ))} +
    + + {adding ? ( +
    + setNewName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Escape") { + setNewName(""); + setAdding(false); + setError(null); + } + }} + placeholder="Stage name" + className="flex-1 rounded-md border border-border bg-background px-2 py-1 text-[13px] text-text outline-none focus:border-accent" + disabled={pending} + /> + + +
    + ) : ( + + )} + + {error ?

    {error}

    : null} +
    + ); +} diff --git a/lib/data.ts b/lib/data.ts index c7f7a4a..18e3725 100644 --- a/lib/data.ts +++ b/lib/data.ts @@ -65,6 +65,7 @@ export const getStages = cache(async (pipelineId: string, organizationId: string pipelineId: stages.pipelineId, name: stages.name, order: stages.order, + color: stages.color, isWon: stages.isWon, isLost: stages.isLost, createdAt: stages.createdAt, From 61b8336488f31201b7a1dc94e37117f635058c99 Mon Sep 17 00:00:00 2001 From: Timmyy3000 Date: Tue, 12 May 2026 14:02:46 +0100 Subject: [PATCH 6/9] test(stages): unit tests for lib/stages + MCP wrapper smoke + audit assertions tests/stages.test.ts: 14 tests against lib/stages directly. Covers assertStageInOrg cross-org isolation, createStage append-at-end + duplicate-name + won/lost conflict, updateStage patch semantics (including color: null clears), DB CHECK on both-flags, reorderStage up/down/boundary, deleteStage migration + cross-pipeline + same-stage + last-stage refusals, previewStageFlagToggle counts, and updateStage returning null for cross-org. tests/mcp-tools.test.ts: two stage-tool integration tests: - create_stage / update_stage / reorder_stage round-trip with audit row + color round-trip + boundary moved=false - delete_stage migrates a deal and writes per-deal audit rows + a "delete" audit row on the stage Bug fix in lib/stages.ts: postgres-js wraps unique-constraint errors with the postgres `code: "23505"` on `err.cause`, not on `err`. isUniqueViolation now checks both levels so the duplicate-name path returns a StageOpError(name_in_use) instead of leaking the raw postgres error. Test-style note: bun:test's `expect(...).rejects.toBeInstanceOf(...)` hangs with custom Error subclasses; switched to manual try/catch + toBeInstanceOf to avoid the framework quirk. 76/76 tests pass. Lint, typecheck, build all clean. Co-authored-by: Claude <81847+claude@users.noreply.github.com> --- lib/stages.ts | 8 +- tests/mcp-tools.test.ts | 95 ++++++++++++++++ tests/stages.test.ts | 232 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 333 insertions(+), 2 deletions(-) create mode 100644 tests/stages.test.ts diff --git a/lib/stages.ts b/lib/stages.ts index 87a025e..5457fe3 100644 --- a/lib/stages.ts +++ b/lib/stages.ts @@ -265,7 +265,11 @@ export async function listStagesForPipeline( function isUniqueViolation(err: unknown): boolean { if (typeof err !== "object" || err === null) return false; - const code = (err as { code?: unknown }).code; - return code === "23505"; + const top = err as { code?: unknown; cause?: unknown }; + if (top.code === "23505") return true; + if (typeof top.cause === "object" && top.cause !== null) { + return (top.cause as { code?: unknown }).code === "23505"; + } + return false; } diff --git a/tests/mcp-tools.test.ts b/tests/mcp-tools.test.ts index 8a40886..9568584 100644 --- a/tests/mcp-tools.test.ts +++ b/tests/mcp-tools.test.ts @@ -478,6 +478,101 @@ describe("MCP tools", () => { expect(empty.activity).toEqual([]); }); + test("create_stage / update_stage / reorder_stage round-trip with audit rows", async () => { + const orgId = await seedOrgWithPipeline(); + const { client, close: c } = await makeClient(orgId); + close = c; + const ps = await db + .select({ id: pipelines.id }) + .from(pipelines) + .where(eq(pipelines.organizationId, orgId)) + .limit(1); + const pipelineId = ps[0]!.id; + + const created = structured<{ stage: { id: string; order: number; color: string | null } }>( + await client.callTool({ + name: "create_stage", + arguments: { pipelineId, name: "Discovery+", color: "indigo" }, + }), + ); + expect(created.stage.color).toBe("indigo"); + expect(created.stage.order).toBe(2); // appended after the 2 seeded stages + + const createAudit = await db + .select() + .from(auditLog) + .where(eq(auditLog.entityId, created.stage.id)); + expect(createAudit.length).toBe(1); + expect(createAudit[0]!.entityType).toBe("stage"); + expect(createAudit[0]!.action).toBe("create"); + + const updated = structured<{ stage: { color: string | null } }>( + await client.callTool({ + name: "update_stage", + arguments: { id: created.stage.id, color: null }, + }), + ); + expect(updated.stage.color).toBeNull(); + + const reordered = structured<{ moved: boolean; stage: { order: number } }>( + await client.callTool({ + name: "reorder_stage", + arguments: { id: created.stage.id, direction: "up" }, + }), + ); + expect(reordered.moved).toBe(true); + expect(reordered.stage.order).toBe(1); + }); + + test("delete_stage migrates deals and audits each one", async () => { + const orgId = await seedOrgWithPipeline(); + const { client, close: c } = await makeClient(orgId); + close = c; + const allStages = await db.select().from(stages).orderBy(stages.order); + const sourceStage = allStages[0]!; + const destStage = allStages[1]!; + + const company = structured<{ company: { id: string } }>( + await client.callTool({ + name: "create_company", + arguments: { name: "Migrate Co" }, + }), + ); + const deal = structured<{ deal: { id: string } }>( + await client.callTool({ + name: "create_deal", + arguments: { companyId: company.company.id, name: "D1", stageId: sourceStage.id }, + }), + ); + + const result = structured<{ migrated_deal_count: number }>( + await client.callTool({ + name: "delete_stage", + arguments: { id: sourceStage.id, destinationStageId: destStage.id }, + }), + ); + expect(result.migrated_deal_count).toBe(1); + + const remaining = await db.select().from(stages).where(eq(stages.id, sourceStage.id)); + expect(remaining.length).toBe(0); + + const dealAudits = await db + .select() + .from(auditLog) + .where(eq(auditLog.entityId, deal.deal.id)); + const stageMigrationRow = dealAudits.find((r) => { + const c = r.changes as { before?: { stageId?: string }; after?: { stageId?: string } } | null; + return c?.before?.stageId === sourceStage.id && c?.after?.stageId === destStage.id; + }); + expect(stageMigrationRow).toBeDefined(); + + const stageAudits = await db + .select() + .from(auditLog) + .where(eq(auditLog.entityId, sourceStage.id)); + expect(stageAudits.find((r) => r.action === "delete")).toBeDefined(); + }); + test("cross-org access is rejected (org-scoped queries)", async () => { const orgA = await seedOrgWithPipeline("org_a"); const orgB = await seedOrgWithPipeline("org_b"); diff --git a/tests/stages.test.ts b/tests/stages.test.ts new file mode 100644 index 0000000..d5f8c46 --- /dev/null +++ b/tests/stages.test.ts @@ -0,0 +1,232 @@ +import { eq } from "drizzle-orm"; +import { beforeEach, describe, expect, test } from "bun:test"; +import { createDb } from "@/db/client"; +import { organization } from "@/db/schema/auth"; +import { companies } from "@/db/schema/companies"; +import { deals, pipelines, stages } from "@/db/schema/deals"; +import { + assertStageInOrg, + createStage, + deleteStage, + listStagesForPipeline, + previewStageFlagToggle, + reorderStage, + StageOpError, + updateStage, +} from "@/lib/stages"; +import { resetDb } from "./setup"; + +const db = createDb(process.env.TEST_DATABASE_URL!); + +async function seedOrgWithPipeline(orgId = "org_stages"): Promise<{ + orgId: string; + pipelineId: string; + stageIds: string[]; +}> { + await db.insert(organization).values({ id: orgId, name: "Stages", slug: orgId }); + const pipelineId = crypto.randomUUID(); + await db.insert(pipelines).values({ id: pipelineId, organizationId: orgId, name: "Sales", isDefault: true }); + const ids = [crypto.randomUUID(), crypto.randomUUID(), crypto.randomUUID()]; + await db.insert(stages).values([ + { id: ids[0], pipelineId, name: "Lead", order: 0 }, + { id: ids[1], pipelineId, name: "Discovery", order: 1 }, + { id: ids[2], pipelineId, name: "Won", order: 2, isWon: true }, + ]); + return { orgId, pipelineId, stageIds: ids }; +} + +async function seedCompany(orgId: string): Promise { + const id = crypto.randomUUID(); + await db.insert(companies).values({ id, organizationId: orgId, name: "Acme" }); + return id; +} + +async function seedDeal(orgId: string, pipelineId: string, stageId: string): Promise { + const companyId = await seedCompany(orgId); + const id = crypto.randomUUID(); + await db.insert(deals).values({ + id, + organizationId: orgId, + companyId, + pipelineId, + stageId, + name: "Acme deal", + }); + return id; +} + +describe("lib/stages", () => { + beforeEach(async () => { + await resetDb(); + }); + + test("assertStageInOrg returns null for cross-org stages", async () => { + const a = await seedOrgWithPipeline("org_a"); + const b = await seedOrgWithPipeline("org_b"); + expect(await assertStageInOrg(db, a.stageIds[0]!, b.orgId)).toBeNull(); + expect(await assertStageInOrg(db, a.stageIds[0]!, a.orgId)).not.toBeNull(); + }); + + test("createStage always inserts at end (max(order)+1)", async () => { + const { orgId, pipelineId } = await seedOrgWithPipeline(); + const result = await createStage(db, orgId, { pipelineId, name: "Discovery+" }); + expect(result.after.order).toBe(3); + }); + + test("createStage rejects both isWon and isLost", async () => { + const { orgId, pipelineId } = await seedOrgWithPipeline(); + let caught: unknown; + try { + await createStage(db, orgId, { pipelineId, name: "Bad", isWon: true, isLost: true }); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(StageOpError); + expect((caught as StageOpError).code).toBe("won_lost_conflict"); + }); + + test("createStage rejects duplicate name within a pipeline", async () => { + const { orgId, pipelineId } = await seedOrgWithPipeline(); + let caught: unknown; + try { + await createStage(db, orgId, { pipelineId, name: "Lead" }); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(StageOpError); + expect((caught as StageOpError).code).toBe("name_in_use"); + }); + + test("updateStage patch semantics; color: null clears", async () => { + const { orgId, stageIds } = await seedOrgWithPipeline(); + const set = await updateStage(db, orgId, { id: stageIds[0]!, color: "blue" }); + expect(set?.after.color).toBe("blue"); + + const cleared = await updateStage(db, orgId, { id: stageIds[0]!, color: null }); + expect(cleared?.after.color).toBeNull(); + expect(cleared?.after.name).toBe("Lead"); // untouched + + const renamed = await updateStage(db, orgId, { id: stageIds[0]!, name: "Brand New" }); + expect(renamed?.after.name).toBe("Brand New"); + expect(renamed?.after.color).toBeNull(); // untouched + }); + + test("updateStage rejects setting both isWon and isLost true", async () => { + const { orgId, stageIds } = await seedOrgWithPipeline(); + let caught: unknown; + try { + await updateStage(db, orgId, { id: stageIds[1]!, isWon: true, isLost: true }); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(StageOpError); + expect((caught as StageOpError).code).toBe("won_lost_conflict"); + }); + + test("DB CHECK constraint blocks setting both isWon and isLost via direct write", async () => { + const { pipelineId } = await seedOrgWithPipeline(); + let caught: unknown; + try { + await db + .insert(stages) + .values({ pipelineId, name: "BothBad", order: 99, isWon: true, isLost: true }); + } catch (e) { + caught = e; + } + expect(caught).toBeDefined(); + }); + + test("reorderStage moves down then up; boundary calls return unchanged", async () => { + const { orgId, pipelineId, stageIds } = await seedOrgWithPipeline(); + const movedDown = await reorderStage(db, orgId, stageIds[0]!, "down"); + expect(movedDown?.after.order).toBe(1); + expect(movedDown?.partner.order).toBe(0); + + const movedUp = await reorderStage(db, orgId, stageIds[0]!, "up"); + expect(movedUp?.after.order).toBe(0); + + const sortedAfter = await listStagesForPipeline(db, orgId, pipelineId); + expect(sortedAfter.map((s) => s.id)).toEqual(stageIds); + + // Boundary: top moving up is a no-op. + const noop = await reorderStage(db, orgId, stageIds[0]!, "up"); + expect(noop?.before.order).toBe(noop?.after.order); + }); + + test("deleteStage migrates deals atomically then deletes the stage", async () => { + const { orgId, pipelineId, stageIds } = await seedOrgWithPipeline(); + const dealId = await seedDeal(orgId, pipelineId, stageIds[0]!); + + const result = await deleteStage(db, orgId, stageIds[0]!, stageIds[1]!); + expect(result?.migratedDealIds).toEqual([dealId]); + + const remaining = await db.select().from(stages).where(eq(stages.id, stageIds[0]!)); + expect(remaining.length).toBe(0); + + const moved = await db.select({ stageId: deals.stageId }).from(deals).where(eq(deals.id, dealId)); + expect(moved[0]?.stageId).toBe(stageIds[1]!); + }); + + test("deleteStage refuses self as destination", async () => { + const { orgId, stageIds } = await seedOrgWithPipeline(); + let caught: unknown; + try { + await deleteStage(db, orgId, stageIds[0]!, stageIds[0]!); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(StageOpError); + }); + + test("deleteStage refuses cross-pipeline destination", async () => { + const { orgId, stageIds } = await seedOrgWithPipeline(); + const pipeline2 = crypto.randomUUID(); + await db.insert(pipelines).values({ id: pipeline2, organizationId: orgId, name: "Other" }); + const otherStageId = crypto.randomUUID(); + await db.insert(stages).values({ id: otherStageId, pipelineId: pipeline2, name: "X", order: 0 }); + + let caught: unknown; + try { + await deleteStage(db, orgId, stageIds[0]!, otherStageId); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(StageOpError); + expect((caught as StageOpError).code).toBe("wrong_pipeline"); + }); + + test("deleteStage refuses the only stage in a pipeline", async () => { + const { orgId } = await seedOrgWithPipeline(); + const lonely = crypto.randomUUID(); + const onlyStage = crypto.randomUUID(); + await db.insert(pipelines).values({ id: lonely, organizationId: orgId, name: "Lonely" }); + await db.insert(stages).values({ id: onlyStage, pipelineId: lonely, name: "Only", order: 0 }); + // dest must be different from src to pass the first guard, then last_stage fires. + const otherStageId = crypto.randomUUID(); + await db.insert(stages).values({ id: otherStageId, pipelineId: lonely, name: "OnlyDest", order: 1 }); + // Now delete one and try to delete the remaining lone one. + await deleteStage(db, orgId, onlyStage, otherStageId); + let caught: unknown; + try { + await deleteStage(db, orgId, otherStageId, otherStageId); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(StageOpError); + }); + + test("previewStageFlagToggle counts deals on the stage", async () => { + const { orgId, pipelineId, stageIds } = await seedOrgWithPipeline(); + expect((await previewStageFlagToggle(db, orgId, stageIds[0]!))?.affectedDealCount).toBe(0); + await seedDeal(orgId, pipelineId, stageIds[0]!); + await seedDeal(orgId, pipelineId, stageIds[0]!); + expect((await previewStageFlagToggle(db, orgId, stageIds[0]!))?.affectedDealCount).toBe(2); + }); + + test("cross-org isolation: updateStage on another org's stage returns null", async () => { + const a = await seedOrgWithPipeline("org_iso_a"); + const b = await seedOrgWithPipeline("org_iso_b"); + const result = await updateStage(db, b.orgId, { id: a.stageIds[0]!, name: "hijack" }); + expect(result).toBeNull(); + }); +}); From a4dc681568ddd03ef7970689bc676d0c48752bbf Mon Sep 17 00:00:00 2001 From: Timmyy3000 Date: Tue, 12 May 2026 14:07:43 +0100 Subject: [PATCH 7/9] feat(stages): propagate stage.color to all StagePill consumers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1 from local code review: StagePill accepts an optional color prop, but only the settings page passes it. Result: a user picks a colour in /settings/pipelines, the swatch updates there but every other StagePill (deals list, deals kanban column header, company-detail overview, company-detail deals tab, cockpit) keeps falling back to the legacy name-keyed style — colour selection has no visible effect where it matters. Add stages.color to the SELECT in five consumers and pass it to StagePill: - app/(app)/deals/page.tsx (server) — pipeline stage rows include color - app/(app)/deals/deals-view.tsx — KanbanStage type + per-deal stageColor - app/(app)/deals/kanban.tsx — column header now renders with color (was a plain text span) - app/(app)/companies/[id]/page.tsx — active-deals select - app/(app)/companies/[id]/deals/page.tsx — deals tab table - app/(app)/page.tsx — cockpit at-risk deals Co-authored-by: Claude <81847+claude@users.noreply.github.com> --- app/(app)/companies/[id]/deals/page.tsx | 3 ++- app/(app)/companies/[id]/page.tsx | 3 ++- app/(app)/deals/deals-view.tsx | 5 +++-- app/(app)/deals/kanban.tsx | 5 +++-- app/(app)/deals/page.tsx | 1 + app/(app)/page.tsx | 3 ++- 6 files changed, 13 insertions(+), 7 deletions(-) diff --git a/app/(app)/companies/[id]/deals/page.tsx b/app/(app)/companies/[id]/deals/page.tsx index 1196738..a75cb15 100644 --- a/app/(app)/companies/[id]/deals/page.tsx +++ b/app/(app)/companies/[id]/deals/page.tsx @@ -12,6 +12,7 @@ export default async function DealsTab({ params }: { params: Promise<{ id: strin name: deals.name, value: deals.value, stageName: stages.name, + stageColor: stages.color, stageEnteredAt: deals.stageEnteredAt, expectedCloseDate: deals.expectedCloseDate, }) @@ -50,7 +51,7 @@ export default async function DealsTab({ params }: { params: Promise<{ id: strin > {d.name} - + {money(d.value)} diff --git a/app/(app)/companies/[id]/page.tsx b/app/(app)/companies/[id]/page.tsx index d160485..b9b97b7 100644 --- a/app/(app)/companies/[id]/page.tsx +++ b/app/(app)/companies/[id]/page.tsx @@ -44,6 +44,7 @@ export default async function OverviewTab({ params }: { params: Promise<{ id: st name: deals.name, value: deals.value, stageName: stages.name, + stageColor: stages.color, }) .from(deals) .innerJoin(stages, eq(deals.stageId, stages.id)) @@ -169,7 +170,7 @@ export default async function OverviewTab({ params }: { params: Promise<{ id: st
    {d.name}
    - +
    diff --git a/app/(app)/deals/deals-view.tsx b/app/(app)/deals/deals-view.tsx index 441ef3f..2d2b700 100644 --- a/app/(app)/deals/deals-view.tsx +++ b/app/(app)/deals/deals-view.tsx @@ -45,7 +45,7 @@ export function DealsView({ }: { pipelineName: string; view: "list" | "board"; - stages: (KanbanStage & { isWon: boolean; isLost: boolean })[]; + stages: (KanbanStage & { color: string | null; isWon: boolean; isLost: boolean })[]; deals: DealRow[]; truncatedAt: number | null; }) { @@ -63,6 +63,7 @@ export function DealsView({ return { ...d, stageName: stage?.name ?? "Unknown", + stageColor: stage?.color ?? null, isWon: stage?.isWon ?? false, isLost: stage?.isLost ?? false, daysInStage: days, @@ -226,7 +227,7 @@ export function DealsView({ {money(d.value)} - + {FORECAST[d.stageName] ?? "Pipeline"} diff --git a/app/(app)/deals/kanban.tsx b/app/(app)/deals/kanban.tsx index 03f7864..e2dc87f 100644 --- a/app/(app)/deals/kanban.tsx +++ b/app/(app)/deals/kanban.tsx @@ -3,7 +3,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { useState, type DragEvent } from "react"; -import { TemperaturePill } from "@/components/pills"; +import { StagePill, TemperaturePill } from "@/components/pills"; import { money, relativeTime } from "@/lib/format"; import { cn } from "@/lib/utils"; import { moveDealStageAction } from "./actions"; @@ -22,6 +22,7 @@ export type KanbanDeal = { export type KanbanStage = { id: string; name: string; + color?: string | null; }; export function KanbanBoard({ @@ -89,7 +90,7 @@ export function KanbanBoard({ onDrop={(e) => onDrop(e, stage.id)} >
    - {stage.name} + {list.length} · {money(total)} diff --git a/app/(app)/deals/page.tsx b/app/(app)/deals/page.tsx index fbd3eb9..757adf7 100644 --- a/app/(app)/deals/page.tsx +++ b/app/(app)/deals/page.tsx @@ -56,6 +56,7 @@ export default async function DealsPage({ stages={stageRows.map((s) => ({ id: s.id, name: s.name, + color: s.color, isWon: s.isWon, isLost: s.isLost, }))} diff --git a/app/(app)/page.tsx b/app/(app)/page.tsx index 018d5ad..502d9b9 100644 --- a/app/(app)/page.tsx +++ b/app/(app)/page.tsx @@ -52,6 +52,7 @@ export default async function CockpitPage() { value: deals.value, stageEnteredAt: deals.stageEnteredAt, stageName: stages.name, + stageColor: stages.color, companyId: deals.companyId, companyName: companies.name, temperature: companies.temperature, @@ -219,7 +220,7 @@ export default async function CockpitPage() { {money(d.value)} - + Date: Tue, 12 May 2026 14:24:44 +0100 Subject: [PATCH 8/9] fix(stages): three review findings from enkii MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P1: KanbanBoard never received stage colours because DealsView's stages mapping dropped the color field. Include it in the map so the kanban column header recolours. - P1: StageRow's local `name` state was initialized once from the prop and never re-synced. After a stage is renamed externally (revalidatePath, another tab, MCP, etc.) the input still held the old value; blurring would silently overwrite the new name with the old one. Fix: introduce startEdit() that explicitly resyncs from the prop every time edit mode opens. - P2: StagePill's name-keyed fallback was comparing cfg.label === key (string vs underscore-lowercased key — never equal). Every custom- named stage without an explicit color rendered as "Prospecting". Fix: check whether the lookup actually matched and render the matched label when it did, otherwise render the raw value. 76/76 tests pass; lint, typecheck clean. Co-authored-by: Claude <81847+claude@users.noreply.github.com> --- app/(app)/deals/deals-view.tsx | 2 +- app/(app)/settings/pipelines/stage-row.tsx | 7 ++++++- components/pills.tsx | 7 ++++--- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/app/(app)/deals/deals-view.tsx b/app/(app)/deals/deals-view.tsx index 2d2b700..05499a4 100644 --- a/app/(app)/deals/deals-view.tsx +++ b/app/(app)/deals/deals-view.tsx @@ -257,7 +257,7 @@ export function DealsView({ ) : (
    ({ id: s.id, name: s.name }))} + stages={stages.map((s) => ({ id: s.id, name: s.name, color: s.color }))} initialDeals={deals} />
    diff --git a/app/(app)/settings/pipelines/stage-row.tsx b/app/(app)/settings/pipelines/stage-row.tsx index 321e26d..90774b6 100644 --- a/app/(app)/settings/pipelines/stage-row.tsx +++ b/app/(app)/settings/pipelines/stage-row.tsx @@ -26,6 +26,11 @@ export function StageRow({ const [editing, setEditing] = useState(false); const [name, setName] = useState(stage.name); const [colorPickerOpen, setColorPickerOpen] = useState(false); + + function startEdit() { + setName(stage.name); + setEditing(true); + } const [deleteOpen, setDeleteOpen] = useState(false); const [error, setError] = useState(null); const [pending, startTransition] = useTransition(); @@ -183,7 +188,7 @@ export function StageRow({ ) : (