/** * lib/page-spec-actions.ts — Canonical Zod schema for custom page actions. * * SINGLE SOURCE OF TRUTH for the contract `screen.md` → `pagespec.actions[]` → * scaffold-controller / scaffold-business / scaffold-api-client / scaffold-component. * * The CLI deterministic generators already accept their own ad-hoc shapes * (`ControllerCustomActionSchema`, `BusinessCustomActionSchema`, * `ApiCustomActionSchema`, `PageActionMinSchema`). This module unifies the * upstream contract: ba-create-prd writes a `PageCustomAction[]` into every * pagespec, ba-develop derives each generator's input from it, and the dev * audits cross-check the generated code against the same schema. * * The fundamental invariant: * pageSpec.action.endpoint === [HttpVerb("")] route attribute * === apiClient.('/api/.../') * * The same string is propagated VERBATIM in both directions. Frontend and * backend can never drift on a name, because there is only one name. * * @see business-analyse/create-prd/SKILL.md (propagates screen.md → pagespec) * @see ba-develop/SKILL.md (derives the generator inputs) * @see development/backend/controller/cli/scaffold-controller/types.ts (downstream consumer) * @see development/frontend/component/cli/scaffold-component/types.ts (downstream consumer) * @see development/audit-dev-api/SKILL.md (DEV-API-010/011 — pageSpec ↔ controller) * @see development/audit-dev-frontend/SKILL.md (DEV-UI-019/020/021 — pageSpec ↔ service.ts) */ import { z } from 'zod' import { toKebabCase, toCamelCase, toPascalCase } from './string-utils.js' import { KANBAN_MOVE_ACTION_CODE, type KanbanMoveContext } from './page-spec-kanban.js' export { moveMatrixOf, type KanbanMoveContext } from './page-spec-kanban.js' /** * Action codes emitted by the canonical CRUD paths of every scaffolder. * Filters here are applied BEFORE iterating customActions so we never emit * a duplicate POST for an action already handled by the standard generator. * * Examples: * - `create` (header) → `POST /` from scaffold-controller's actions[] enum * - `edit` (row) → navigate(routes..edit(item.id)) from scaffold-component * - `delete` (row) → `DELETE /{id:guid}` from scaffold-controller */ export const STANDARD_CRUD_CODES = new Set([ 'create', 'read', 'list', 'detail', 'edit', 'update', 'delete', ]) /** * Compute the default URL segment for an action code. * The pageSpec author can override by setting `endpoint` explicitly — useful * when the business code (e.g. `syncFromPce`) maps to a legacy backend route * (e.g. `sync-from-proconcept`). * * defaultEndpoint('syncFromPce') === 'sync-from-pce' * defaultEndpoint('archive') === 'archive' * defaultEndpoint('analyzeImpact') === 'analyze-impact' */ export function defaultEndpoint(code: string): string { return toKebabCase(code) } /** * Derive the C# controller method name from a pageSpec endpoint. * Scaffold-controller appends `Async`, so this helper returns the bare PascalCase * — the suffix is the generator's responsibility. * * controllerMethodNameFromEndpoint('sync-from-proconcept') === 'SyncFromProconcept' * controllerMethodNameFromEndpoint('archive') === 'Archive' */ export function controllerMethodNameFromEndpoint(endpoint: string): string { return toPascalCase(endpoint) } /** * Derive the TypeScript service-method name from a pageSpec endpoint. * Used by scaffold-api-client when emitting `Service.ts` members. * * serviceMethodNameFromEndpoint('sync-from-proconcept') === 'syncFromProconcept' * serviceMethodNameFromEndpoint('archive') === 'archive' */ export function serviceMethodNameFromEndpoint(endpoint: string): string { return toCamelCase(endpoint) } /** * Optional state-machine metadata. Populated by ba-create-prd from any * linked business rule of type `workflow` whose flow lists a status * transition. Consumed by scaffold-business to emit the guard * (`if (entity.Status not in fromStatus) throw …`) and the assignment * (`entity.Status = toStatus`). */ export const WorkflowTransitionSchema = z.object({ /** Allowed source statuses (raw values from the Status enum/lookup). */ fromStatus: z.array(z.string().min(1)).min(1), /** Target status (raw value). */ toStatus: z.string().min(1), /** Names of parameters the use case requires on the request body. */ flowParameters: z.array(z.string().min(1)).default([]), }) export type WorkflowTransition = z.infer /** * One custom page action — the full vertical-slice contract. * * `kind: "api"` actions generate a backend endpoint + a service method + a * React Query hook + a button. `kind: "navigate"` actions generate ONLY a * button that calls `navigate(targetRoute)` — no API call, no hook, no axios * (this is the fix for the legacy pattern `POST /{id}/open` → 405). */ export const PageCustomActionSchema = z.object({ /** * Business identifier in camelCase. Drives the human-facing label key and * the React Query hook name (`use${Entity}`). MAY differ * from `endpoint` when the BA wants a clean UI label but the backend uses * a legacy route name (e.g. code='syncFromPce', endpoint='sync-from-proconcept'). * * Accepted: camelCase or single-word lowercase. We allow letters & digits but * not dashes to keep TypeScript identifiers valid. */ code: z .string() .min(1) .regex(/^[a-z][a-zA-Z0-9]*$/, 'code must be camelCase or a single lowercase word'), /** * Distinguishes API-bound actions from pure navigations. * - `api` → emits backend endpoint + service method + React Query hook + button * - `navigate` → emits ONLY a button that calls `navigate(targetRoute)` * * Default `api` for legacy pagespecs that omit the field. */ kind: z.enum(['api', 'navigate']).default('api'), /** Where the button renders. */ scope: z.enum(['row', 'bulk', 'header']), /** * URL segment, verbatim. Used identically on both sides: * - backend → [HttpVerb("")] * - frontend → apiClient.('/api/.../') * * Optional in the schema — when missing for `kind:api`, ba-create-prd * fills it with `defaultEndpoint(code)`. The audits enforce presence after * normalisation. */ endpoint: z .string() .min(1) .regex(/^[a-z][a-z0-9-]*$/, 'endpoint must be kebab-case') .optional(), /** HTTP verb. Defaults POST (state mutations). GET for compute / read-only * endpoints (analyze-impact). */ httpMethod: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).default('POST'), /** * C# DTO type names. null when there is no body / no response payload * (state-mutation endpoints like archive that return 204). */ payloadDto: z.string().nullable().optional(), responseDto: z.string().nullable().default(null), /** * TypeScript counterparts. When omitted, scaffold-api-client falls back to * `payloadDto` / `responseDto` (they are usually identical PascalCase names * shared with the backend via codegen). */ payloadType: z.string().nullable().optional(), responseType: z.string().nullable().default(null), /** * For `kind:navigate`: the screen the button leads to. ba-create-prd * resolves it into a concrete `targetRoute` string by looking it up in the * screen registry. One of `targetScreen` or `targetRoute` MUST be set. */ targetScreen: z .string() .regex(/^SCR-[A-Za-z0-9_-]+$/, 'targetScreen must be SCR-...') .optional(), /** * For `kind:navigate`: the resolved route helper expression, e.g. * `routes.referentiels.typesAffaire.detail(item.id)`. scaffold-component * inlines this verbatim inside `onClick={() => navigate()}`. */ targetRoute: z.string().min(1).optional(), /** i18n key for the button label. */ labelKey: z.string().min(1), /** * Permission constant: `.
.` (3 segments) or the * resource grain `.
..` (4 segments) — * app-less, the app prefix is added at the controller boundary. Same * grammar as lib/page-spec-related-tabs.ts and scaffold-controller's * permissionPrefix. Drives the PermissionGuard wrapping in * scaffold-component AND the permissionAction segment passed to * scaffold-controller / scaffold-business. * * BINDING invariant (kind:api): the generators compile the constant from * the SPEC's own module/section (`{Mod}Permissions.{Section}.{Action}`) — * only the ACTION segment of this string survives projection. A permission * authored on another module/section, or at the resource grain, would be * SILENTLY REBOUND to a different (or wider) permission than specified — * `actionPermissionBindingIssue` is the guard; derive-action-specs rejects * the action (BLOCKING) instead of letting the rebind ship. */ permission: z .string() .min(1) .regex( /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*){2,3}$/, 'permission must be `.
[.].` kebab-case (3-4 segments)', ), /** Visual variant. Defaults `secondary` for custom actions, `primary` for the * first header action (create), `danger` for destructive (delete). */ variant: z.enum(['primary', 'secondary', 'danger']).optional(), /** UC-... back-reference — REQUIRED for kind:api (enforced by the * superRefine below): the stub's TODO[UC-…] anchor is what DEV-API-009 * verifies, and derive-uc-coverage counts it as the action's UC surface. * The field stays `.optional()` at the field level because kind:navigate * legitimately has none. Authoring channel: `UC: UC-…` on the screen.md * action line (create-screen grammar), copied by create-prd. */ ucReference: z .string() .regex(/^UC-[A-Za-z0-9_-]+$/, 'ucReference must be UC-...') .optional(), /** BR-... guards to enforce before performing the action. */ guardRules: z.array(z.string().regex(/^BR-[A-Z0-9_-]+$/)).default([]), /** Optional state-machine transition. Populated by ba-create-prd from a * linked workflow rule, consumed by scaffold-business. */ workflowTransition: WorkflowTransitionSchema.optional(), /** Module names this action depends on (entities not yet in prd.entities.md). * scaffold-business emits a `// BLOCKED[CROSS-MODULE: …]` TODO so audit-dev * can flag the dependency. */ crossModuleDeps: z.array(z.string().min(1)).default([]), /** * Field-level parameters the action collects from the user BEFORE firing. When * present (kind:api only), scaffold-component renders a `` * that gathers them and passes the assembled payload to the mutation — instead of * a no-op button. e.g. `transferer`/`fusionner` collect a target (`type:lookup`). * Empty/omitted → the button fires the mutation directly (the legacy behaviour). * The payload keys are the parameter `name`s. * * ⚠ `type:file` is NOT wired end-to-end — do NOT author it. The dialog renders * a file input, but scaffold-api-client posts the payload as JSON (a `File` * serializes to `{}`), the scaffolded controller binds `[FromBody]` JSON, and * run-smoke skips the param (audit PRD-107 flags authored ones). Uploads use * the attachments pattern instead — metadata entity + dedicated multipart * endpoints (`development/backend/data-layer/references/file-storage.md`). * The enum keeps `'file'` for backward-compat with existing pagespecs until * the real multipart path ships (backlog — attachments scaffold family). */ payloadParameters: z .array( z.object({ /** camelCase payload key sent to the backend (e.g. `targetId`, `file`). */ name: z.string().min(1).regex(/^[a-z][a-zA-Z0-9]*$/, 'parameter name must be camelCase'), /** Input control rendered for this parameter. */ type: z.enum(['text', 'textarea', 'number', 'date', 'lookup', 'file', 'select']), /** * i18n key for the field label. Default: the SIBLING-branch key derived * from the action's labelKey — `list.actions.x` → `list.actionParams.x.{name}` * (select options: `list.actionParamOptions.x.{name}.{value}`). Never * nested UNDER the button-label leaf: `{labelKey}.params.{name}` cannot * coexist with the label in a nested JSON (leaf wins) — legacy keys in * that shape are remapped by scaffold-component. */ labelKey: z.string().optional(), required: z.boolean().default(false), /** * The ENTITY attribute (camelCase pagespec field key) this parameter * writes — the lifecycle action↔field binding (a `capturedBy` phase's * fields are captured through these parameters). `name` stays the wire * key / DTO member (they usually coincide); `field` never crosses the * wire. Effects: scaffold-business assigns * `entity. = payload.` (fieldAssignments, * replacing the name-identity-only `flowParameters` path for bound * params), the dialog label defaults to the SHARED `form.fields.` * key instead of the `actionParams.*` branch (one label for form, * detail AND dialog), and PRD-120(f) verifies the field exists on the * pagespec and is owned by the capturing phase. */ field: z.string().regex(/^[a-z][a-zA-Z0-9]*$/, 'field must be a camelCase entity field key').optional(), /** For `type:lookup` — FK target entity + module (drives the EntityLookup endpoint). */ entity: z.string().optional(), module: z.string().optional(), /** * Explicit lookup endpoint for a `type: "lookup"` parameter — set by * derive-action-specs from lib/core-catalog.coreLookupEndpointFor for * Core targets, so the dialog's hits the SAME route the * FK channel uses. Without it, a Core param whose `module` was omitted * fell back to the PAGE's module: the list filter called * /api/core/offices/lookup while the « transférer » action invented * /api/parc/offices/lookup — two routes for one catalogue. */ apiEndpoint: z.string().optional(), /** * Resolved navRoute of the lookup TARGET entity (`module.section` or * `module.routeParent.routeFamily` for a satellite) — spliced by the * orchestrator from derive-action-specs' `dialogLookupParams` (§28). * scaffold-component derives the endpoint as * `buildNavApiPath(navRoute)/lookup` — the SAME `[NavRoute]`-mirroring * derivation as API_PATH, never a `{module}/{english-plural}` guess * (9/9 literals wrong on one project, every dialog combobox on a 404). */ navRoute: z.string().optional(), /** For `type:file` — accept filter (e.g. `.pdf,.docx`). */ accept: z.string().optional(), /** For `type:select` — static option set. */ options: z.array(z.object({ value: z.string(), labelKey: z.string().optional() })).optional(), }), ) .optional(), }).superRefine((action, ctx) => { if (action.kind === 'navigate') { if (!action.targetRoute && !action.targetScreen) { ctx.addIssue({ code: 'custom', path: ['targetRoute'], message: 'navigate action requires targetRoute or targetScreen', }) } if (action.endpoint) { ctx.addIssue({ code: 'custom', path: ['endpoint'], message: 'navigate action must NOT carry endpoint — use targetRoute/targetScreen', }) } if (action.payloadDto || action.payloadType) { ctx.addIssue({ code: 'custom', path: ['payloadDto'], message: 'navigate action must NOT carry payloadDto/payloadType', }) } if (action.payloadParameters && action.payloadParameters.length > 0) { ctx.addIssue({ code: 'custom', path: ['payloadParameters'], message: 'navigate action must NOT carry payloadParameters — it collects no input', }) } if (action.workflowTransition) { ctx.addIssue({ code: 'custom', path: ['workflowTransition'], message: 'navigate action must NOT carry workflowTransition', }) } } else { // kind === 'api' if (action.targetScreen || action.targetRoute) { ctx.addIssue({ code: 'custom', path: ['kind'], message: 'api action must NOT carry targetScreen/targetRoute — set kind:navigate instead', }) } // The comment above `ucReference` said « mandatory for kind:api » since // day one while the schema said `.optional()`: an api action authored // without its UC anchor lost the TODO[UC-…] trace SILENTLY — DEV-API-009 // then had nothing to verify and derive-uc-coverage nothing to count. // Standard CRUD codes are exempt: pagespecs list create/edit/delete as // kind:api too, the scaffolders own them and no UC anchors them // (derive-action-specs parses BEFORE its customActionsOnly filter, so a // CRUD requirement here would block every real pagespec). Legacy CUSTOM // actions surface through derive-action-specs' BLOCKING `rejected[]` // channel — the sanctioned migration path is authoring `UC: UC-…` on the // screen.md action and re-running /ba-create-prd. if (!action.ucReference && !STANDARD_CRUD_CODES.has(action.code)) { ctx.addIssue({ code: 'custom', path: ['ucReference'], message: 'api action requires ucReference (UC-…) — it is the TODO[UC-…] implementation anchor ' + 'DEV-API-009 verifies and the UC-coverage surface SCR-024/PRD-131 count. Author ' + '`UC: UC-…` on the screen.md action line, then re-run /ba-create-prd.', }) } // A GET is a READ — it MUST declare what it returns. The permissive era // generated `[HttpGet] → NoContent()` measures (driver-at, forecast, // history): the service computed the answer and threw it away (`_ = await // …`), costing ~20 acceptance criteria on one client project. const responseless = (action.responseDto === null || action.responseDto === 'NoContent') && (action.responseType === undefined || action.responseType === null || action.responseType === 'void') if (action.httpMethod === 'GET' && responseless) { ctx.addIssue({ code: 'custom', path: ['responseDto'], message: 'a GET action is a READ and MUST declare its response contract — set responseDto ' + '(e.g. "{Code}ResultDto") so the controller returns ActionResult and the service ' + 'Task, or requalify the action as a POST state mutation. A [HttpGet] answering ' + '204 computes a value nobody can read (PRD-124 / DEV-API-025).', }) } } }) /** * Verbs that TARGET ONE INSTANCE by nature (freeze/close/approve/…). A * header-scoped action named with one of these but collecting no instance * reference is the « freeze gèle tout » smell: the client's LifecycleSummary * `freeze` froze every complete record at once because nothing designated ONE. * SCR-023 / PRD-126 read this list — one definition, adjustable in one place. */ export const INSTANCE_STATE_VERBS: readonly string[] = [ 'freeze', 'geler', 'close', 'cloturer', 'clôturer', 'validate', 'valider', 'approve', 'approuver', 'reject', 'rejeter', 'refuser', 'suspend', 'suspendre', 'archive', 'archiver', 'terminate', 'terminer', 'resilier', 'résilier', ] export type PageCustomAction = z.infer /** * Normalise a `PageCustomAction` after validation: fill missing defaults so * downstream consumers (ba-develop, audits) can rely on a stable shape. * * - `kind:api` without `endpoint` → `defaultEndpoint(code)` * - `kind:api` without `payloadType`/`responseType` → mirror the C# DTO names */ export function normalizePageCustomAction(action: PageCustomAction): PageCustomAction { if (action.kind === 'navigate') return action const endpoint = action.endpoint ?? defaultEndpoint(action.code) return { ...action, endpoint, payloadType: action.payloadType ?? action.payloadDto ?? null, responseType: action.responseType ?? action.responseDto ?? null, } } /** * Extract the permission's last segment, which maps to scaffold-controller's * `permissionAction` field (it joins it with the module/section to build * `Permissions.
.`). * * lastPermissionSegment('referentiels.types-affaire.execute') === 'execute' */ export function lastPermissionSegment(permission: string): string { const parts = permission.split('.') return parts[parts.length - 1] ?? '' } /** * Build the expected URL path matched against axios calls in audit-dev-frontend * and against `[HttpVerb("…")]` attributes in audit-dev-api. * * expectedUrlPath('header', 'sync-from-pce') → '/sync-from-pce' * expectedUrlPath('row', 'archive') → '/{id}/archive' * expectedUrlPath('bulk', 'export') → '/bulk/export' * * The returned path is RELATIVE to the controller mount point * (`/api//`). Audits concatenate the prefix when * comparing against full URLs. */ export function expectedUrlPath( scope: PageCustomAction['scope'], endpoint: string, ): string { switch (scope) { case 'row': return `/{id}/${endpoint}` case 'bulk': return `/bulk/${endpoint}` case 'header': return `/${endpoint}` } } /** * Same as `expectedUrlPath` but uses the C# route-attribute syntax with the * `{id:guid}` constraint that scaffold-controller emits. Audit-dev-api uses * this form when checking `[HttpVerb("…")]` strings. */ export function expectedControllerRoute( scope: PageCustomAction['scope'], endpoint: string, ): string { switch (scope) { case 'row': return `{id:guid}/${endpoint}` case 'bulk': return `bulk/${endpoint}` case 'header': return endpoint } } /** * Filter a list of pagespec actions down to the custom (non-CRUD) ones — * used by every downstream consumer that derives a specific scaffolder input. */ export function customActionsOnly(actions: T[]): T[] { return actions.filter(a => !STANDARD_CRUD_CODES.has(a.code)) } // ─────────────────────────────────────────────────────────────────────────── // Generator projections — the SINGLE place that maps a canonical // `PageCustomAction` onto each generator's ad-hoc custom-action input shape. // // Before this layer, ba-develop's orchestrator (an LLM subagent) hand-derived // three subtly-different shapes from the pagespec prose in `phases-detail.md`. // The conventions diverge per generator (kebab `code`, UPPER vs lower // `httpMethod`, `payloadDto` vs `payloadType`, flattened workflow…), so the // hand-derivation silently dropped actions. These functions encode every // mapping ONCE; the `derive-action-specs` CLI is a thin I/O wrapper over them, // and the dev audits compute their "expected" names through the same helpers — // so generated code and audit can never drift. // // The return shapes intentionally MIRROR (structurally) the generator Zod // schemas (`ControllerCustomActionSchema`, `BusinessCustomActionSchema`, // `ApiCustomActionSchema`). lib must not depend on `development/*`, so the // shapes are re-declared here as interfaces; `__tests__/page-spec-actions.test.ts` // imports the REAL generator schemas and `.parse()`s every projection — that // round-trip test is what guarantees this layer stays byte-compatible. // ─────────────────────────────────────────────────────────────────────────── /** Mirrors `scaffold-controller` `ControllerCustomActionSchema`. */ export interface ControllerActionInput { code: string scope: PageCustomAction['scope'] httpMethod: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' payloadDto: string | null /** True when the payload record has ≥1 REQUIRED member. The controller then * binds a MANDATORY `[FromBody]` (model binding 400s on a missing body) * instead of `EmptyBodyBehavior.Allow` + `dto ?? new()` — a record with a * required positional member has no parameterless ctor, so `new()` is * CS7036 (client defect 2026-08-25 #3). */ payloadHasRequired?: boolean /** GET transport of `payloadParameters` (no body on GETs): each entry becomes * a nullable `[FromQuery]` parameter, mirrored into the business service * signature — without this a GET action's parameters had NO transport at * all (client defect 2026-08-25 #4). */ queryParameters?: { name: string; type: string }[] responseDto: string permissionAction: string } /** Mirrors `scaffold-business` `BusinessCustomActionSchema`. `fromStatus` is an * array (multi-source-status guard); `toStatus` stays a single target. */ export interface BusinessActionInput { code: string scope: PageCustomAction['scope'] payloadDto: string | null /** Payload field specs (camelCase wire name / dialog control type / required) * mirrored from the action's `payloadParameters`. scaffold-business EMITS the * payload DTO record from them — nothing else in the chain does, so without * this the Command/Service would reference a type that exists nowhere. */ payloadFields?: { name: string; type: string; required: boolean }[] /** GET transport of `payloadParameters` — scalar nullable service/Command * parameters matching the controller's `[FromQuery]` set (a GET carries no * body, so `payloadDto`/`payloadFields` stay null on GET actions). */ queryParameters?: { name: string; type: string }[] responseDto: string ucReference?: string fromStatus?: string[] toStatus?: string guardRules?: string[] flowParameters?: string[] /** Explicit entity-attribute assignments ({param: wire name, field: entity * attribute}) derived from `payloadParameters[].field` — scaffold-business * emits `entity. = payload.` per entry and * skips any `flowParameters` name already covered (the legacy * identity-mapped path stays for unbound params). */ fieldAssignments?: { param: string; field: string }[] crossModuleDeps?: string[] /** Kanban move-action ONLY: the FULL from→to matrix (lib/page-spec-kanban * `moveMatrixOf`) — the dynamic sibling of `workflowTransition` (which * carries ONE edge). scaffold-business compiles it into the dictionary * guard `if (!allowed[entity.].Contains(target)) throw`. */ transitionMatrix?: { from: string; to: string; rule?: string }[] /** PascalCase entity property the matrix guard reads/assigns (`Status`). */ statusProperty?: string /** Business error code of the refused-transition throw. */ statusErrorCode?: string } /** Mirrors `scaffold-api-client` `ApiCustomActionSchema`. */ export interface ApiClientActionInput { code: string kind: 'api' | 'navigate' endpoint: string scope: PageCustomAction['scope'] payloadType: string | null responseType: string httpMethod: 'get' | 'post' | 'put' | 'patch' | 'delete' /** * Field shape of the request payload (`{ name: tsType }`), derived from the * action's `payloadParameters`. Present ONLY when the action collects a body * from the user; scaffold-api-client renders it into a typed `export interface` * (instead of the permissive `{ [key: string]: unknown }` placeholder). */ payloadShape?: Record /** * GET transport of `payloadParameters` (`{ name: tsType }`): the client sends * them as a QUERY STRING, never a body — the controller binds `[FromQuery]`. * Mutually exclusive with `payloadShape`/`payloadType` (GET nulls both). */ queryShape?: Record } /** * Name of the payload DTO synthesized for an action that collects * `payloadParameters` but declares no `payloadDto`. ONE name derived in ONE * place — the controller `[FromBody]`, the business Command/Service parameter * and the api-client `payloadType` all come out of the same projection, so the * two halves of the wire cannot disagree. (Historically the apiClient carried a * `payloadShape` while `controller.payloadDto` stayed null → the dialog * collected values the backend had nowhere to receive — exactly the drift the * single-projection design exists to prevent.) * * synthesizedPayloadDtoName('deactivate', 'VehicleType') === 'DeactivateVehicleTypeDto' */ export function synthesizedPayloadDtoName(code: string, entity: string): string { return `${toPascalCase(code)}${entity}Dto` } /** Effective payload DTO name: the authored `payloadDto`, else the synthesized * one when the action collects `payloadParameters` AND the caller supplied * the entity name (older callers that omit it keep the legacy null). */ function effectivePayloadDto(a: PageCustomAction, entityName?: string): string | null { if (a.payloadDto) return a.payloadDto const collects = (a.payloadParameters?.length ?? 0) > 0 return collects && entityName ? synthesizedPayloadDtoName(a.code, entityName) : null } /** * Project a canonical action onto `scaffold-controller`'s `customActions[]`. * The controller `code` is the URL segment (`endpoint`), NOT the business code — * so the `[HttpVerb("")]` attribute matches the api-client URL verbatim. * Pass `entityName` so an action collecting `payloadParameters` without an * authored `payloadDto` gets the synthesized DTO — and with it the * `[FromBody(EmptyBodyBehavior.Allow)]` parameter scaffold-controller emits. */ export function toControllerCustomAction(action: PageCustomAction, entityName?: string): ControllerActionInput { const a = normalizePageCustomAction(action) const isGet = a.httpMethod === 'GET' const params = a.payloadParameters ?? [] // A GET carries no body: payloadDto is nulled here (NOT left for the emitter // to ignore) so the business projection below stays in lockstep — the two // halves of the wire come out of the same decision. const payloadDto = isGet ? null : effectivePayloadDto(a, entityName) const qp = queryParametersOf(a) return { code: a.endpoint ?? defaultEndpoint(a.code), scope: a.scope, httpMethod: a.httpMethod, payloadDto, ...(payloadDto && params.some(p => p.required === true) ? { payloadHasRequired: true } : {}), ...(qp ? { queryParameters: qp } : {}), responseDto: a.responseDto ?? 'NoContent', permissionAction: lastPermissionSegment(a.permission), } } /** * The kind:api permission-binding guard (see the `permission` field doc). * * The projections keep ONLY the action segment (`lastPermissionSegment`) and * the generators re-derive module/section from the SPEC — so an authored * permission rooting elsewhere compiles into a DIFFERENT constant than the * one specified, with no error anywhere (audit finding H3), and a * resource-grain permission is silently WIDENED to its section (H4: the * 4-seg row is seeded but no generated code path ever enforces it). * Returns null when the binding is sound; otherwise the issue to REJECT the * action with (fail-closed — cross-section intent must be modelled * explicitly, never rebound silently). */ export interface ActionPermissionBindingIssue { reason: 'module-mismatch' | 'section-mismatch' | 'resource-grain' actual: string expected: string } export function actionPermissionBindingIssue( action: Pick, module: string, section: string, ): ActionPermissionBindingIssue | null { const expected = `${module}.${section}.` const parts = action.permission.split('.') if (parts.length === 4) { return { reason: 'resource-grain', actual: action.permission, expected } } if (parts[0] !== module) { return { reason: 'module-mismatch', actual: action.permission, expected } } if (parts[1] !== section) { return { reason: 'section-mismatch', actual: action.permission, expected } } return null } /** GET-only projection of `payloadParameters` onto nullable `[FromQuery]` / * scalar service parameters. Non-GET verbs transport them as the body. */ function queryParametersOf(a: PageCustomAction): { name: string; type: string }[] | undefined { if (a.httpMethod !== 'GET') return undefined const params = a.payloadParameters ?? [] if (params.length === 0) return undefined return params.map(p => ({ name: p.name, type: p.type })) } /** * Project a canonical action onto `scaffold-business`'s `customActions[]`. * Flattens `workflowTransition` into `fromStatus[]` / `toStatus` / `flowParameters`. * * `kanban` (the pagespec's `moveMatrixOf` extract) is injected ONLY into the * `move` action (KANBAN_MOVE_ACTION_CODE): the dictionary guard replaces the * single-edge `workflowTransition` recipe for a dynamic target status — the * board and the service compile the SAME matrix. */ export function toBusinessCustomAction( action: PageCustomAction, entityName?: string, kanban?: KanbanMoveContext, ): BusinessActionInput { const a = normalizePageCustomAction(action) const wf = a.workflowTransition const matrix = a.code === KANBAN_MOVE_ACTION_CODE ? kanban : undefined // GET actions carry no body — the SAME rule the controller projection // applies. The business projection used to be verb-blind and kept the DTO in // the service signature the controller never passed → CS1503 on every GET // compute action (client defect 2026-08-25 #4). const isGet = a.httpMethod === 'GET' const payloadDto = isGet ? null : effectivePayloadDto(a, entityName) const params = a.payloadParameters ?? [] const qp = queryParametersOf(a) // Lifecycle action↔field binding: a param declaring `field` writes that // entity attribute explicitly — projected only when a body exists (the // recipe reads `payload.`). const fieldAssignments = params .filter(p => p.field !== undefined) .map(p => ({ param: p.name, field: p.field! })) return { code: a.endpoint ?? defaultEndpoint(a.code), scope: a.scope, payloadDto, ...(payloadDto && params.length > 0 ? { payloadFields: params.map(p => ({ name: p.name, type: p.type, required: p.required ?? false })) } : {}), ...(payloadDto && fieldAssignments.length > 0 ? { fieldAssignments } : {}), ...(qp ? { queryParameters: qp } : {}), responseDto: a.responseDto ?? 'NoContent', ...(a.ucReference ? { ucReference: a.ucReference } : {}), ...(wf ? { fromStatus: wf.fromStatus, toStatus: wf.toStatus, flowParameters: wf.flowParameters, } : {}), ...(matrix ? { transitionMatrix: matrix.transitions, statusProperty: matrix.statusProperty, ...(matrix.errorCode !== undefined ? { statusErrorCode: matrix.errorCode } : {}), } : {}), ...(a.guardRules.length > 0 ? { guardRules: a.guardRules } : {}), ...(a.crossModuleDeps.length > 0 ? { crossModuleDeps: a.crossModuleDeps } : {}), } } /** * Map a custom action's `payloadParameters` onto the api-client `payloadShape` * (`{ fieldName: tsType }`) that scaffold-api-client renders into a typed * `export interface`. The type mapping mirrors the controls `CustomActionDialog` * emits: text / textarea / select / date / lookup collect strings, number a * `number`, file a `File` — TYPE ONLY: the generated transport is JSON, so a * `File` value serializes to `{}` (no multipart path exists; see the * payloadParameters doc above / PRD-107). */ export function payloadParametersToShape( params: NonNullable, ): Record { const shape: Record = {} for (const p of params) { shape[p.name] = payloadParamTsType(p.type) } return shape } function payloadParamTsType(type: string): string { switch (type) { case 'number': return 'number' case 'file': return 'File' default: // text | textarea | select | date | lookup — all collected as strings. return 'string' } } /** * Project a canonical action onto `scaffold-api-client`'s `customActions[]`. * The api-client `code` drives TS naming (`use${Entity}`) so it is * the kebab-cased business code; `endpoint` (the URL segment) is emitted * explicitly so the service URL equals the controller route even when they * diverge (`syncFromPce` → `sync-from-proconcept`). `navigate` actions never * reach this projection — they have no service/hook. * * The frontend sends a body IF AND ONLY IF the action declares collectible * `payloadParameters`. A `payloadDto` WITHOUT parameters is an optional / * server-defaulted body (the controller tolerates an empty body via * `EmptyBodyBehavior.Allow`), so `payloadType` is deliberately dropped to `null` * here — keeping page↔hook arity at 0 and removing any reason to null out * `payloadType` by hand to make the build pass (the historical 415 hack). */ export function toApiClientCustomAction(action: PageCustomAction, entityName?: string): ApiClientActionInput { const a = normalizePageCustomAction(action) // GET actions transport their parameters as a QUERY STRING (mirrors the // controller's `[FromQuery]` binding) — a GET has no body, so payloadType / // payloadShape are nulled and `queryShape` carries the fields instead. const isGet = a.httpMethod === 'GET' const hasCollectiblePayload = !isGet && (a.payloadParameters?.length ?? 0) > 0 const payloadShape = hasCollectiblePayload ? payloadParametersToShape(a.payloadParameters!) : undefined const queryShape = isGet && (a.payloadParameters?.length ?? 0) > 0 ? payloadParametersToShape(a.payloadParameters!) : undefined return { code: toKebabCase(a.code), kind: a.kind, endpoint: a.endpoint ?? defaultEndpoint(a.code), scope: a.scope, payloadType: hasCollectiblePayload ? (a.payloadType ?? a.payloadDto ?? (entityName ? synthesizedPayloadDtoName(a.code, entityName) : null)) : null, responseType: a.responseType ?? a.responseDto ?? 'void', httpMethod: a.httpMethod.toLowerCase() as ApiClientActionInput['httpMethod'], ...(payloadShape ? { payloadShape } : {}), ...(queryShape ? { queryShape } : {}), } } /** * Split a pagespec's `actions[]` into the two streams the generators need, * after dropping CRUD codes and de-duplicating api actions by their wire * identity `(scope, endpoint, httpMethod)` — the same action MAY appear on * several views (list + detail) and must land ONCE on the controller / service. * * `apiActions` are normalised (endpoint/payloadType/responseType filled); * `navigateActions` are returned verbatim (scaffold-component renders them as * inline `navigate(targetRoute)` buttons — no backend, no hook). */ export function splitActions(actions: PageCustomAction[]): { apiActions: PageCustomAction[] navigateActions: PageCustomAction[] } { const apiActions: PageCustomAction[] = [] const navigateActions: PageCustomAction[] = [] const seen = new Set() for (const raw of customActionsOnly(actions)) { if (raw.kind === 'navigate') { navigateActions.push(raw) continue } const a = normalizePageCustomAction(raw) const key = `${a.scope}|${a.endpoint ?? defaultEndpoint(a.code)}|${a.httpMethod}` if (seen.has(key)) continue seen.add(key) apiActions.push(a) } return { apiActions, navigateActions } }