7a6852e33a
Motion was written against a base five commits behind main, so the integration is the interesting part of this commit: - The migration is renumbered 0014 -> 0015. Main shipped 0014_piggy_conversations, and two migrations sharing an index is a journal that applies one of them. - The seed-idempotency gate keeps main's all-tables diff rather than the motion_templates counter this branch added; the general check subsumes the specific one. - Nav gains a Motion group alongside main's new Workspace group, and Piggy keeps the mark main gave it. - Stat keeps main's container-scaled figure, which already carries the min-w-0 this branch added for the same reason. - Piggy's page labels keep main's refusal wording for the four pages with no tool of their own, and gain the three Motion routes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
295 lines
13 KiB
TypeScript
295 lines
13 KiB
TypeScript
/**
|
|
* Motion — the library, the engagements, and the loop between them.
|
|
*
|
|
* Four tables, and three of the decisions in them are load-bearing.
|
|
*
|
|
* **A used template is never edited in place.** `usage_count` is incremented
|
|
* when a template is instantiated into an engagement, and the API refuses a
|
|
* `PATCH` once it is above zero — a new version row is written instead,
|
|
* carrying `supersedes_id` back to its predecessor and `origin_artifact_id`
|
|
* forward to the artifact that proved it. A live engagement whose template
|
|
* changed underneath it has lost its provenance, and provenance is the only
|
|
* thing that makes "this came from playbook v3" mean anything a quarter later.
|
|
* That is also why `(slug, version)` is unique: the seed writes
|
|
* `onConflictDoNothing({ target: [slug, version] })`, which is a silent no-op
|
|
* without a constraint to conflict on, and that has already duplicated seed
|
|
* data twice in this codebase.
|
|
*
|
|
* **`visibility = 'private'` is a real access rule, and it is this codebase's
|
|
* first row-level filter.** `permissions.ts` says plainly that every read
|
|
* endpoint returns the whole book, because no row-level team filter exists
|
|
* anywhere in the query layer. Motion is a deliberate exception, stated here
|
|
* so it cannot be discovered by surprise: a draft proposal for a live deal is
|
|
* not the same object as a contract, and a library nobody can draft in
|
|
* privately becomes a library nobody drafts in. `shared` is book-wide on
|
|
* `book:read` exactly like everything else; `private` is readable and writable
|
|
* by `owner_user_id` and by a platform admin, and by nobody else.
|
|
*
|
|
* The CHECK below refuses a private row with no owner, because such a row is
|
|
* readable by nobody and writable by nobody — a leak the day someone "fixes"
|
|
* the query that appears to be dropping rows. It also, measured by running the
|
|
* delete rather than by reading the DDL, makes `ON DELETE SET NULL` on
|
|
* `owner_user_id` unreachable for a private row: Postgres evaluates the CHECK
|
|
* on the UPDATE the referential action performs, so deleting a user who owns
|
|
* one raises `motion_templates_private_has_owner_check` and the delete fails.
|
|
* Private authorship therefore blocks a user delete today, and whoever adds a
|
|
* member-removal path has to archive or reassign those rows first. The read
|
|
* query still treats a private row with a null owner as invisible to everyone
|
|
* but a platform admin, so that relaxing the CHECK cannot quietly publish
|
|
* them — do not write that filter as `owner_user_id = $me OR owner_user_id IS
|
|
* NULL`.
|
|
*
|
|
* **`qualification_scores` is append-only.** Never updated, never deleted. The
|
|
* movement of a score across an engagement is the evidence that qualification
|
|
* happened at all; a mutable current score is a number somebody can make true
|
|
* afterwards. Everything else archives rather than deletes, as elsewhere in
|
|
* PIG.
|
|
*
|
|
* The FK cycle between `motion_templates.origin_artifact_id` and
|
|
* `engagement_artifacts.template_id` is intentional — it is the loop — and it
|
|
* is why migration `0015_motion.sql` is hand-written, adding one of the two
|
|
* constraints in a separate `ALTER TABLE` after both tables exist.
|
|
*/
|
|
import { sql } from 'drizzle-orm';
|
|
import {
|
|
boolean,
|
|
check,
|
|
index,
|
|
integer,
|
|
jsonb,
|
|
pgTable,
|
|
text,
|
|
timestamp,
|
|
unique,
|
|
uuid,
|
|
type AnyPgColumn,
|
|
} from 'drizzle-orm/pg-core';
|
|
import {
|
|
ARTIFACT_STATUSES,
|
|
DEMAND_STAGES,
|
|
ENGAGEMENT_STATUSES,
|
|
MOTION_BASIS_POINTS_MAX,
|
|
MOTION_KINDS,
|
|
MOTION_VISIBILITIES,
|
|
} from '@pig/core';
|
|
import { demandDeals } from './demand';
|
|
import { users } from './identity';
|
|
|
|
/**
|
|
* Render a value set as a SQL `IN` list from the ontology constant.
|
|
*
|
|
* Copied from `learn.ts` rather than imported: it is a private detail of how a
|
|
* schema file renders its constraints, and exporting it would make two files
|
|
* that must be able to diverge share one. The values are compile-time literal
|
|
* constants from `@pig/core`, never input.
|
|
*/
|
|
const inList = (values: readonly string[]) =>
|
|
sql.raw(values.map((value) => `'${value}'`).join(', '));
|
|
|
|
export const motionTemplates = pgTable(
|
|
'motion_templates',
|
|
{
|
|
id: uuid('id').primaryKey().defaultRandom(),
|
|
|
|
kind: text('kind', { enum: MOTION_KINDS }).notNull(),
|
|
|
|
/** Stable across versions: the slug, not the id, is the identity of a lineage. */
|
|
slug: text('slug').notNull(),
|
|
version: integer('version').notNull().default(1),
|
|
|
|
title: text('title').notNull(),
|
|
summary: text('summary').notNull(),
|
|
/** Markdown. Rendered by the web app; never framed, never executed. */
|
|
body: text('body').notNull(),
|
|
/** The structured half — scoring dimensions, pricing inputs, checklist items. */
|
|
fields: jsonb('fields').$type<Record<string, unknown>>(),
|
|
|
|
stage: text('stage', { enum: DEMAND_STAGES }).notNull(),
|
|
|
|
visibility: text('visibility', { enum: MOTION_VISIBILITIES }).notNull().default('private'),
|
|
ownerUserId: uuid('owner_user_id').references(() => users.id, { onDelete: 'set null' }),
|
|
|
|
/** The previous version in this lineage. Null on version 1. */
|
|
supersedesId: uuid('supersedes_id').references((): AnyPgColumn => motionTemplates.id, {
|
|
onDelete: 'set null',
|
|
}),
|
|
/**
|
|
* The engagement artifact this version was promoted from. Half of the
|
|
* cycle the migration has to break — see the file header.
|
|
*/
|
|
originArtifactId: uuid('origin_artifact_id').references(
|
|
(): AnyPgColumn => engagementArtifacts.id,
|
|
{ onDelete: 'set null' },
|
|
),
|
|
|
|
/** The starter library shipped with the product, not somebody's draft. */
|
|
isSystem: boolean('is_system').notNull().default(false),
|
|
/** Incremented on instantiate. Above zero, this row stops being editable. */
|
|
usageCount: integer('usage_count').notNull().default(0),
|
|
|
|
archivedAt: timestamp('archived_at', { withTimezone: true }),
|
|
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
|
},
|
|
(t) => [
|
|
check('motion_templates_kind_check', sql`${t.kind} IN (${inList(MOTION_KINDS)})`),
|
|
check('motion_templates_stage_check', sql`${t.stage} IN (${inList(DEMAND_STAGES)})`),
|
|
check(
|
|
'motion_templates_visibility_check',
|
|
sql`${t.visibility} IN (${inList(MOTION_VISIBILITIES)})`,
|
|
),
|
|
check('motion_templates_version_positive_check', sql`${t.version} > 0`),
|
|
/**
|
|
* Written as an implication so it reads as the rule it encodes: private
|
|
* implies owned. An unowned private row is invisible to every query that
|
|
* is written correctly, which is precisely why it must not exist.
|
|
*/
|
|
check(
|
|
'motion_templates_private_has_owner_check',
|
|
sql`${t.visibility} <> 'private' OR ${t.ownerUserId} IS NOT NULL`,
|
|
),
|
|
|
|
/** Load-bearing for the seed's `onConflictDoNothing` — see the file header. */
|
|
unique('motion_templates_slug_version_key').on(t.slug, t.version),
|
|
|
|
/** The library browse: a kind, at a stage. */
|
|
index('motion_templates_kind_stage_idx').on(t.kind, t.stage),
|
|
/** The shared-library read, which filters on visibility before anything else. */
|
|
index('motion_templates_visibility_kind_idx').on(t.visibility, t.kind),
|
|
/** "My drafts", the other half of the private/shared split. */
|
|
index('motion_templates_owner_idx').on(t.ownerUserId),
|
|
],
|
|
);
|
|
|
|
export const engagements = pgTable(
|
|
'engagements',
|
|
{
|
|
id: uuid('id').primaryKey().defaultRandom(),
|
|
|
|
/**
|
|
* The spine. An engagement has no independent existence — it is the motion
|
|
* being run against a deal that already exists, so it dies with the deal.
|
|
*/
|
|
demandDealId: uuid('demand_deal_id')
|
|
.notNull()
|
|
.references(() => demandDeals.id, { onDelete: 'cascade' }),
|
|
|
|
playbookTemplateId: uuid('playbook_template_id').references(() => motionTemplates.id, {
|
|
onDelete: 'set null',
|
|
}),
|
|
ownerUserId: uuid('owner_user_id').references(() => users.id, { onDelete: 'set null' }),
|
|
|
|
status: text('status', { enum: ENGAGEMENT_STATUSES }).notNull().default('open'),
|
|
summary: text('summary'),
|
|
|
|
openedAt: timestamp('opened_at', { withTimezone: true }).notNull().defaultNow(),
|
|
closedAt: timestamp('closed_at', { withTimezone: true }),
|
|
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
|
},
|
|
(t) => [
|
|
check('engagements_status_check', sql`${t.status} IN (${inList(ENGAGEMENT_STATUSES)})`),
|
|
/**
|
|
* One engagement per deal. Two engagements on one deal would give the same
|
|
* opportunity two qualification histories and two answers to "what stage
|
|
* is this at"; if that ever has to relax it relaxes deliberately.
|
|
*/
|
|
unique('engagements_demand_deal_key').on(t.demandDealId),
|
|
index('engagements_status_idx').on(t.status),
|
|
],
|
|
);
|
|
|
|
export const engagementArtifacts = pgTable(
|
|
'engagement_artifacts',
|
|
{
|
|
id: uuid('id').primaryKey().defaultRandom(),
|
|
|
|
engagementId: uuid('engagement_id')
|
|
.notNull()
|
|
.references(() => engagements.id, { onDelete: 'cascade' }),
|
|
|
|
/** What it was instantiated from. Null for an artifact written from scratch. */
|
|
templateId: uuid('template_id').references(() => motionTemplates.id, { onDelete: 'set null' }),
|
|
|
|
kind: text('kind', { enum: MOTION_KINDS }).notNull(),
|
|
stage: text('stage', { enum: DEMAND_STAGES }).notNull(),
|
|
|
|
title: text('title').notNull(),
|
|
body: text('body').notNull(),
|
|
fields: jsonb('fields').$type<Record<string, unknown>>(),
|
|
|
|
status: text('status', { enum: ARTIFACT_STATUSES }).notNull().default('draft'),
|
|
authoredByUserId: uuid('authored_by_user_id').references(() => users.id, {
|
|
onDelete: 'set null',
|
|
}),
|
|
|
|
/** Set once, by promotion. Its presence is what refuses a second promotion. */
|
|
promotedTemplateId: uuid('promoted_template_id').references(() => motionTemplates.id, {
|
|
onDelete: 'set null',
|
|
}),
|
|
|
|
archivedAt: timestamp('archived_at', { withTimezone: true }),
|
|
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
|
},
|
|
(t) => [
|
|
check('engagement_artifacts_kind_check', sql`${t.kind} IN (${inList(MOTION_KINDS)})`),
|
|
check('engagement_artifacts_stage_check', sql`${t.stage} IN (${inList(DEMAND_STAGES)})`),
|
|
check('engagement_artifacts_status_check', sql`${t.status} IN (${inList(ARTIFACT_STATUSES)})`),
|
|
/** The workspace reads one engagement's artifacts grouped by stage. */
|
|
index('engagement_artifacts_engagement_stage_idx').on(t.engagementId, t.stage),
|
|
],
|
|
);
|
|
|
|
export const qualificationScores = pgTable(
|
|
'qualification_scores',
|
|
{
|
|
id: uuid('id').primaryKey().defaultRandom(),
|
|
|
|
engagementId: uuid('engagement_id')
|
|
.notNull()
|
|
.references(() => engagements.id, { onDelete: 'cascade' }),
|
|
frameworkTemplateId: uuid('framework_template_id').references(() => motionTemplates.id, {
|
|
onDelete: 'set null',
|
|
}),
|
|
|
|
/** `MotionDimensionScore[]` exactly as scored, so an old row survives a reweighted framework. */
|
|
dimensions: jsonb('dimensions').$type<Record<string, unknown>[]>().notNull(),
|
|
|
|
/**
|
|
* Basis points of the maximum, integer, computed by
|
|
* `motionScoreBasisPoints`. Stored rather than derived because the
|
|
* framework's weights can change and this number must not.
|
|
*/
|
|
basisPoints: integer('basis_points').notNull(),
|
|
band: text('band').notNull(),
|
|
note: text('note'),
|
|
|
|
scoredByUserId: uuid('scored_by_user_id').references(() => users.id, { onDelete: 'set null' }),
|
|
scoredAt: timestamp('scored_at', { withTimezone: true }).notNull().defaultNow(),
|
|
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
|
},
|
|
(t) => [
|
|
check(
|
|
'qualification_scores_basis_points_check',
|
|
sql`${t.basisPoints} >= 0 AND ${t.basisPoints} <= ${sql.raw(String(MOTION_BASIS_POINTS_MAX))}`,
|
|
),
|
|
/** The history panel: one engagement, newest first. */
|
|
index('qualification_scores_engagement_idx').on(t.engagementId, t.scoredAt.desc()),
|
|
],
|
|
);
|
|
|
|
export type MotionTemplate = typeof motionTemplates.$inferSelect;
|
|
export type NewMotionTemplate = typeof motionTemplates.$inferInsert;
|
|
export type Engagement = typeof engagements.$inferSelect;
|
|
export type NewEngagement = typeof engagements.$inferInsert;
|
|
export type EngagementArtifact = typeof engagementArtifacts.$inferSelect;
|
|
export type NewEngagementArtifact = typeof engagementArtifacts.$inferInsert;
|
|
export type QualificationScore = typeof qualificationScores.$inferSelect;
|
|
export type NewQualificationScore = typeof qualificationScores.$inferInsert;
|