Add Motion: the go-to-market operating system on top of the ledger
The ledger answers which contracted capacity is sold, to whom, at what margin. It says nothing about the motion — the repeatable practice that turns a customer conversation into a scoped deployment, and turns that deployment into something the next one reuses. Motion is deliberately not a parallel entity tree. DEMAND_STAGES already is the motion, so Motion binds reusable artefacts to the stages of a demand deal that already exists: an engagement hangs off one deal, cascade deleted, one per deal by unique constraint. Nine closed kinds, each declaring which stages it serves, and a starter library of twelve templates covering all eight open stages. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -72,6 +72,7 @@ import { createNotionImportRoutes, NOTION_OAUTH_CALLBACK_PATH } from './routes/n
|
||||
import { createGrowthRoutes } from './routes/growth';
|
||||
import { createCalendarRoutes } from './routes/calendar';
|
||||
import { createLearnRoutes, LEARN_ACCESS_PATH, LEARN_PUBLIC_PATH } from './routes/learn';
|
||||
import { createMotionRoutes } from './routes/motion';
|
||||
import { createReadGuardRoutes } from './routes/read-guards';
|
||||
import { createActivityRoutes } from './routes/activities';
|
||||
import { NotificationOutbox } from './services/notification-outbox';
|
||||
@@ -266,6 +267,7 @@ export function createApp(
|
||||
app.route('/', createGrowthRoutes(db));
|
||||
app.route('/', createCalendarRoutes(db));
|
||||
app.route('/', createLearnRoutes(db));
|
||||
app.route('/', createMotionRoutes(db));
|
||||
app.route(
|
||||
'/',
|
||||
createPiggyChatRoutes({
|
||||
|
||||
@@ -0,0 +1,933 @@
|
||||
/**
|
||||
* Motion — the HTTP surface for the library, the engagements and the loop.
|
||||
*
|
||||
* Thin by design. Every decision worth arguing about — who may see a private
|
||||
* template, what promotion writes, how a score is computed — is in
|
||||
* `services/motion.ts`, so Piggy reaches the same answers in-process without
|
||||
* going through a handler. The writes are exported `MutationDefinition`
|
||||
* factories in the register of `capacity-writes.ts`, which is what lets a test
|
||||
* drive the rules in §7 without an HTTP server or a database.
|
||||
*
|
||||
* Two things here are not obvious from the endpoint table.
|
||||
*
|
||||
* **`motion:write` and `motion:publish` are team capabilities held on any
|
||||
* team**, so they are enforced with `requireAnyTeamCapability` rather than a
|
||||
* `{ capability, team }` pair. A motion template belongs to a person and a
|
||||
* lineage, not to supply or demand; picking a team to check against would mean
|
||||
* inventing one, and inventing one is how a research lead ends up unable to
|
||||
* publish the reference architecture they are the one writing.
|
||||
*
|
||||
* **Creating or forking straight to `shared` needs `motion:publish`.** The
|
||||
* publish endpoint is not the only way into the shared library — `POST
|
||||
* /templates` and `POST /templates/:id/versions` both take a `visibility` — so
|
||||
* the gate is applied in all three places rather than on the one door that
|
||||
* happens to be named after it.
|
||||
*/
|
||||
import {
|
||||
ARTIFACT_STATUSES,
|
||||
DEMAND_STAGES,
|
||||
ENGAGEMENT_STATUSES,
|
||||
MOTION_KINDS,
|
||||
MOTION_MAX_DIMENSION_SCORE,
|
||||
MOTION_MIN_DIMENSION_SCORE,
|
||||
MOTION_VISIBILITIES,
|
||||
motionBand,
|
||||
permissionGranted,
|
||||
resolveWritePermissionGrants,
|
||||
} from '@pig/core';
|
||||
import type {
|
||||
Database,
|
||||
Engagement,
|
||||
EngagementArtifact,
|
||||
MotionTemplate,
|
||||
QualificationScore,
|
||||
} from '@pig/db';
|
||||
import { demandDeals, engagementArtifacts, engagements, motionTemplates } from '@pig/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
import { requireAnyTeamCapability, type Principal } from '../lib/auth';
|
||||
import {
|
||||
apiError,
|
||||
bodylessMutation,
|
||||
MutationError,
|
||||
mutation,
|
||||
type ApiEnv,
|
||||
type MutationDefinition,
|
||||
} from '../lib/mutation';
|
||||
import {
|
||||
assertTemplateEditable,
|
||||
assertTemplateWritable,
|
||||
instantiateArtifact,
|
||||
loadEngagement,
|
||||
loadTemplateForWrite,
|
||||
lockNewestVersion,
|
||||
motionSlug,
|
||||
MotionService,
|
||||
newestVisibleInLineage,
|
||||
ownsTemplate,
|
||||
promoteArtifact,
|
||||
recordScore,
|
||||
versionConflict,
|
||||
type MotionTransaction,
|
||||
type MotionViewer,
|
||||
} from '../services/motion';
|
||||
|
||||
// ------------------------------------------------------------------- schemas
|
||||
|
||||
const uuid = z.string().uuid();
|
||||
const title = z.string().trim().min(1).max(200);
|
||||
const summary = z.string().trim().min(1).max(2_000);
|
||||
/** Markdown, and long: a playbook is a document, not a field. */
|
||||
const bodyText = z.string().max(200_000);
|
||||
const slug = z.string().trim().min(1).max(80);
|
||||
const fields = z.record(z.unknown()).nullable();
|
||||
|
||||
const templateCreateSchema = z
|
||||
.object({
|
||||
kind: z.enum(MOTION_KINDS),
|
||||
slug: slug.optional(),
|
||||
title,
|
||||
summary,
|
||||
body: bodyText,
|
||||
stage: z.enum(DEMAND_STAGES),
|
||||
fields: fields.optional(),
|
||||
visibility: z.enum(MOTION_VISIBILITIES).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* No `kind` and no `visibility`. A lineage that changes kind halfway is a
|
||||
* different template wearing the same slug, and visibility moves through
|
||||
* `/publish`, which is where the capability check lives.
|
||||
*/
|
||||
const templateUpdateSchema = z
|
||||
.object({ title, summary, body: bodyText, stage: z.enum(DEMAND_STAGES), fields })
|
||||
.partial()
|
||||
.strict()
|
||||
.refine((input) => Object.keys(input).length > 0, 'At least one change is required.');
|
||||
|
||||
const templateVersionSchema = z
|
||||
.object({
|
||||
title: title.optional(),
|
||||
summary: summary.optional(),
|
||||
body: bodyText.optional(),
|
||||
stage: z.enum(DEMAND_STAGES).optional(),
|
||||
fields: fields.optional(),
|
||||
visibility: z.enum(MOTION_VISIBILITIES).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const emptySchema = z.object({}).strict();
|
||||
|
||||
const engagementCreateSchema = z
|
||||
.object({
|
||||
demandDealId: uuid,
|
||||
playbookTemplateId: uuid.nullable().optional(),
|
||||
ownerUserId: uuid.nullable().optional(),
|
||||
summary: z.string().trim().max(2_000).nullable().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const engagementUpdateSchema = z
|
||||
.object({
|
||||
status: z.enum(ENGAGEMENT_STATUSES),
|
||||
ownerUserId: uuid.nullable(),
|
||||
playbookTemplateId: uuid.nullable(),
|
||||
summary: z.string().trim().max(2_000).nullable(),
|
||||
})
|
||||
.partial()
|
||||
.strict()
|
||||
.refine((input) => Object.keys(input).length > 0, 'At least one change is required.');
|
||||
|
||||
const artifactCreateSchema = z
|
||||
.object({
|
||||
templateId: uuid.optional(),
|
||||
kind: z.enum(MOTION_KINDS).optional(),
|
||||
stage: z.enum(DEMAND_STAGES).optional(),
|
||||
title: title.optional(),
|
||||
body: bodyText.optional(),
|
||||
fields: fields.optional(),
|
||||
status: z.enum(ARTIFACT_STATUSES).optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(input) =>
|
||||
Boolean(input.templateId) ||
|
||||
Boolean(input.kind && input.stage && input.title && input.body !== undefined),
|
||||
'Supply a templateId to instantiate, or a kind, stage, title and body.',
|
||||
);
|
||||
|
||||
const artifactUpdateSchema = z
|
||||
.object({
|
||||
title,
|
||||
body: bodyText,
|
||||
fields,
|
||||
stage: z.enum(DEMAND_STAGES),
|
||||
status: z.enum(ARTIFACT_STATUSES),
|
||||
archived: z.boolean(),
|
||||
})
|
||||
.partial()
|
||||
.strict()
|
||||
.refine((input) => Object.keys(input).length > 0, 'At least one change is required.');
|
||||
|
||||
const promoteSchema = z
|
||||
.object({ slug: slug.optional(), title: title.optional(), summary: summary.optional() })
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Weights and scores are integers, and the total is recomputed server-side from
|
||||
* these — the client never posts a score. Bounds come from `@pig/core` so the
|
||||
* scale is defined in one place.
|
||||
*/
|
||||
const scoreSchema = z
|
||||
.object({
|
||||
dimensions: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
id: z.string().trim().min(1).max(120),
|
||||
weight: z.number().int().min(0).max(1_000),
|
||||
score: z
|
||||
.number()
|
||||
.int()
|
||||
.min(MOTION_MIN_DIMENSION_SCORE)
|
||||
.max(MOTION_MAX_DIMENSION_SCORE),
|
||||
})
|
||||
.strict(),
|
||||
)
|
||||
.max(50),
|
||||
frameworkTemplateId: uuid.nullable().optional(),
|
||||
note: z.string().trim().max(2_000).nullable().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const filtersSchema = z
|
||||
.object({
|
||||
kind: z.enum(MOTION_KINDS).optional(),
|
||||
stage: z.enum(DEMAND_STAGES).optional(),
|
||||
visibility: z.enum(MOTION_VISIBILITIES).optional(),
|
||||
q: z.string().trim().max(200).optional(),
|
||||
all: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const engagementFiltersSchema = z
|
||||
.object({ status: z.enum(ENGAGEMENT_STATUSES).optional() })
|
||||
.strict();
|
||||
|
||||
// ------------------------------------------------------------------- helpers
|
||||
|
||||
function viewerOf(principal: Principal): MotionViewer {
|
||||
return { userId: principal.userId, isPlatformAdmin: principal.isPlatformAdmin };
|
||||
}
|
||||
|
||||
const authorizeWrite = {
|
||||
authorize: (p: Principal) => requireAnyTeamCapability(p, 'motion:write'),
|
||||
};
|
||||
const authorizePublish = {
|
||||
authorize: (p: Principal) => requireAnyTeamCapability(p, 'motion:publish'),
|
||||
};
|
||||
|
||||
/**
|
||||
* A template id somebody sent, checked before it is stored.
|
||||
*
|
||||
* `playbook_template_id` and `framework_template_id` are provenance, and they
|
||||
* are returned book-wide on every engagement summary. Written raw, an id
|
||||
* nobody but its owner may read becomes public through the engagement that
|
||||
* cites it, and a wrong id is a foreign-key 500 rather than a 404. Reading it
|
||||
* through the visibility filter makes both cases a clean not-found.
|
||||
*/
|
||||
async function checkedTemplateId(
|
||||
tx: MotionTransaction,
|
||||
principal: Principal,
|
||||
id: string | null | undefined,
|
||||
): Promise<void> {
|
||||
if (!id) return;
|
||||
await loadTemplateForWrite(tx, viewerOf(principal), id);
|
||||
}
|
||||
|
||||
/** The same gate as `authorizePublish`, applied once the body says `shared`. */
|
||||
function requirePublishFor(principal: Principal, visibility: string | undefined): void {
|
||||
if (visibility === 'shared') requireAnyTeamCapability(principal, 'motion:publish');
|
||||
}
|
||||
|
||||
/**
|
||||
* The hint the detail page renders its Publish button from. It checks the
|
||||
* `write` scope as well as the capability, because `requireAnyTeamCapability`
|
||||
* does — a read-only API key belonging to a lead would otherwise be told it may
|
||||
* publish and then be refused `insufficient_scope` by the endpoint itself.
|
||||
*/
|
||||
function canPublish(principal: Principal): boolean {
|
||||
return (
|
||||
principal.scopes.includes('write') &&
|
||||
permissionGranted(resolveWritePermissionGrants(principal), 'motion:publish')
|
||||
);
|
||||
}
|
||||
|
||||
function requiredId(params: Readonly<Record<string, string>>, resource: string): string {
|
||||
const id = params.id;
|
||||
if (!id) throw MutationError.notFound(resource);
|
||||
return id;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ library writes
|
||||
|
||||
export function motionTemplateCreateDefinition(): MutationDefinition<
|
||||
typeof templateCreateSchema,
|
||||
{ template: MotionTemplate }
|
||||
> {
|
||||
return {
|
||||
schema: templateCreateSchema,
|
||||
permission: authorizeWrite,
|
||||
invalidMessage: 'Invalid motion template.',
|
||||
async mutate({ input, principal, tx, now }) {
|
||||
requirePublishFor(principal, input.visibility);
|
||||
const slug = motionSlug(input.slug ?? input.title);
|
||||
|
||||
// Checked rather than left to `(slug, version)`, for the reason the
|
||||
// engagement create gives below: a title that derives an existing slug —
|
||||
// "Proposal Blocks" is one of the nine shipped starters — would otherwise
|
||||
// be a 500 with `Internal error` and no way for the caller to tell what
|
||||
// they collided with.
|
||||
if (await lockNewestVersion(tx, slug)) {
|
||||
throw new MutationError(
|
||||
'template_slug_exists',
|
||||
`A template lineage already uses the slug "${slug}". POST /api/motion/templates/<id>/versions to add a version to it, or supply a different slug.`,
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
const [created] = await tx
|
||||
.insert(motionTemplates)
|
||||
.values({
|
||||
kind: input.kind,
|
||||
slug,
|
||||
version: 1,
|
||||
title: input.title,
|
||||
summary: input.summary,
|
||||
body: input.body,
|
||||
fields: input.fields ?? null,
|
||||
stage: input.stage,
|
||||
visibility: input.visibility ?? 'private',
|
||||
// Never null. The CHECK refuses an unowned private row, and an
|
||||
// unowned private row is one nobody could ever read back.
|
||||
ownerUserId: principal.userId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning()
|
||||
.catch(versionConflict);
|
||||
if (!created) throw new Error('Motion template insert returned no row');
|
||||
|
||||
return {
|
||||
data: { template: created },
|
||||
activity: {
|
||||
type: 'note',
|
||||
subject: `Added a motion template: ${created.title}`,
|
||||
meta: {
|
||||
action: 'motion_template.created',
|
||||
templateId: created.id,
|
||||
kind: created.kind,
|
||||
slug: created.slug,
|
||||
visibility: created.visibility,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function motionTemplateUpdateDefinition(): MutationDefinition<
|
||||
typeof templateUpdateSchema,
|
||||
{ template: MotionTemplate }
|
||||
> {
|
||||
return {
|
||||
schema: templateUpdateSchema,
|
||||
permission: authorizeWrite,
|
||||
invalidMessage: 'Invalid motion template change.',
|
||||
async mutate({ input, params, principal, tx, now }) {
|
||||
const viewer = viewerOf(principal);
|
||||
const existing = await loadTemplateForWrite(
|
||||
tx,
|
||||
viewer,
|
||||
requiredId(params, 'Motion template'),
|
||||
);
|
||||
assertTemplateWritable(viewer, existing);
|
||||
assertTemplateEditable(existing);
|
||||
|
||||
const [updated] = await tx
|
||||
.update(motionTemplates)
|
||||
.set({
|
||||
...(input.title !== undefined ? { title: input.title } : {}),
|
||||
...(input.summary !== undefined ? { summary: input.summary } : {}),
|
||||
...(input.body !== undefined ? { body: input.body } : {}),
|
||||
...(input.stage !== undefined ? { stage: input.stage } : {}),
|
||||
...(input.fields !== undefined ? { fields: input.fields } : {}),
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(motionTemplates.id, existing.id))
|
||||
.returning();
|
||||
if (!updated) throw MutationError.notFound('Motion template');
|
||||
|
||||
return {
|
||||
data: { template: updated },
|
||||
activity: {
|
||||
type: 'note',
|
||||
subject: `Edited a motion template: ${updated.title}`,
|
||||
meta: {
|
||||
action: 'motion_template.updated',
|
||||
templateId: updated.id,
|
||||
fields: Object.keys(input),
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A new version of a lineage — the answer to a `409 template_in_use`, and the
|
||||
* "fork to private" affordance, in one endpoint.
|
||||
*
|
||||
* Ownership is deliberately NOT required: a shared template belongs to the
|
||||
* book, and forking it into your own private draft is what a library is for.
|
||||
* What is required is `motion:publish` if the fork is to land shared.
|
||||
*/
|
||||
export function motionTemplateVersionDefinition(): MutationDefinition<
|
||||
typeof templateVersionSchema,
|
||||
{ template: MotionTemplate }
|
||||
> {
|
||||
return {
|
||||
schema: templateVersionSchema,
|
||||
permission: authorizeWrite,
|
||||
invalidMessage: 'Invalid motion template version.',
|
||||
async mutate({ input, params, principal, tx, now }) {
|
||||
requirePublishFor(principal, input.visibility);
|
||||
const viewer = viewerOf(principal);
|
||||
const source = await loadTemplateForWrite(
|
||||
tx,
|
||||
viewer,
|
||||
requiredId(params, 'Motion template'),
|
||||
);
|
||||
// Two different questions, deliberately answered by two queries. The
|
||||
// version has to be allocated against the whole lineage or it collides
|
||||
// with a private fork nobody else can see, but `supersedes_id` must name
|
||||
// a row this caller could actually fetch — otherwise a fork of a shared
|
||||
// v1 comes back claiming to supersede somebody's private v4, which both
|
||||
// leaks that the private versions exist and records a lineage edge that
|
||||
// is not the one the user made.
|
||||
const newest = await lockNewestVersion(tx, source.slug);
|
||||
const previous = (await newestVisibleInLineage(tx, viewer, source.slug)) ?? source;
|
||||
|
||||
const [created] = await tx
|
||||
.insert(motionTemplates)
|
||||
.values({
|
||||
kind: source.kind,
|
||||
slug: source.slug,
|
||||
version: (newest?.version ?? source.version) + 1,
|
||||
title: input.title ?? source.title,
|
||||
summary: input.summary ?? source.summary,
|
||||
body: input.body ?? source.body,
|
||||
fields: input.fields !== undefined ? input.fields : source.fields,
|
||||
stage: input.stage ?? source.stage,
|
||||
// Private unless asked otherwise, so forking a shared template is
|
||||
// never accidentally a publication.
|
||||
visibility: input.visibility ?? 'private',
|
||||
ownerUserId: principal.userId,
|
||||
supersedesId: previous.id,
|
||||
isSystem: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning()
|
||||
.catch(versionConflict);
|
||||
if (!created) throw new Error('Motion template version insert returned no row');
|
||||
|
||||
return {
|
||||
data: { template: created },
|
||||
activity: {
|
||||
type: 'note',
|
||||
subject: `New version of ${created.title} (v${created.version})`,
|
||||
meta: {
|
||||
action: 'motion_template.versioned',
|
||||
templateId: created.id,
|
||||
supersedesId: previous.id,
|
||||
slug: created.slug,
|
||||
version: created.version,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function motionTemplatePublishDefinition(): MutationDefinition<
|
||||
typeof emptySchema,
|
||||
{ template: MotionTemplate }
|
||||
> {
|
||||
return {
|
||||
schema: emptySchema,
|
||||
permission: authorizePublish,
|
||||
invalidMessage: 'Invalid publish request.',
|
||||
async mutate({ params, principal, tx, now }) {
|
||||
const viewer = viewerOf(principal);
|
||||
const existing = await loadTemplateForWrite(
|
||||
tx,
|
||||
viewer,
|
||||
requiredId(params, 'Motion template'),
|
||||
);
|
||||
assertTemplateWritable(viewer, existing);
|
||||
|
||||
// Idempotent: publishing twice is the same statement made twice, and a
|
||||
// 409 here would only ever fire on a double-clicked button.
|
||||
const [published] =
|
||||
existing.visibility === 'shared'
|
||||
? [existing]
|
||||
: await tx
|
||||
.update(motionTemplates)
|
||||
.set({ visibility: 'shared', updatedAt: now })
|
||||
.where(eq(motionTemplates.id, existing.id))
|
||||
.returning();
|
||||
if (!published) throw MutationError.notFound('Motion template');
|
||||
|
||||
return {
|
||||
data: { template: published },
|
||||
activity: {
|
||||
type: 'note',
|
||||
subject: `Published to the library: ${published.title} (v${published.version})`,
|
||||
meta: {
|
||||
action: 'motion_template.published',
|
||||
templateId: published.id,
|
||||
alreadyShared: existing.visibility === 'shared',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive, never delete: an engagement artifact records the template it came
|
||||
* from, and provenance that can vanish is not provenance.
|
||||
*/
|
||||
export function motionTemplateArchiveDefinition(): MutationDefinition<
|
||||
typeof emptySchema,
|
||||
{ id: string; archivedAt: Date | null }
|
||||
> {
|
||||
return {
|
||||
schema: emptySchema,
|
||||
permission: authorizeWrite,
|
||||
invalidMessage: 'Invalid template removal.',
|
||||
async mutate({ params, principal, tx, now }) {
|
||||
const viewer = viewerOf(principal);
|
||||
const existing = await loadTemplateForWrite(
|
||||
tx,
|
||||
viewer,
|
||||
requiredId(params, 'Motion template'),
|
||||
);
|
||||
assertTemplateWritable(viewer, existing);
|
||||
|
||||
const [archived] = existing.archivedAt
|
||||
? [existing]
|
||||
: await tx
|
||||
.update(motionTemplates)
|
||||
.set({ archivedAt: now, updatedAt: now })
|
||||
.where(eq(motionTemplates.id, existing.id))
|
||||
.returning();
|
||||
if (!archived) throw MutationError.notFound('Motion template');
|
||||
|
||||
return {
|
||||
data: { id: archived.id, archivedAt: archived.archivedAt },
|
||||
activity: {
|
||||
type: 'note',
|
||||
subject: `Archived a motion template: ${archived.title}`,
|
||||
meta: {
|
||||
action: 'motion_template.archived',
|
||||
templateId: archived.id,
|
||||
alreadyArchived: existing.archivedAt !== null,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// --------------------------------------------------------- engagement writes
|
||||
|
||||
export function motionEngagementCreateDefinition(): MutationDefinition<
|
||||
typeof engagementCreateSchema,
|
||||
{ engagement: Engagement }
|
||||
> {
|
||||
return {
|
||||
schema: engagementCreateSchema,
|
||||
permission: authorizeWrite,
|
||||
invalidMessage: 'Invalid engagement.',
|
||||
async mutate({ input, principal, tx, now }) {
|
||||
const [deal] = await tx
|
||||
.select()
|
||||
.from(demandDeals)
|
||||
.where(eq(demandDeals.id, input.demandDealId))
|
||||
.limit(1);
|
||||
if (!deal) throw MutationError.notFound('Demand deal');
|
||||
await checkedTemplateId(tx, principal, input.playbookTemplateId);
|
||||
|
||||
// Checked rather than left to the unique constraint, so the caller gets
|
||||
// the id of the engagement that already exists instead of a 500.
|
||||
const [existing] = await tx
|
||||
.select()
|
||||
.from(engagements)
|
||||
.where(eq(engagements.demandDealId, deal.id))
|
||||
.limit(1);
|
||||
if (existing) {
|
||||
throw new MutationError(
|
||||
'engagement_exists',
|
||||
`This deal already has an engagement (${existing.id}).`,
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
const [created] = await tx
|
||||
.insert(engagements)
|
||||
.values({
|
||||
demandDealId: deal.id,
|
||||
playbookTemplateId: input.playbookTemplateId ?? null,
|
||||
ownerUserId: input.ownerUserId ?? principal.userId,
|
||||
summary: input.summary ?? null,
|
||||
openedAt: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning();
|
||||
if (!created) throw new Error('Engagement insert returned no row');
|
||||
|
||||
return {
|
||||
data: { engagement: created },
|
||||
activity: {
|
||||
type: 'note',
|
||||
subject: `Opened an engagement on ${deal.name}`,
|
||||
accountId: deal.accountId,
|
||||
demandDealId: deal.id,
|
||||
meta: { action: 'engagement.opened', engagementId: created.id },
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function motionEngagementUpdateDefinition(): MutationDefinition<
|
||||
typeof engagementUpdateSchema,
|
||||
{ engagement: Engagement }
|
||||
> {
|
||||
return {
|
||||
schema: engagementUpdateSchema,
|
||||
permission: authorizeWrite,
|
||||
invalidMessage: 'Invalid engagement change.',
|
||||
async mutate({ input, params, principal, tx, now }) {
|
||||
const { engagement, deal } = await loadEngagement(tx, requiredId(params, 'Engagement'));
|
||||
await checkedTemplateId(tx, principal, input.playbookTemplateId);
|
||||
|
||||
const [updated] = await tx
|
||||
.update(engagements)
|
||||
.set({
|
||||
...(input.status !== undefined ? { status: input.status } : {}),
|
||||
...(input.ownerUserId !== undefined ? { ownerUserId: input.ownerUserId } : {}),
|
||||
...(input.playbookTemplateId !== undefined
|
||||
? { playbookTemplateId: input.playbookTemplateId }
|
||||
: {}),
|
||||
...(input.summary !== undefined ? { summary: input.summary } : {}),
|
||||
// `closed_at` follows the status rather than being sent, so a
|
||||
// reopened engagement cannot keep a close date it no longer has.
|
||||
...(input.status !== undefined
|
||||
? { closedAt: input.status === 'won' || input.status === 'lost' ? now : null }
|
||||
: {}),
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(engagements.id, engagement.id))
|
||||
.returning();
|
||||
if (!updated) throw MutationError.notFound('Engagement');
|
||||
|
||||
return {
|
||||
data: { engagement: updated },
|
||||
activity: {
|
||||
type: 'note',
|
||||
subject: `Updated the engagement on ${deal.name}`,
|
||||
accountId: deal.accountId,
|
||||
demandDealId: deal.id,
|
||||
meta: {
|
||||
action: 'engagement.updated',
|
||||
engagementId: updated.id,
|
||||
fields: Object.keys(input),
|
||||
status: updated.status,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function motionArtifactCreateDefinition(): MutationDefinition<
|
||||
typeof artifactCreateSchema,
|
||||
{ artifact: EngagementArtifact }
|
||||
> {
|
||||
return {
|
||||
schema: artifactCreateSchema,
|
||||
permission: authorizeWrite,
|
||||
invalidMessage: 'Invalid engagement artefact.',
|
||||
async mutate({ input, params, principal, tx, now }) {
|
||||
const { engagement, deal } = await loadEngagement(tx, requiredId(params, 'Engagement'));
|
||||
const { artifact, template } = await instantiateArtifact(
|
||||
tx,
|
||||
viewerOf(principal),
|
||||
{ ...input, engagementId: engagement.id },
|
||||
now,
|
||||
);
|
||||
|
||||
return {
|
||||
data: { artifact },
|
||||
activity: {
|
||||
type: 'note',
|
||||
subject: template
|
||||
? `Instantiated ${template.title} (v${template.version}) on ${deal.name}`
|
||||
: `Added a ${artifact.kind} artefact to ${deal.name}`,
|
||||
accountId: deal.accountId,
|
||||
demandDealId: deal.id,
|
||||
meta: {
|
||||
action: 'engagement_artifact.created',
|
||||
engagementId: engagement.id,
|
||||
artifactId: artifact.id,
|
||||
templateId: template?.id ?? null,
|
||||
kind: artifact.kind,
|
||||
stage: artifact.stage,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Not restricted to the author: an engagement is worked by a team, and an
|
||||
* artifact nobody but its author can finish is one that stalls the week they go
|
||||
* on leave. The library — which everybody copies — is what ownership guards.
|
||||
*/
|
||||
export function motionArtifactUpdateDefinition(): MutationDefinition<
|
||||
typeof artifactUpdateSchema,
|
||||
{ artifact: EngagementArtifact }
|
||||
> {
|
||||
return {
|
||||
schema: artifactUpdateSchema,
|
||||
permission: authorizeWrite,
|
||||
invalidMessage: 'Invalid artefact change.',
|
||||
async mutate({ input, params, tx, now }) {
|
||||
const id = requiredId(params, 'Engagement artefact');
|
||||
const [existing] = await tx
|
||||
.select()
|
||||
.from(engagementArtifacts)
|
||||
.where(eq(engagementArtifacts.id, id))
|
||||
.limit(1);
|
||||
if (!existing) throw MutationError.notFound('Engagement artefact');
|
||||
const { deal } = await loadEngagement(tx, existing.engagementId);
|
||||
|
||||
const [updated] = await tx
|
||||
.update(engagementArtifacts)
|
||||
.set({
|
||||
...(input.title !== undefined ? { title: input.title } : {}),
|
||||
...(input.body !== undefined ? { body: input.body } : {}),
|
||||
...(input.fields !== undefined ? { fields: input.fields } : {}),
|
||||
...(input.stage !== undefined ? { stage: input.stage } : {}),
|
||||
...(input.status !== undefined ? { status: input.status } : {}),
|
||||
...(input.archived !== undefined ? { archivedAt: input.archived ? now : null } : {}),
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(engagementArtifacts.id, existing.id))
|
||||
.returning();
|
||||
if (!updated) throw MutationError.notFound('Engagement artefact');
|
||||
|
||||
return {
|
||||
data: { artifact: updated },
|
||||
activity: {
|
||||
type: 'note',
|
||||
subject: `Updated ${updated.title} on ${deal.name}`,
|
||||
accountId: deal.accountId,
|
||||
demandDealId: deal.id,
|
||||
meta: {
|
||||
action: 'engagement_artifact.updated',
|
||||
artifactId: updated.id,
|
||||
engagementId: updated.engagementId,
|
||||
fields: Object.keys(input),
|
||||
status: updated.status,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function motionArtifactPromoteDefinition(): MutationDefinition<
|
||||
typeof promoteSchema,
|
||||
{ template: MotionTemplate; artifact: EngagementArtifact }
|
||||
> {
|
||||
return {
|
||||
schema: promoteSchema,
|
||||
permission: authorizePublish,
|
||||
invalidMessage: 'Invalid promotion.',
|
||||
async mutate({ input, params, principal, tx, now }) {
|
||||
const result = await promoteArtifact(
|
||||
tx,
|
||||
viewerOf(principal),
|
||||
requiredId(params, 'Engagement artefact'),
|
||||
input,
|
||||
now,
|
||||
);
|
||||
|
||||
return {
|
||||
data: { template: result.template, artifact: result.artifact },
|
||||
activity: {
|
||||
type: 'note',
|
||||
subject: `Promoted to the library: ${result.template.title} (v${result.template.version})`,
|
||||
// The account, so the loop lands on the timeline of the deal that
|
||||
// proved it rather than only in the library.
|
||||
accountId: result.engagement.deal.accountId,
|
||||
demandDealId: result.engagement.deal.id,
|
||||
meta: {
|
||||
action: 'engagement_artifact.promoted',
|
||||
artifactId: result.artifact.id,
|
||||
engagementId: result.engagement.engagement.id,
|
||||
templateId: result.template.id,
|
||||
supersedesId: result.supersedes?.id ?? null,
|
||||
slug: result.template.slug,
|
||||
version: result.template.version,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function motionScoreDefinition(): MutationDefinition<
|
||||
typeof scoreSchema,
|
||||
{ score: QualificationScore }
|
||||
> {
|
||||
return {
|
||||
schema: scoreSchema,
|
||||
permission: authorizeWrite,
|
||||
invalidMessage: 'Invalid qualification score.',
|
||||
async mutate({ input, params, principal, tx, now }) {
|
||||
const { engagement, deal } = await loadEngagement(tx, requiredId(params, 'Engagement'));
|
||||
await checkedTemplateId(tx, principal, input.frameworkTemplateId);
|
||||
const score = await recordScore(tx, viewerOf(principal), engagement.id, input, now);
|
||||
|
||||
return {
|
||||
data: { score },
|
||||
activity: {
|
||||
type: 'note',
|
||||
// Rounded, never truncated — the same rule money follows, for the
|
||||
// same reason: this line is the only score most people will read.
|
||||
subject: `Qualified ${deal.name}: ${score.band} (${Math.round(score.basisPoints / 100)}%)`,
|
||||
accountId: deal.accountId,
|
||||
demandDealId: deal.id,
|
||||
meta: {
|
||||
action: 'qualification_score.recorded',
|
||||
engagementId: engagement.id,
|
||||
scoreId: score.id,
|
||||
basisPoints: score.basisPoints,
|
||||
band: score.band,
|
||||
tone: motionBand(score.basisPoints).tone,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- routes
|
||||
|
||||
export function createMotionRoutes(db: Database): Hono<ApiEnv> {
|
||||
const routes = new Hono<ApiEnv>();
|
||||
const motion = new MotionService(db);
|
||||
|
||||
routes.get('/api/motion', async (c) =>
|
||||
c.json(await motion.overview(viewerOf(c.get('principal')))),
|
||||
);
|
||||
|
||||
routes.get('/api/motion/templates', async (c) => {
|
||||
const parsed = filtersSchema.safeParse(c.req.query());
|
||||
if (!parsed.success) {
|
||||
return c.json(
|
||||
apiError('invalid_request', 'Invalid library filter.', parsed.error.issues),
|
||||
400,
|
||||
);
|
||||
}
|
||||
const { all, ...filters } = parsed.data;
|
||||
return c.json(
|
||||
await motion.listTemplates(viewerOf(c.get('principal')), { ...filters, all: all === '1' }),
|
||||
);
|
||||
});
|
||||
|
||||
routes.get('/api/motion/templates/:id', async (c) => {
|
||||
const principal = c.get('principal');
|
||||
const found = await motion.template(viewerOf(principal), c.req.param('id'));
|
||||
// A private template belonging to somebody else answers exactly as an
|
||||
// unknown id does. Anything else confirms that it exists.
|
||||
if (!found) return c.json(apiError('not_found', 'Motion template not found.'), 404);
|
||||
|
||||
const owned = ownsTemplate(viewerOf(principal), found.template);
|
||||
return c.json({
|
||||
...found,
|
||||
/** The server's judgement, so the page does not re-derive §7a in TSX. */
|
||||
canEdit: owned && found.template.usageCount === 0,
|
||||
canPublish: owned && found.template.visibility === 'private' && canPublish(principal),
|
||||
});
|
||||
});
|
||||
|
||||
routes.get('/api/motion/engagements', async (c) => {
|
||||
const parsed = engagementFiltersSchema.safeParse(c.req.query());
|
||||
if (!parsed.success) {
|
||||
return c.json(
|
||||
apiError('invalid_request', 'Invalid engagement filter.', parsed.error.issues),
|
||||
400,
|
||||
);
|
||||
}
|
||||
return c.json({ engagements: await motion.listEngagements(parsed.data.status) });
|
||||
});
|
||||
|
||||
routes.get('/api/motion/engagements/:id', async (c) => {
|
||||
const found = await motion.engagement(viewerOf(c.get('principal')), c.req.param('id'));
|
||||
if (!found) return c.json(apiError('not_found', 'Engagement not found.'), 404);
|
||||
return c.json(found);
|
||||
});
|
||||
|
||||
routes.post('/api/motion/templates', mutation(db, motionTemplateCreateDefinition()));
|
||||
routes.patch('/api/motion/templates/:id', mutation(db, motionTemplateUpdateDefinition()));
|
||||
routes.post(
|
||||
'/api/motion/templates/:id/versions',
|
||||
mutation(db, motionTemplateVersionDefinition()),
|
||||
);
|
||||
routes.post(
|
||||
'/api/motion/templates/:id/publish',
|
||||
bodylessMutation(db, motionTemplatePublishDefinition()),
|
||||
);
|
||||
routes.delete(
|
||||
'/api/motion/templates/:id',
|
||||
bodylessMutation(db, motionTemplateArchiveDefinition()),
|
||||
);
|
||||
|
||||
routes.post('/api/motion/engagements', mutation(db, motionEngagementCreateDefinition()));
|
||||
routes.patch('/api/motion/engagements/:id', mutation(db, motionEngagementUpdateDefinition()));
|
||||
routes.post(
|
||||
'/api/motion/engagements/:id/artifacts',
|
||||
mutation(db, motionArtifactCreateDefinition()),
|
||||
);
|
||||
routes.patch('/api/motion/artifacts/:id', mutation(db, motionArtifactUpdateDefinition()));
|
||||
routes.post(
|
||||
'/api/motion/artifacts/:id/promote',
|
||||
mutation(db, motionArtifactPromoteDefinition()),
|
||||
);
|
||||
routes.post(
|
||||
'/api/motion/engagements/:id/scores',
|
||||
mutation(db, motionScoreDefinition()),
|
||||
);
|
||||
|
||||
return routes;
|
||||
}
|
||||
@@ -58,6 +58,22 @@ export const READ_RULES: readonly ReadRule[] = [
|
||||
{ method: 'GET', path: '/api/growth/accounts/:id', capability: 'book:read' },
|
||||
{ method: 'GET', path: '/api/facts', capability: 'book:read' },
|
||||
|
||||
/**
|
||||
* The motion library is not the cost book. A proposal block, a reference
|
||||
* architecture and a qualification framework carry no supplier cost and no
|
||||
* break-even, so gating them on `economics:read` would keep a research lead
|
||||
* out of the reference architectures they are the ones writing.
|
||||
*
|
||||
* The private/shared split is NOT enforced here and could not be: this table
|
||||
* is keyed on a path and knows nothing about rows. It is a real WHERE clause
|
||||
* in `services/motion.ts`, which every one of these reads goes through.
|
||||
*/
|
||||
{ method: 'GET', path: '/api/motion', capability: 'book:read' },
|
||||
{ method: 'GET', path: '/api/motion/templates', capability: 'book:read' },
|
||||
{ method: 'GET', path: '/api/motion/templates/:id', capability: 'book:read' },
|
||||
{ method: 'GET', path: '/api/motion/engagements', capability: 'book:read' },
|
||||
{ method: 'GET', path: '/api/motion/engagements/:id', capability: 'book:read' },
|
||||
|
||||
{ method: 'GET', path: '/api/team', capability: 'team:read' },
|
||||
|
||||
/**
|
||||
@@ -120,6 +136,16 @@ const PIGGY_PAGE_CAPABILITIES: Readonly<Record<PiggyPageRoute, ReadCapability>>
|
||||
'/supply': 'book:read',
|
||||
'/calendar': 'book:read',
|
||||
'/contracts': 'book:read',
|
||||
// The motion library is not the cost book. pig_get_motion_summary,
|
||||
// pig_search_motion_library and pig_get_engagement return templates,
|
||||
// artifacts and qualification scores — authored practice, carrying no
|
||||
// supplier cost and no break-even — so the classification follows what those
|
||||
// tools return rather than what the pages look like. The private/shared rule
|
||||
// is a separate matter and is enforced in the query, not here: the library
|
||||
// tool filters to `visibility = 'shared'` unconditionally.
|
||||
'/motion': 'book:read',
|
||||
'/motion/library': 'book:read',
|
||||
'/motion/engagements': 'book:read',
|
||||
};
|
||||
|
||||
const PIGGY_RECORD_CAPABILITIES: Readonly<Record<PiggyRecordType, ReadCapability>> = {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,725 @@
|
||||
/**
|
||||
* Motion — the two rules that make this a system, and the one departure that
|
||||
* makes it a risk.
|
||||
*
|
||||
* The departure first. `permissions.ts` states that every read endpoint returns
|
||||
* the whole book because no row-level filter exists anywhere in the query
|
||||
* layer; motion templates are the first exception, and an exception that is
|
||||
* only enforced by the UI is not an exception, it is a leak with a nice screen
|
||||
* in front of it. So the first suite renders the actual predicate to SQL and
|
||||
* asserts on it. It looks like a test of an implementation detail and is not:
|
||||
* the WHERE clause IS the access policy, and the two ways of getting it wrong —
|
||||
* dropping the owner comparison, or widening it with `OR owner IS NULL` — both
|
||||
* produce a query that returns rows and reports nothing.
|
||||
*
|
||||
* The rest drive the exported mutation definitions against a scripted
|
||||
* transaction, in the register of `activities.test.ts`. The fake answers
|
||||
* queries in call order and records what ran; it is deliberately not a
|
||||
* database, because a fake that pretends to run SQL is a fake that will one day
|
||||
* assert a broken query works.
|
||||
*/
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { describe, it } from 'node:test';
|
||||
import { getTableName, isSQLWrapper, isTable, type SQL } from 'drizzle-orm';
|
||||
import { PgDialect } from 'drizzle-orm/pg-core';
|
||||
import { motionScoreBasisPoints } from '@pig/core';
|
||||
import type { Database, MotionTemplate } from '@pig/db';
|
||||
import { Hono } from 'hono';
|
||||
import { AuthError, type Principal } from '../src/lib/auth';
|
||||
import { executeMutation, MutationError, type ApiEnv } from '../src/lib/mutation';
|
||||
import {
|
||||
createMotionRoutes,
|
||||
motionArtifactCreateDefinition,
|
||||
motionArtifactPromoteDefinition,
|
||||
motionEngagementCreateDefinition,
|
||||
motionEngagementUpdateDefinition,
|
||||
motionScoreDefinition,
|
||||
motionTemplateCreateDefinition,
|
||||
motionTemplatePublishDefinition,
|
||||
motionTemplateUpdateDefinition,
|
||||
motionTemplateVersionDefinition,
|
||||
} from '../src/routes/motion';
|
||||
import { motionSlug, visibleTemplates } from '../src/services/motion';
|
||||
import { onTeam, principal } from './helpers/principal';
|
||||
|
||||
const OWNER = '00000000-0000-4000-8000-000000000001';
|
||||
const OTHER = '00000000-0000-4000-8000-0000000000aa';
|
||||
const DEAL_ID = '00000000-0000-4000-8000-0000000000d1';
|
||||
const ACCOUNT_ID = '00000000-0000-4000-8000-0000000000ac';
|
||||
const ENGAGEMENT_ID = '00000000-0000-4000-8000-0000000000e1';
|
||||
const ARTIFACT_ID = '00000000-0000-4000-8000-0000000000f1';
|
||||
const TEMPLATE_ID = '00000000-0000-4000-8000-0000000000b1';
|
||||
const NOW = new Date('2026-08-17T09:00:00.000Z');
|
||||
|
||||
/** A demand member: `motion:write`, no `motion:publish`. */
|
||||
const member = principal(onTeam('demand', 'member'));
|
||||
/** A demand lead: both, which is the point of the split. */
|
||||
const lead = principal(onTeam('demand', 'lead'));
|
||||
|
||||
// --------------------------------------------------------------- the fixtures
|
||||
|
||||
function template(overrides: Partial<MotionTemplate> = {}): MotionTemplate {
|
||||
return {
|
||||
id: TEMPLATE_ID,
|
||||
kind: 'poc',
|
||||
slug: 'poc-plan',
|
||||
version: 1,
|
||||
title: 'POC plan',
|
||||
summary: 'What a proof of concept must show.',
|
||||
body: '# POC plan',
|
||||
fields: null,
|
||||
stage: 'poc',
|
||||
visibility: 'private',
|
||||
ownerUserId: OWNER,
|
||||
supersedesId: null,
|
||||
originArtifactId: null,
|
||||
isSystem: false,
|
||||
usageCount: 0,
|
||||
archivedAt: null,
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const deal = { id: DEAL_ID, accountId: ACCOUNT_ID, name: 'DEMO — Northwind training' };
|
||||
const engagement = { id: ENGAGEMENT_ID, demandDealId: DEAL_ID, status: 'open' };
|
||||
|
||||
function artifact(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: ARTIFACT_ID,
|
||||
engagementId: ENGAGEMENT_ID,
|
||||
templateId: null,
|
||||
kind: 'poc',
|
||||
stage: 'poc',
|
||||
title: 'Northwind POC plan',
|
||||
body: '# What we proved',
|
||||
fields: null,
|
||||
status: 'final',
|
||||
authoredByUserId: OWNER,
|
||||
promotedTemplateId: null,
|
||||
archivedAt: null,
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ the fake
|
||||
|
||||
interface Recorded {
|
||||
events: string[];
|
||||
inserted: { table: string; row: Record<string, unknown> }[];
|
||||
updated: { table: string; values: Record<string, unknown> }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A transaction that answers selects from a script, in call order, and records
|
||||
* every write with the table it landed in. Order-dependence is the price of not
|
||||
* pretending to be Postgres, and it is what makes "no UPDATE ran" assertable.
|
||||
*/
|
||||
function database(script: unknown[][]): { db: Database; log: Recorded } {
|
||||
const log: Recorded = { events: [], inserted: [], updated: [] };
|
||||
const results = [...script];
|
||||
const next = (): unknown[] => results.shift() ?? [];
|
||||
|
||||
const selectChain = {
|
||||
from: () => selectChain,
|
||||
innerJoin: () => selectChain,
|
||||
leftJoin: () => selectChain,
|
||||
where: () => selectChain,
|
||||
orderBy: () => selectChain,
|
||||
groupBy: () => selectChain,
|
||||
// `limit` returns the chain rather than a promise so that `.for('update')`
|
||||
// can follow it, as it does on every query in this feature that allocates
|
||||
// a version number. The chain is a thenable, so `await` still ends it.
|
||||
limit: () => selectChain,
|
||||
for: () => selectChain,
|
||||
then: (resolve: (rows: unknown[]) => unknown) => resolve(next()),
|
||||
};
|
||||
|
||||
const name = (table: unknown): string => (isTable(table) ? getTableName(table) : 'unknown');
|
||||
|
||||
const tx = {
|
||||
select: () => {
|
||||
log.events.push('select');
|
||||
return selectChain;
|
||||
},
|
||||
insert: (table: unknown) => ({
|
||||
values: (row: Record<string, unknown>) => {
|
||||
log.events.push(`insert:${name(table)}`);
|
||||
log.inserted.push({ table: name(table), row });
|
||||
const written = [{ id: `${name(table)}-${log.inserted.length}`, ...row }];
|
||||
return {
|
||||
returning: async () => written,
|
||||
then: (resolve: (value: unknown) => unknown) => resolve(undefined),
|
||||
};
|
||||
},
|
||||
}),
|
||||
update: (table: unknown) => ({
|
||||
set: (values: Record<string, unknown>) => ({
|
||||
where: () => {
|
||||
log.events.push(`update:${name(table)}`);
|
||||
log.updated.push({ table: name(table), values });
|
||||
return Object.assign(Promise.resolve(undefined), {
|
||||
returning: async () => [{ id: `${name(table)}-updated`, ...values }],
|
||||
});
|
||||
},
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
return {
|
||||
db: {
|
||||
transaction: async (work: (t: unknown) => Promise<unknown>) => {
|
||||
log.events.push('transaction');
|
||||
return work(tx);
|
||||
},
|
||||
// The same scripted chain outside a transaction, so a read handler can be
|
||||
// driven through the mounted routes rather than only its service.
|
||||
select: tx.select,
|
||||
} as unknown as Database,
|
||||
log,
|
||||
};
|
||||
}
|
||||
|
||||
/** The motion routes with a principal already resolved, as `createApp` mounts them. */
|
||||
function mounted(db: Database, who: Principal): Hono<ApiEnv> {
|
||||
const app = new Hono<ApiEnv>();
|
||||
app.use('*', async (c, next) => {
|
||||
c.set('principal', who);
|
||||
await next();
|
||||
});
|
||||
app.route('/', createMotionRoutes(db));
|
||||
return app;
|
||||
}
|
||||
|
||||
function rendered(userId: string, isPlatformAdmin = false) {
|
||||
const predicate = visibleTemplates({ userId, isPlatformAdmin });
|
||||
return predicate ? new PgDialect().sqlToQuery(predicate) : null;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- the tests
|
||||
|
||||
describe('the private library filter is a WHERE clause, not an affordance', () => {
|
||||
it('matches shared rows for everyone and private rows only against the viewer id', () => {
|
||||
const query = rendered(OWNER);
|
||||
|
||||
assert.ok(query, 'a member must be filtered at all');
|
||||
assert.match(query.sql, /"visibility" = \$1/);
|
||||
assert.match(query.sql, /"owner_user_id" = \$3/);
|
||||
assert.deepEqual(query.params, ['shared', 'private', OWNER]);
|
||||
});
|
||||
|
||||
it('never widens to `owner_user_id IS NULL` — an orphaned private draft belongs to nobody', () => {
|
||||
const query = rendered(OWNER);
|
||||
|
||||
// `ON DELETE SET NULL` on the owner column can produce a private row with
|
||||
// no owner. Matching it here would publish every departed colleague's
|
||||
// drafts to the whole workspace, and the query would look like a fix for
|
||||
// rows that had "gone missing".
|
||||
assert.ok(query);
|
||||
assert.doesNotMatch(query.sql, /owner_user_id" is null/i);
|
||||
});
|
||||
|
||||
it('is deliberately absent for a platform admin — an admin CAN read a private template', () => {
|
||||
// Not a hole. An unfiltered query here is the decision: someone has to be
|
||||
// able to answer "what is in this workspace" during an audit or a
|
||||
// departure. If this ever starts returning a predicate, that was a choice
|
||||
// somebody made, and this test is where they say so.
|
||||
assert.equal(rendered(OTHER, true), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a slug is the identity of a lineage', () => {
|
||||
it('strips the combining mark NFKD leaves behind rather than hyphenating through a word', () => {
|
||||
// `normalize('NFKD')` splits "é" into "e" plus a combining acute, and the
|
||||
// `[^a-z0-9]+` rule that follows turns that mark into a separator — so
|
||||
// without the strip, "Café strategy" starts the lineage `caf-e-strategy`
|
||||
// and the next person authoring the same title cannot find it.
|
||||
assert.equal(motionSlug('Café strategy'), 'cafe-strategy');
|
||||
assert.equal(motionSlug('Proposal Blocks'), 'proposal-blocks');
|
||||
// Nothing latin survives, and an empty slug would violate the NOT NULL.
|
||||
assert.equal(motionSlug('日本語'), 'untitled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('a used template is never edited in place', () => {
|
||||
it('refuses a PATCH once usage_count is above zero, and names the versions endpoint', async () => {
|
||||
const { db, log } = database([[template({ usageCount: 3 })]]);
|
||||
|
||||
await assert.rejects(
|
||||
executeMutation(
|
||||
db,
|
||||
principal(onTeam('demand', 'member')),
|
||||
async () => ({ body: '# Rewritten' }),
|
||||
motionTemplateUpdateDefinition(),
|
||||
{ id: TEMPLATE_ID },
|
||||
),
|
||||
(error: unknown) =>
|
||||
error instanceof MutationError &&
|
||||
error.code === 'template_in_use' &&
|
||||
error.status === 409 &&
|
||||
error.message.includes('/versions'),
|
||||
);
|
||||
assert.deepEqual(log.updated, [], 'a live engagement must not have its template move underneath it');
|
||||
});
|
||||
|
||||
it('edits freely while nobody has instantiated it — an unused template is still a draft', async () => {
|
||||
const { db, log } = database([[template({ usageCount: 0 })]]);
|
||||
|
||||
await executeMutation(
|
||||
db,
|
||||
member,
|
||||
async () => ({ title: 'POC plan, tightened' }),
|
||||
motionTemplateUpdateDefinition(),
|
||||
{ id: TEMPLATE_ID },
|
||||
);
|
||||
|
||||
assert.equal(log.updated.length, 1);
|
||||
assert.equal(log.updated[0]?.table, 'motion_templates');
|
||||
assert.equal(log.updated[0]?.values.title, 'POC plan, tightened');
|
||||
});
|
||||
|
||||
it('refuses an edit to somebody else\'s template even when it is shared — publishing is not donating', async () => {
|
||||
const { db, log } = database([[template({ visibility: 'shared', ownerUserId: OTHER })]]);
|
||||
|
||||
await assert.rejects(
|
||||
executeMutation(
|
||||
db,
|
||||
member,
|
||||
async () => ({ body: '# Mine now' }),
|
||||
motionTemplateUpdateDefinition(),
|
||||
{ id: TEMPLATE_ID },
|
||||
),
|
||||
(error: unknown) => error instanceof AuthError && error.code === 'not_owner',
|
||||
);
|
||||
assert.deepEqual(log.updated, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('publishing is a lead\'s judgement, and the owner\'s', () => {
|
||||
it('refuses a member without motion:publish before the row is read at all', async () => {
|
||||
const { db, log } = database([[template()]]);
|
||||
|
||||
await assert.rejects(
|
||||
executeMutation(db, member, async () => ({}), motionTemplatePublishDefinition(), {
|
||||
id: TEMPLATE_ID,
|
||||
}),
|
||||
(error: unknown) => error instanceof AuthError && error.code === 'insufficient_permission',
|
||||
);
|
||||
assert.deepEqual(log.events, [], 'permission precedes the transaction, so nothing was queried');
|
||||
});
|
||||
|
||||
it('refuses a lead flipping somebody else\'s private template to shared', async () => {
|
||||
const { db, log } = database([[template({ ownerUserId: OTHER })]]);
|
||||
|
||||
await assert.rejects(
|
||||
executeMutation(db, lead, async () => ({}), motionTemplatePublishDefinition(), {
|
||||
id: TEMPLATE_ID,
|
||||
}),
|
||||
(error: unknown) => error instanceof AuthError && error.code === 'not_owner',
|
||||
);
|
||||
assert.deepEqual(log.updated, []);
|
||||
});
|
||||
|
||||
it('gates creating straight into the shared library on the same capability', async () => {
|
||||
// The publish endpoint is not the only door into the shared library, so a
|
||||
// member who simply POSTs `visibility: 'shared'` must be refused too.
|
||||
const { db, log } = database([]);
|
||||
|
||||
await assert.rejects(
|
||||
executeMutation(
|
||||
db,
|
||||
member,
|
||||
async () => ({
|
||||
kind: 'poc',
|
||||
title: 'POC plan',
|
||||
summary: 'What a POC must show.',
|
||||
body: '# POC',
|
||||
stage: 'poc',
|
||||
visibility: 'shared',
|
||||
}),
|
||||
motionTemplateCreateDefinition(),
|
||||
),
|
||||
(error: unknown) => error instanceof AuthError && error.code === 'insufficient_permission',
|
||||
);
|
||||
assert.deepEqual(log.inserted, []);
|
||||
});
|
||||
|
||||
it('creates a private template owned by the author, because an unowned private row is unreadable', async () => {
|
||||
const { db, log } = database([]);
|
||||
|
||||
await executeMutation(
|
||||
db,
|
||||
member,
|
||||
async () => ({
|
||||
kind: 'poc',
|
||||
title: 'POC plan',
|
||||
summary: 'What a POC must show.',
|
||||
body: '# POC',
|
||||
stage: 'poc',
|
||||
}),
|
||||
motionTemplateCreateDefinition(),
|
||||
);
|
||||
|
||||
const written = log.inserted.find((row) => row.table === 'motion_templates');
|
||||
assert.equal(written?.row.visibility, 'private');
|
||||
assert.equal(written?.row.ownerUserId, member.userId);
|
||||
assert.equal(written?.row.version, 1);
|
||||
assert.equal(written?.row.slug, 'poc-plan');
|
||||
});
|
||||
});
|
||||
|
||||
describe('promotion is the loop', () => {
|
||||
function promote(db: Database, input: Record<string, unknown> = {}) {
|
||||
return executeMutation(
|
||||
db,
|
||||
lead,
|
||||
async () => input,
|
||||
motionArtifactPromoteDefinition(),
|
||||
{ id: ARTIFACT_ID },
|
||||
);
|
||||
}
|
||||
|
||||
it('refuses to promote an artifact that is already in the library', async () => {
|
||||
const { db, log } = database([[artifact({ promotedTemplateId: TEMPLATE_ID })]]);
|
||||
|
||||
await assert.rejects(
|
||||
promote(db),
|
||||
(error: unknown) =>
|
||||
error instanceof MutationError &&
|
||||
error.code === 'already_promoted' &&
|
||||
error.status === 409,
|
||||
);
|
||||
assert.deepEqual(log.inserted, [], 'a second promotion would fork the lineage silently');
|
||||
});
|
||||
|
||||
it('refuses to promote a draft — the library is what the next deployment copies', async () => {
|
||||
const { db, log } = database([[artifact({ status: 'draft' })]]);
|
||||
|
||||
await assert.rejects(
|
||||
promote(db),
|
||||
(error: unknown) =>
|
||||
error instanceof MutationError &&
|
||||
error.code === 'artifact_not_final' &&
|
||||
error.status === 409,
|
||||
);
|
||||
assert.deepEqual(log.inserted, []);
|
||||
});
|
||||
|
||||
it('writes a shared version pointing back at the artifact that proved it', async () => {
|
||||
const { db, log } = database([[artifact()], [{ engagement, deal }], []]);
|
||||
|
||||
const result = (await promote(db, { slug: 'poc-plan' })) as {
|
||||
template: { version: number; visibility: string; originArtifactId: string };
|
||||
};
|
||||
|
||||
assert.equal(result.template.version, 1);
|
||||
assert.equal(result.template.visibility, 'shared');
|
||||
assert.equal(result.template.originArtifactId, ARTIFACT_ID);
|
||||
assert.equal(
|
||||
log.updated.find((row) => row.table === 'engagement_artifacts')?.values.promotedTemplateId,
|
||||
'motion_templates-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('chains supersedes_id across three promotions of one lineage', async () => {
|
||||
let previous: { id: string; version: number } | null = null;
|
||||
const chain: { id: string; version: number; supersedesId: string | null }[] = [];
|
||||
|
||||
for (let round = 0; round < 3; round += 1) {
|
||||
// The lineage is read twice and on purpose: once unfiltered for the
|
||||
// version number, once through the visibility filter for the row the new
|
||||
// version may claim to supersede.
|
||||
const lineage = previous ? [{ ...template(), ...previous, slug: 'poc-plan' }] : [];
|
||||
const { db } = database([
|
||||
[artifact({ id: `${ARTIFACT_ID}-${round}` })],
|
||||
[{ engagement, deal }],
|
||||
lineage,
|
||||
lineage,
|
||||
]);
|
||||
|
||||
const result = (await promote(db, { slug: 'poc-plan' })) as {
|
||||
template: { id: string; version: number; supersedesId: string | null };
|
||||
};
|
||||
chain.push({
|
||||
id: result.template.id,
|
||||
version: result.template.version,
|
||||
supersedesId: result.template.supersedesId,
|
||||
});
|
||||
previous = { id: result.template.id, version: result.template.version };
|
||||
}
|
||||
|
||||
assert.deepEqual(
|
||||
chain.map((row) => row.version),
|
||||
[1, 2, 3],
|
||||
);
|
||||
assert.equal(chain[0]?.supersedesId, null, 'the first version supersedes nothing');
|
||||
assert.equal(chain[1]?.supersedesId, chain[0]?.id);
|
||||
assert.equal(chain[2]?.supersedesId, chain[1]?.id);
|
||||
});
|
||||
|
||||
it('lands the audit row on the account of the deal that proved it', async () => {
|
||||
const { db, log } = database([[artifact()], [{ engagement, deal }], []]);
|
||||
|
||||
await promote(db, { slug: 'poc-plan' });
|
||||
|
||||
const activity = log.inserted.find((row) => row.table === 'activities')?.row;
|
||||
assert.equal(activity?.accountId, ACCOUNT_ID);
|
||||
assert.equal(activity?.demandDealId, DEAL_ID);
|
||||
assert.match(String(activity?.subject), /^Promoted to the library:/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a lineage nobody can read never supplies content to one everybody can', () => {
|
||||
/**
|
||||
* The version number and the predecessor row are two different questions, and
|
||||
* answering both from one unfiltered query is the shape of the bug these pin.
|
||||
* Both suites script a lineage whose newest row is private to somebody else:
|
||||
* the unfiltered read finds v4, the filtered read finds nothing.
|
||||
*/
|
||||
const PRIVATE_SUMMARY = 'Unreleased pricing: 40% floor, Northwind only.';
|
||||
/** Alice's v4, which the unfiltered read finds and the filtered read must not. */
|
||||
const hidden = () =>
|
||||
template({
|
||||
id: '00000000-0000-4000-8000-0000000000c4',
|
||||
version: 4,
|
||||
ownerUserId: OTHER,
|
||||
summary: PRIVATE_SUMMARY,
|
||||
});
|
||||
|
||||
it('promotes past a private newest version without copying its summary or naming its id', async () => {
|
||||
const { db, log } = database([
|
||||
[artifact()],
|
||||
[{ engagement, deal }],
|
||||
[hidden()],
|
||||
[],
|
||||
]);
|
||||
|
||||
await executeMutation(db, lead, async () => ({ slug: 'poc-plan' }), motionArtifactPromoteDefinition(), {
|
||||
id: ARTIFACT_ID,
|
||||
});
|
||||
|
||||
const written = log.inserted.find((row) => row.table === 'motion_templates')?.row;
|
||||
// v5, because the unique constraint is on the whole lineage and a private
|
||||
// fork still consumes a number.
|
||||
assert.equal(written?.version, 5);
|
||||
// But nothing else from that row. The summary fell back to the artifact's
|
||||
// own title, and the new version supersedes nothing it cannot show.
|
||||
assert.notEqual(written?.summary, PRIVATE_SUMMARY);
|
||||
assert.equal(written?.summary, artifact().title);
|
||||
assert.equal(written?.supersedesId, null);
|
||||
});
|
||||
|
||||
it('forks a shared template without disclosing that private versions of it exist', async () => {
|
||||
const source = template({ id: TEMPLATE_ID, visibility: 'shared', ownerUserId: OTHER });
|
||||
const { db, log } = database([[source], [hidden()], []]);
|
||||
|
||||
await executeMutation(db, member, async () => ({}), motionTemplateVersionDefinition(), {
|
||||
id: TEMPLATE_ID,
|
||||
});
|
||||
|
||||
const written = log.inserted.find((row) => row.table === 'motion_templates')?.row;
|
||||
assert.equal(written?.version, 5, 'the number is allocated against the whole lineage');
|
||||
assert.equal(
|
||||
written?.supersedesId,
|
||||
source.id,
|
||||
'the edge points at the row that was actually forked, not at a private row the forker cannot fetch',
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses to promote an artifact whose source template is unreadable, rather than 404ing on the artifact', async () => {
|
||||
const { db, log } = database([
|
||||
[artifact({ templateId: TEMPLATE_ID })],
|
||||
[{ engagement, deal }],
|
||||
[],
|
||||
]);
|
||||
|
||||
await assert.rejects(
|
||||
executeMutation(db, lead, async () => ({}), motionArtifactPromoteDefinition(), {
|
||||
id: ARTIFACT_ID,
|
||||
}),
|
||||
(error: unknown) =>
|
||||
error instanceof MutationError &&
|
||||
error.code === 'source_template_unreadable' &&
|
||||
error.status === 409 &&
|
||||
error.message.includes('slug'),
|
||||
);
|
||||
assert.deepEqual(log.inserted, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a template id somebody sent is read before it is stored', () => {
|
||||
it('refuses a playbook the setter cannot read, so a private id cannot go book-wide on an engagement', async () => {
|
||||
const { db, log } = database([[{ engagement, deal }], []]);
|
||||
|
||||
await assert.rejects(
|
||||
executeMutation(
|
||||
db,
|
||||
member,
|
||||
async () => ({ playbookTemplateId: TEMPLATE_ID }),
|
||||
motionEngagementUpdateDefinition(),
|
||||
{ id: ENGAGEMENT_ID },
|
||||
),
|
||||
(error: unknown) => error instanceof MutationError && error.status === 404,
|
||||
);
|
||||
assert.deepEqual(log.updated, [], 'an unreadable id must not reach the foreign key either');
|
||||
});
|
||||
});
|
||||
|
||||
describe('a UI hint that disagrees with its endpoint is the hint that is wrong', () => {
|
||||
it('does not offer publish to a read-only key, which the endpoint would refuse for scope', async () => {
|
||||
const readOnlyLead = principal({ ...onTeam('demand', 'lead'), via: 'api_key', scopes: ['read'] });
|
||||
const mine = template({ ownerUserId: readOnlyLead.userId });
|
||||
const { db } = database([[mine], [mine]]);
|
||||
|
||||
const response = await mounted(db, readOnlyLead).request(`/api/motion/templates/${TEMPLATE_ID}`);
|
||||
const body = (await response.json()) as { canPublish: boolean };
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(
|
||||
body.canPublish,
|
||||
false,
|
||||
'the button would 403 insufficient_scope, and a button that cannot work must not be offered',
|
||||
);
|
||||
});
|
||||
|
||||
it('still offers it to the same lead on a full-scope session', async () => {
|
||||
const mine = template({ ownerUserId: lead.userId });
|
||||
const { db } = database([[mine], [mine]]);
|
||||
|
||||
const response = await mounted(db, lead).request(`/api/motion/templates/${TEMPLATE_ID}`);
|
||||
const body = (await response.json()) as { canPublish: boolean };
|
||||
|
||||
assert.equal(body.canPublish, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a slug collision is the caller\'s to fix, not a 500', () => {
|
||||
it('answers 409 naming the versions endpoint when the title derives a slug already in use', async () => {
|
||||
// "Proposal Blocks" is one of the nine shipped starter templates, so this
|
||||
// is the first thing a new author trips over rather than an edge case.
|
||||
const { db, log } = database([[{ version: 1 }]]);
|
||||
|
||||
await assert.rejects(
|
||||
executeMutation(
|
||||
db,
|
||||
member,
|
||||
async () => ({
|
||||
kind: 'proposal',
|
||||
title: 'Proposal Blocks',
|
||||
summary: 'Reusable proposal language.',
|
||||
body: '# Blocks',
|
||||
stage: 'proposal',
|
||||
}),
|
||||
motionTemplateCreateDefinition(),
|
||||
),
|
||||
(error: unknown) =>
|
||||
error instanceof MutationError &&
|
||||
error.code === 'template_slug_exists' &&
|
||||
error.status === 409 &&
|
||||
error.message.includes('/versions'),
|
||||
);
|
||||
assert.deepEqual(log.inserted, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('instantiating a template', () => {
|
||||
it('increments usage_count in the same transaction that writes the artifact', async () => {
|
||||
const { db, log } = database([
|
||||
[{ engagement, deal }],
|
||||
[template({ usageCount: 2, visibility: 'shared', ownerUserId: OTHER })],
|
||||
]);
|
||||
|
||||
await executeMutation(
|
||||
db,
|
||||
member,
|
||||
async () => ({ templateId: TEMPLATE_ID }),
|
||||
motionArtifactCreateDefinition(),
|
||||
{ id: ENGAGEMENT_ID },
|
||||
);
|
||||
|
||||
// One transaction, both writes: a count that could be committed without the
|
||||
// artifact would close the template to edits for a use that never happened.
|
||||
assert.equal(log.updated[0]?.table, 'motion_templates');
|
||||
// And the count is incremented by the database, not by JS arithmetic on the
|
||||
// row that was read a moment ago. Two people instantiating one template
|
||||
// under READ COMMITTED both read 2 and both write 3, so a use is lost — and
|
||||
// `usage_count` is the only thing holding §7a shut. Asserting the rendered
|
||||
// SQL is what makes the difference between the two visible at all: the
|
||||
// wrong version writes the literal 3 and passes every behavioural test.
|
||||
const increment = log.updated[0]?.values.usageCount;
|
||||
assert.ok(
|
||||
isSQLWrapper(increment),
|
||||
'usage_count must be written as an expression, never as a number computed in JS',
|
||||
);
|
||||
assert.match(new PgDialect().sqlToQuery(increment as SQL).sql, /"usage_count" \+ \$?1/);
|
||||
assert.equal(log.inserted[0]?.table, 'engagement_artifacts');
|
||||
assert.equal(log.inserted[0]?.row.templateId, TEMPLATE_ID);
|
||||
// The body is copied, not referenced — editing the artifact must not reach
|
||||
// back into the library.
|
||||
assert.equal(log.inserted[0]?.row.body, template().body);
|
||||
});
|
||||
});
|
||||
|
||||
describe('one engagement per deal', () => {
|
||||
it('answers 409 with the id of the engagement that already exists', async () => {
|
||||
const { db, log } = database([[deal], [engagement]]);
|
||||
|
||||
await assert.rejects(
|
||||
executeMutation(
|
||||
db,
|
||||
member,
|
||||
async () => ({ demandDealId: DEAL_ID }),
|
||||
motionEngagementCreateDefinition(),
|
||||
),
|
||||
(error: unknown) =>
|
||||
error instanceof MutationError &&
|
||||
error.code === 'engagement_exists' &&
|
||||
error.status === 409 &&
|
||||
error.message.includes(ENGAGEMENT_ID),
|
||||
);
|
||||
assert.deepEqual(log.inserted, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('qualification scores', () => {
|
||||
it('computes the score from the dimensions rather than believing the body', async () => {
|
||||
const { db, log } = database([[{ engagement, deal }]]);
|
||||
const dimensions = [
|
||||
{ id: 'budget', weight: 30, score: 4 },
|
||||
{ id: 'urgency', weight: 20, score: 2 },
|
||||
{ id: 'fit', weight: 50, score: 3 },
|
||||
];
|
||||
|
||||
await executeMutation(
|
||||
db,
|
||||
member,
|
||||
async () => ({ dimensions }),
|
||||
motionScoreDefinition(),
|
||||
{ id: ENGAGEMENT_ID },
|
||||
);
|
||||
|
||||
const written = log.inserted.find((row) => row.table === 'qualification_scores')?.row;
|
||||
assert.equal(written?.basisPoints, motionScoreBasisPoints(dimensions));
|
||||
assert.equal(written?.band, 'Strategic');
|
||||
assert.equal(typeof written?.basisPoints, 'number');
|
||||
assert.ok(Number.isInteger(written?.basisPoints), 'a score is an integer, exactly as money is');
|
||||
});
|
||||
|
||||
it('refuses a posted basisPoints outright — a score somebody can send is a score somebody can fix', async () => {
|
||||
const { db } = database([[{ engagement, deal }]]);
|
||||
|
||||
await assert.rejects(
|
||||
executeMutation(
|
||||
db,
|
||||
member,
|
||||
async () => ({ dimensions: [{ id: 'fit', weight: 1, score: 1 }], basisPoints: 10_000 }),
|
||||
motionScoreDefinition(),
|
||||
{ id: ENGAGEMENT_ID },
|
||||
),
|
||||
(error: unknown) => error instanceof MutationError && error.code === 'invalid_request',
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user