/**
* cli:scaffold-controller — generate.ts
* Generates API controller + module-scoped permissions class.
* Generated code consumes NuGet packages (SmartStack.Core, SmartStack.Application, SmartStack.Api).
*
* Per-page mode (post-Step 3 of the vertical-slice refactor):
* When `spec.customActions[]` is non-empty, each entry produces ONE extra
* controller method beyond the standard CRUD. Codes outside the canonical
* REST set (create/update/delete) are emitted as POST routes following the
* convention:
* - row scope → POST /api/{plural}/{id:guid}/{code}
* - bulk scope → POST /api/{plural}/bulk/{code}
* - header scope → POST /api/{plural}/{code}
* The bound service method is `_service.{Code}Async(...)` (canonical
* cross-layer naming — same as the React hook + frontend service stub).
*/
import { isSupplied } from '../../../../../lib/page-spec-coded-entity.js'
import { pluralize } from '../../../../../lib/string-utils.js'
import { expectedControllerRoute } from '../../../../../lib/page-spec-actions.js'
import { fkFilterFields } from '../../../../../lib/page-spec-related-tabs.js'
import { screenFilterParams } from '../../../../../lib/page-spec-filters.js'
import {
applicationNs,
controllersNs,
controllersDir,
permissionsNs,
permissionsDir,
moduleSegment,
sectionSegment,
legacyControllersDir,
legacyPermissionsDir,
} from '../../../../../lib/app-classification.js'
import type {
ScaffoldControllerInput,
GeneratedFile,
ControllerField,
ControllerCustomAction,
} from './types.js'
export function generate(spec: ScaffoldControllerInput): GeneratedFile[] {
const ns = spec.namespace ?? spec.appCode
const mod = moduleSegment(spec.module)
// App/Module classification — the .NET root (`${ns}.Api`) is untouched; the
// controller + permissions namespaces gain `.${App}.${Module}`, and the cross-
// layer `using`s below track scaffold-business's classified Application namespace.
const appNs = applicationNs(ns, spec.applicationCode, spec.module)
const ctrlNs = controllersNs(ns, spec.applicationCode, spec.module)
const permNs = permissionsNs(ns, spec.applicationCode, spec.module)
const ctrlDir = controllersDir(ns, spec.applicationCode, spec.module)
const permDir = permissionsDir(ns, spec.applicationCode, spec.module)
const e = spec.name
// `pluralize` guards words already ending in 's' (no `effectifss`). Same
// fallback as the frontend api-client → the integration route segment stays
// identical on both sides even when `pluralName` is omitted.
const plural = spec.pluralName ?? pluralize(e)
// Route attributes: the integration controller carries ONLY [NavRoute]. The
// platform's NavigationRouteModelProvider clears every selector and builds the
// route from [NavRoute] → /api/{module}/{section} (a literal [Route] would be
// discarded at runtime). The frontend derives the SAME path from the same
// navRoute via buildNavApiPath — front == back by construction.
const section = sectionSegment(spec.section)
// The permission path MUST carry the application code — the platform's
// PermissionMatcher does an EXACT (or wildcard) match against the seeded grants,
// which are `{app}.{module}.{section}.{action}` (see types.ts:44 + the nav seed).
// Omitting `${spec.applicationCode}.` yields `{module}.{section}.{action}`, which
// never matches the `{app}.`-prefixed grant → every role-based user gets 403 on
// every endpoint (only a `*` super-admin passes). The app code is already in hand
// (used for the namespaces/dirs above); include it in the fallback too.
const permPrefix = spec.permissionPrefix ?? `${spec.applicationCode}.${spec.module}.${spec.section}`
const userFields = spec.fields.filter(f => !isSystemField(f.name))
// Create{E}Dto/Command carry the CREATION fields — required PLUS optional
// non-phased (scaffold-business `createInputFields`, the Create-honnête
// contract: an optional typed at create used to be silently dropped by
// binding against the required-only DTO). Lifecycle-phased fields stay
// excluded on BOTH sides so the arities keep matching (CS1061/CS7036 guard).
// Update stays on the full stored list, mirroring `updatableFields`.
const createMapping = mapDtoToCommand(userFields.filter(f => f.required || !f.phase), 'dto')
// Supplied-on-create (coded entities): forward the optional Code as a
// NAMED argument — the command's `string? Code = null` is terminal, so the
// name keeps the call order-proof whatever the field list becomes.
+ (isSupplied(spec.codedEntity) ? ', Code: dto.Code' : '')
// Versioned entities: Update{E}Command ends with `byte[]? RowVersion = null`
// (offline-outbox 409 guard) and Update{E}Dto carries the token — forward it,
// otherwise the concurrency check silently never runs on this stratum.
const updateMapping = mapDtoToCommand(userFields, 'dto', 'id') + (spec.versioned ? ', dto.RowVersion' : '')
// Relation (FK) filters — one optional `[FromQuery] Guid?` per Guid FK,
// forwarded positionally to Get{Plural}Query (scaffold-business appends the
// matching `Guid?` params in the SAME field order). The camelCase wire name
// === pagespec `relatedTabs[].relationFk` (lib/page-spec-related-tabs.ts).
const fkFilterParams = fkFilterFields(userFields)
// Pagespec filters[] → [FromQuery] scalar params, SSOT-ordered
// (lib/page-spec-filters.ts) so the binding, the Get{Plural}Query members
// (scaffold-business) and the api-client list params stay in lockstep. FKs
// already on the Guid? channel are skipped inside the lib; forwarding uses
// NAMED args so the record's parameter order can never drift the values.
const listScreenFilters = screenFilterParams(spec.screenFilters, fkFilterParams)
const files: GeneratedFile[] = []
const customActionMethods = spec.customActions
.map(a => emitCustomActionMethod(a, mod, section, e))
.join('\n')
// ── Scheduled-job triggers (derive-job-specs) ──────────────────────────────
// One manual POST per scheduled UC — the TESTABLE half of the runtime
// (?date=YYYY-MM-DD replays a period; the service pass is idempotent).
// Permission `.Execute` rides the default floor — zero RBAC change.
const scheduledJobMethods = (spec.scheduledJobs ?? []).map(j => `
/// Manual trigger of scheduled job '${j.jobId}' (${j.ucCode}) —
/// ?date=YYYY-MM-DD replays a period (idempotent pass).
[HttpPost("jobs/${j.slug}/run")]
[RequirePermission(${mod}Permissions.${section}.Execute)]
public async Task ${j.methodName.replace(/Async$/, '')}Trigger([FromQuery] DateOnly? date = null, CancellationToken ct = default)
=> Ok(new { emitted = await _service.${j.methodName}(date ?? default, ct) });`).join('\n')
const controllerContent = `// @generated-by scaffold-controller — do NOT hand-edit. The integration controller
// is 100% scaffolder-owned (routes, the paginated GetAll, the lookup endpoint, the
// DTO shapes). Re-run scaffold-controller to change it; hand-writing it drifts the
// backend off the contract the deterministic frontend is generated against
// (missing /lookup, bare-array list) — exactly what DEV-API-016 / DEV-WIRE-001 catch.
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using SmartStack.Api.Routing;
using SmartStack.Application.Common.Models;
using SmartStack.Api.Authorization;
using ${appNs}.DTOs;
using ${appNs}.Interfaces;
using ${appNs}.Commands;
using ${appNs}.Queries;
using ${permNs};
namespace ${ctrlNs};
///
/// Integration controller — exposes generic CRUD over the entity for
/// machine-to-machine consumers (imports/exports, ETL, BI, third-party
/// integrations, dev tooling). The screen-driven API lives under
/// /api/screens/... and is generated by scaffold-screen-controller.
/// Both strata call the SAME business layer — business rules never duplicate.
///
[ApiController]
[ApiExplorerSettings(GroupName = "integration")]
// Route comes from [NavRoute]: the platform rewrites it to /api/{module}/{section}.
// A literal [Route] would be silently discarded at runtime — do not add one.
[NavRoute("${spec.navRoute}")]
[Authorize]
[Produces("application/json")]
public class ${plural}Controller : ControllerBase
{
private readonly I${e}Service _service;
public ${plural}Controller(I${e}Service service) => _service = service;
[HttpGet]
[RequirePermission(${mod}Permissions.${section}.Read)]
public async Task>> GetAll(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string? search = null,
[FromQuery] string? sortBy = null,
[FromQuery] string? sortDir = null,
${fkFilterParams.map(p => ` [FromQuery] Guid? ${p} = null,\n`).join('')}${listScreenFilters.map(p => ` [FromQuery] ${p.csType} ${p.name} = null,\n`).join('')} CancellationToken ct = default)
{
var result = await _service.GetAllAsync(new Get${plural}Query(page, pageSize, search, sortBy, sortDir${fkFilterParams.map(p => `, ${p}`).join('')}${listScreenFilters.map(p => `, ${p.pascal}: ${p.name}`).join('')}), ct);
return Ok(result);
}
[HttpGet("{id:guid}")]
[RequirePermission(${mod}Permissions.${section}.Read)]
public async Task> GetById(Guid id, CancellationToken ct = default)
{
var result = await _service.GetByIdAsync(id, ct);
return result is null ? NotFound() : Ok(result);
}
///
/// Lookup endpoint feeding the frontend EntityLookup combobox. Returns
/// paginated {Id, DisplayName} pairs. Dual permission gate — ANY semantics
/// (SmartStack ≥ 3.62): holders of the full Read surface keep passing, and
/// holders of the dedicated Lookup grant get ONLY this id+name reference
/// surface — no list/detail, and no menu visibility (data actions never
/// reveal a menu node; only .access does).
///
[HttpGet("lookup")]
[RequirePermission(${mod}Permissions.${section}.Lookup, ${mod}Permissions.${section}.Read)]
public async Task>> GetLookup(
[FromQuery] string? search = null,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
CancellationToken ct = default)
{
var result = await _service.GetLookupAsync(new Get${plural}LookupQuery(search, page, pageSize), ct);
return Ok(result);
}
${spec.actions.includes('create') ? `
[HttpPost]
[RequirePermission(${mod}Permissions.${section}.Create)]
public async Task> Create([FromBody] Create${e}Dto dto, CancellationToken ct = default)
{
var command = new Create${e}Command(${createMapping});
var id = await _service.CreateAsync(command, ct);
return CreatedAtAction(nameof(GetById), new { id }, id);
}
` : ''}${spec.actions.includes('update') ? `
[HttpPut("{id:guid}")]
[RequirePermission(${mod}Permissions.${section}.Update)]
public async Task Update(Guid id, [FromBody] Update${e}Dto dto, CancellationToken ct = default)
{
var command = new Update${e}Command(${updateMapping});
await _service.UpdateAsync(command, ct);
return NoContent();
}
` : ''}${spec.actions.includes('delete') ? `
[HttpDelete("{id:guid}")]
[RequirePermission(${mod}Permissions.${section}.Delete)]
public async Task Delete(Guid id, CancellationToken ct = default)
{
await _service.DeleteAsync(id, ct);
return NoContent();
}
` : ''}${customActionMethods}${scheduledJobMethods}
}
`
files.push({ path: `${ctrlDir}/${plural}Controller.cs`, content: controllerContent })
// Module-scoped namespace to avoid collisions across modules sharing section names.
// Custom actions reuse the canonical permPrefix.{permissionAction} key — no
// separate constant emitted because the action runs under an existing perm
// (typically Update). This mirrors how DB seeds register them.
// Access + Lookup are STRUCTURAL constants, emitted unconditionally (never
// part of actions[]): `access` is the platform's menu/route visibility lock
// (SmartStack ≥ 3.62), `lookup` gates the /lookup reference endpoint above
// (dual gate with Read, ANY semantics).
const permContent = `namespace ${permNs};
public static partial class ${mod}Permissions
{
public static class ${section}
{
public const string Access = "${permPrefix}.access";
public const string Lookup = "${permPrefix}.lookup";
${spec.actions.map(a => ` public const string ${capitalize(a)} = "${permPrefix}.${a}";`).join('\n')}
}
}
`
files.push({ path: `${permDir}/${mod}Permissions.${section}.cs`, content: permContent })
return files
}
/**
* Pre-classification locations (module-only, no ``) of the controller +
* permissions files. The CLI deletes any that exist before writing the new
* `/`-classified files so re-running /ba-develop MOVES them.
*/
export function legacyPaths(spec: ScaffoldControllerInput): string[] {
const ns = spec.namespace ?? spec.appCode
const mod = moduleSegment(spec.module)
const section = sectionSegment(spec.section)
const plural = spec.pluralName ?? pluralize(spec.name)
return [
`${legacyControllersDir(ns, spec.module)}/${plural}Controller.cs`,
`${legacyPermissionsDir(ns, spec.module)}/${mod}Permissions.${section}.cs`,
]
}
// ─── Helpers ───
function capitalize(s: string): string { return s.charAt(0).toUpperCase() + s.slice(1) }
/** Convert a kebab-case action code (`bulk-archive`) to PascalCase
* (`BulkArchive`) for C# method names. */
function pascalizeCode(code: string): string {
return code
.split('-')
.map(part => capitalize(part))
.join('')
}
/** C# type of a GET action's `[FromQuery]` parameter. Always NULLABLE —
* query-string binding treats every parameter as optional; the service
* validates presence where a rule requires it. Mirrors scaffold-business's
* payloadFieldCsType dialog-type mapping so the two layers agree. */
function queryParamCsType(type: string): string {
const base =
type === 'number' ? 'decimal'
: type === 'date' ? 'DateOnly'
: type === 'lookup' ? 'Guid'
: 'string'
return `${base}?`
}
function isSystemField(name: string): boolean {
const systemFields = ['id', 'createdat', 'updatedat', 'deletedat', 'createdby', 'updatedby', 'tenantid']
return systemFields.includes(name.toLowerCase())
}
function mapDtoToCommand(fields: ControllerField[], dtoVar: string, prependId?: string): string {
const fieldArgs = fields.map(f => `${dtoVar}.${capitalize(f.name)}`)
if (prependId) {
return [prependId, ...fieldArgs].join(', ')
}
return fieldArgs.join(', ')
}
/**
* Map an HTTP verb to its `[HttpXxx]` route attribute.
* POST → [HttpPost(...)]
* GET → [HttpGet(...)]
* PUT → [HttpPut(...)]
* PATCH → [HttpPatch(...)]
* DELETE → [HttpDelete(...)]
*/
function httpAttr(verb: string, route: string): string {
const pascal = verb.charAt(0).toUpperCase() + verb.slice(1).toLowerCase()
return `[Http${pascal}("${route}")]`
}
/**
* Emits a single custom-action controller method. Maps:
* - row scope → {VERB} {id:guid}/{code} with `Guid id` parameter
* - bulk scope → {VERB} bulk/{code}
* - header scope → {VERB} {code} with no id
*
* The VERB defaults to POST but may be GET (compute / read-only endpoints
* like `analyze-impact`), PUT/PATCH (rare — partial updates) or DELETE.
* `[FromBody]` is OMITTED when the verb is GET, even if `payloadDto` is set
* (ASP.NET Core does not allow bodies on GET methods); a GET's
* `queryParameters` are bound as nullable `[FromQuery]` scalars instead and
* forwarded to the service.
*
* Non-GET body binding depends on the payload record's requirements:
* - all members optional → `EmptyBodyBehavior.Allow` + nullable `dto?`, then
* `dto ?? new()` (the record's members all default to null, so `new()`
* compiles). A caller that sends NO body binds `null` → a default instance,
* instead of the 415 a mandatory `[FromBody]` returns.
* - ≥1 REQUIRED member (`payloadHasRequired`) → MANDATORY `[FromBody]`: an
* empty body could never satisfy the requirement, and the record has no
* parameterless ctor for `new()` — model binding 400s on a missing body.
*
* The service call uses the canonical `_service.{Pascal}Async(...)` shape so
* scaffold-business (Step 4) can emit a matching method and the two layers
* line up by construction.
*
* Permission constant: `{Mod}Permissions.{Section}.{PermissionAction}` —
* defaults to the existing Update / Create / Delete perm for state mutations.
*/
function emitCustomActionMethod(
action: ControllerCustomAction,
mod: string,
section: string,
entity: string,
): string {
const methodName = pascalizeCode(action.code)
const permConst = `${mod}Permissions.${section}.${capitalize(action.permissionAction)}`
const responseType = action.responseDto === 'NoContent'
? 'ActionResult'
: `ActionResult<${action.responseDto}>`
// Defensive fallback — Zod's `.default('POST')` only fires on `.parse()`.
// Tests and legacy callers may instantiate the object literally; we keep
// POST as the implicit default to preserve the pre-httpMethod behaviour.
const verb = action.httpMethod ?? 'POST'
const allowBody = verb !== 'GET' && Boolean(action.payloadDto)
// A payload with a REQUIRED member binds a MANDATORY body: the record has no
// parameterless ctor (`dto ?? new()` would be CS7036) and an empty body could
// never satisfy the requirement anyway — model binding 400s instead.
const requireBody = allowBody && action.payloadHasRequired === true
const bodyParam = requireBody
? `[FromBody] ${action.payloadDto} dto`
: `[FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] ${action.payloadDto}? dto = null`
const bodyArg = requireBody ? 'dto' : 'dto ?? new()'
// GET transport: payloadParameters ride the query string as nullable
// `[FromQuery]` scalars (a GET has no body) and are forwarded to the service.
const queryParams = verb === 'GET' ? (action.queryParameters ?? []) : []
const queryParamDecls = queryParams.map(q => `[FromQuery] ${queryParamCsType(q.type)} ${q.name}`)
const queryArgNames = queryParams.map(q => q.name)
// The route attribute comes from the SSOT `expectedControllerRoute` — the SAME
// helper audit-dev-api uses to verify the emitted `[HttpVerb("…")]`. Reimplementing
// the per-scope path inline here is exactly what let row actions silently drop their
// `{id:guid}` segment (front called `/tasks/status`, back served `/tasks/{id}/status`).
const routeAttr = httpAttr(verb, expectedControllerRoute(action.scope, action.code))
const params: string[] = []
const scopeArgs: string[] = []
if (action.scope === 'row') {
params.push('Guid id')
scopeArgs.push('id')
}
if (allowBody) {
params.push(bodyParam)
scopeArgs.push(bodyArg)
}
params.push(...queryParamDecls)
scopeArgs.push(...queryArgNames)
scopeArgs.push('ct')
const serviceArgs = scopeArgs.join(', ')
params.push('CancellationToken ct = default')
// Body — call the service then return Ok(result) for typed responses,
// NoContent() for void endpoints.
const body = action.responseDto === 'NoContent'
? ` await _service.${methodName}Async(${serviceArgs});
return NoContent();`
: ` var result = await _service.${methodName}Async(${serviceArgs});
return Ok(result);`
// Note: comment hints scaffold-business will produce the matching service
// method. The two layers stay in lockstep via the action.code single-source-of-truth.
return `
${routeAttr}
[RequirePermission(${permConst})]
public async Task<${responseType}> ${methodName}(${params.join(', ')})
{
${body}
}
`
}