/** * 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} } ` }