/** * scaffold-screen-controller — generate.ts * * Emits the screen-driven controller stratum for one (section, entity) pair. * * src/{Ns}.Api/Controllers/{Module}/Screens/{EntityPlural}ScreenController.cs * src/{Ns}.Application/{Module}/DTOs/Screens/{Entity}{View}ScreenDto.cs × N * * One endpoint per pagespec. Each endpoint: * - lives under /api/screens/{entityPluralLower}/... * - carries [ApiExplorerSettings(GroupName = "screens")] (class-level) * [RequirePermission(...)] (method-level) * - calls _service.{Method}Async(...) * * The service interface (I{Entity}Service) is assumed to already expose: * - GetForListScreenAsync / GetForDetailScreenAsync (read screens) * - CreateAsync / UpdateAsync (form screens, REUSED from integration) * - {Pascal(action.code)}Async (custom actions, REUSED) * * When the scaffolder cannot prove the method exists (cheap heuristic: it is * never seen in any pagespec.action), it appends a `// TODO[SCREEN-{code}]:` * marker on the controller's call site so audit-dev-api can block the merge. * * Pure function — no I/O, no filesystem. Returns an array of GeneratedFile. */ import { pluralize, toPascalCase } from '../../../../../lib/string-utils.js' import { buildScreenRoute, pluralSegment } from '../../../../../lib/url-conventions.js' import { expectedControllerRoute } from '../../../../../lib/page-spec-actions.js' import { screenFilterParams, type ScreenFilterParam } from '../../../../../lib/page-spec-filters.js' import { applicationNs, applicationDir, screenControllersNs, screenControllersDir, permissionsNs, legacyControllersDir, legacyApplicationDir, } from '../../../../../lib/app-classification.js' import type { GeneratedFile, PageSpec, PageSpecAction, PageSpecColumn, ScaffoldScreenControllerSpec } from './types.js' export interface GenerateOutput { files: GeneratedFile[] todos: string[] } export function generate(spec: ScaffoldScreenControllerSpec, pagespecs: PageSpec[]): GenerateOutput { const e = spec.entity // PascalCase singular const ePlural = pluralize(e) // PascalCase plural const eLower = lowercaseFirst(e) // Kebab-case plural — single source of truth shared with scaffold-controller // and scaffold-api-client. Replaces the prior `lowercaseFirst(pluralize)` which // gave camelCase URLs (`orderLines`) — diverged from the frontend's kebab // (`order-lines`) on every multi-word entity. const ePluralKebab = pluralSegment(ePlural) // URL segment // ASP.NET Core wants the route attribute without a leading slash. const screenRouteAttr = buildScreenRoute(ePluralKebab).replace(/^\//, '') const mod = toPascalCase(spec.module) const ns = spec.namespace // App/Module/Section classification — `spec.appCode` is the business app (kebab, // matches pagespec.appCode); the .NET root (`${ns}.Api`/`${ns}.Application`) is // untouched. Screen controllers are section-grained → land under // //
/. Screen DTOs follow the module's Application folder. const appNs = applicationNs(ns, spec.appCode, spec.module) const appDir = applicationDir(ns, spec.appCode, spec.module) const controllerClass = `${ePlural}ScreenController` const controllerPath = `${screenControllersDir(ns, spec.appCode, spec.module, spec.section)}/${controllerClass}.cs` const files: GeneratedFile[] = [] const todos: string[] = [] // ─── Emit per-pagespec endpoint blocks + DTOs ─── // Each pagespec contributes (a) its view's main method (list/detail/form), // PLUS (b) any custom actions (kind="api") attached to it. Custom actions // are emitted regardless of view — they live alongside the read/write // endpoint(s) of that screen. const methods: string[] = [] const fkFilters = spec.fkFilters ?? [] for (const ps of pagespecs) { const emitted = emitMethodAndDto({ pagespec: ps, entity: e, entityLower: eLower, mod, ns, appNs, appDir, todos, fkFilters }) if (emitted.method) methods.push(emitted.method) if (emitted.dto) files.push(emitted.dto) const defaultPerm = permissionConstant(ps.permission, mod) const customMethods = (ps.actions ?? []) .filter(a => a.kind === 'api') .map(a => emitCustomActionMethod({ entity: e, screenCode: ps.screenCode, action: a, mod, defaultPerm })) methods.push(...customMethods) } // ─── Assemble the controller ─── const usings = [ 'using Microsoft.AspNetCore.Authorization;', 'using Microsoft.AspNetCore.Mvc;', 'using Microsoft.AspNetCore.Mvc.ModelBinding;', 'using SmartStack.Api.Authorization;', 'using SmartStack.Application.Common.Models;', `using ${appNs}.DTOs;`, `using ${appNs}.DTOs.Screens;`, `using ${appNs}.Interfaces;`, `using ${appNs}.Commands;`, `using ${appNs}.Queries;`, `using ${permissionsNs(ns, spec.appCode, spec.module)};`, ].join('\n') const controllerContent = `${usings} namespace ${screenControllersNs(ns, spec.appCode, spec.module, spec.section)}; /// /// Screen-driven controller — exposes one endpoint per screen of section /// "${spec.section}" (entity ${e}). Companion of the integration controller /// (${ePlural}Controller, served at /api/{module}/{section} via [NavRoute]). Both call the same /// I${e}Service — business rules NEVER duplicate between strata. /// [ApiController] [ApiExplorerSettings(GroupName = "screens")] [Route("${screenRouteAttr}")] [Authorize] [Produces("application/json")] public class ${controllerClass} : ControllerBase { private readonly I${e}Service _service; public ${controllerClass}(I${e}Service service) => _service = service; ${methods.join('\n\n')} } ` files.push({ path: controllerPath, content: controllerContent }) return { files, todos } } /** * Pre-classification locations of the generated files: the screen controller used * to live in the per-module `Controllers//Screens/` bucket and the screen * DTOs under `Application//DTOs/Screens/`. Maps each generated (classified) * path back to its legacy location so the CLI can delete the stale copy — re-running * /ba-develop MOVES the section's screen controller instead of leaving a duplicate. */ export function legacyPaths(spec: ScaffoldScreenControllerSpec, files: GeneratedFile[]): string[] { const ns = spec.namespace const ctrlDir = screenControllersDir(ns, spec.appCode, spec.module, spec.section) const legacyCtrlDir = `${legacyControllersDir(ns, spec.module)}/Screens` const dtoDir = `${applicationDir(ns, spec.appCode, spec.module)}/DTOs/Screens` const legacyDtoDir = `${legacyApplicationDir(ns, spec.module)}/DTOs/Screens` return files.map(f => f.path.startsWith(`${ctrlDir}/`) ? `${legacyCtrlDir}${f.path.slice(ctrlDir.length)}` : f.path.startsWith(`${dtoDir}/`) ? `${legacyDtoDir}${f.path.slice(dtoDir.length)}` : f.path, ) } // ─── Per-pagespec emission ─────────────────────────────────────────────── interface EmitArgs { pagespec: PageSpec entity: string // PascalCase singular entityLower: string mod: string // PascalCase module ns: string // PascalCase namespace root appNs: string // Classified Application namespace (${ns}.Application.${App}.${Module}) appDir: string // Classified Application folder (src/${ns}.Application/${App}/${Module}) todos: string[] /** camelCase Guid FK filter params exposed on GET /list (see spec.fkFilters). */ fkFilters: string[] } interface EmittedPart { method: string | null dto: GeneratedFile | null } function emitMethodAndDto(args: EmitArgs): EmittedPart { const { pagespec: ps, entity: e, mod, appNs, appDir } = args const view = ps.view const permConst = permissionConstant(ps.permission, mod) const screenCode = ps.screenCode // Server filter params derived from the pagespec filters[] (shared SSOT — // scaffold-business appends the SAME ordered members to Get{E}ListScreenQuery). const screenFilters = screenFilterParams(ps.filters ?? [], args.fkFilters) switch (view) { case 'list': return { method: emitListMethod({ entity: e, permConst, screenCode, fkFilters: args.fkFilters, screenFilters }), dto: emitListDto({ entity: e, appNs, appDir, columns: ps.columns ?? [] }), } case 'detail': return { method: emitDetailMethod({ entity: e, permConst, screenCode }), dto: emitDetailDto({ entity: e, appNs, appDir, columns: ps.columns ?? [] }), } case 'form': return { method: emitFormMethods({ entity: e, permConst, screenCode }), dto: null, // Form consumes Create/UpdateDto from integration — never re-emit } case 'dashboard': // One endpoint per dashboard: GET /api/screens/{plural}/dashboard returns // { widgets: { : result } } (see development/frontend/dashboard). Each // declared widget is populated with a shape-correct default + a precise // per-widget aggregation TODO so the dashboard renders instead of failing. return { method: emitDashboardMethods({ entity: e, permConst, screenCode, widgets: ps.widgets ?? [] }), dto: emitDashboardDto({ entity: e, appNs, appDir }), } default: // Hub views (app-home / module-home / section-home) are pure Slot // containers on the frontend — `scaffold-component` emits NO useService // hook and NO api.get/post call for them. Emitting no backend endpoint // here is therefore correct AND intentional. Locked by hub-views.test.ts. // No TODO is pushed — there is no debt; the absence is by design. if (view === 'app-home' || view === 'module-home' || view === 'section-home') { return { method: null, dto: null } } // kanban / card — the board and the gallery are viewModes of the LIST // page and are served by ITS endpoint. A standalone kanban/card pagespec // is the pre-fold LEGACY shape (PRD-135f / PRD-117b): serve GET /list so // nothing 404s, and push the migration TODO instead of an emitter debt. args.todos.push( `[SCREEN-${screenCode}] standalone "${view}" pagespec is the pre-fold legacy shape — fold it into the ` + `list pagespec (create-prd/cli/derive-kanban-spec for kanban, viewModes for card) and delete this file; ` + `the list endpoint already serves the representation.`, ) return { method: emitListMethod({ entity: e, permConst, screenCode, fkFilters: args.fkFilters, screenFilters }), dto: emitListDto({ entity: e, appNs, appDir, columns: ps.columns ?? [] }), } } } // ─── Method emitters per view ──────────────────────────────────────────── function emitListMethod(args: { entity: string; permConst: string; screenCode: string; fkFilters: string[]; screenFilters: ScreenFilterParam[] }): string { const { entity, permConst, screenCode, fkFilters, screenFilters } = args const dtoName = `${entity}ListScreenDto` const queryName = `Get${entity}ListScreenQuery` // Relation (FK) filters — same optional `[FromQuery] Guid?` params as the // integration GetAll; wire name === pagespec `relatedTabs[].relationFk`. // Pagespec filters[] — one optional param per non-FK filter (shared SSOT // lib/page-spec-filters.ts; scaffold-business appends the SAME members to // the query record). Passed as NAMED args so two adjacent string? params // can never silently transpose if the record drifts. return ` /// List screen — ${screenCode}. [HttpGet("list")] [RequirePermission(${permConst})] public async Task>> GetList( [FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? search = null, [FromQuery] string? sortBy = null, [FromQuery] string? sortDir = null, ${fkFilters.map(p => ` [FromQuery] Guid? ${p} = null,\n`).join('')}${screenFilters.map(p => ` [FromQuery] ${p.csType} ${p.name} = null,\n`).join('')} CancellationToken ct = default) { var result = await _service.GetForListScreenAsync(new ${queryName}(page, pageSize, search, sortBy, sortDir${fkFilters.map(p => `, ${p}`).join('')}${screenFilters.map(p => `, ${p.pascal}: ${p.name}`).join('')}), ct); return Ok(result); }` } function emitDetailMethod(args: { entity: string; permConst: string; screenCode: string }): string { const { entity, permConst, screenCode } = args const dtoName = `${entity}DetailScreenDto` return ` /// Detail screen — ${screenCode}. [HttpGet("detail/{id:guid}")] [RequirePermission(${permConst})] public async Task> GetDetail(Guid id, CancellationToken ct = default) { var result = await _service.GetForDetailScreenAsync(id, ct); return result is null ? NotFound() : Ok(result); }` } function emitFormMethods(args: { entity: string; permConst: string; screenCode: string }): string { const { entity, permConst, screenCode } = args // Create and Update permissions are derived from the base permission ("read") by swapping // the last segment. Until we read RBAC here, we reuse permConst and add a doc note. const createPerm = permConst.replace(/\.Read$/, '.Create').replace(/\.Update$/, '.Create') const updatePerm = permConst.replace(/\.Read$/, '.Update').replace(/\.Create$/, '.Update') return ` /// Form screen (create) — ${screenCode}. Calls existing CreateAsync on the service. [HttpPost("form")] [RequirePermission(${createPerm})] public async Task> Create([FromBody] Create${entity}Dto dto, CancellationToken ct = default) { var id = await _service.CreateAsync(new Create${entity}Command(/* TODO[SCREEN-${screenCode}]: map dto → command */), ct); return CreatedAtAction(nameof(GetDetailRoute), new { id }, id); } /// Form screen (update) — ${screenCode}. Calls existing UpdateAsync on the service. [HttpPut("form/{id:guid}")] [RequirePermission(${updatePerm})] public async Task Update(Guid id, [FromBody] Update${entity}Dto dto, CancellationToken ct = default) { await _service.UpdateAsync(new Update${entity}Command(id /* TODO[SCREEN-${screenCode}]: map dto → command */), ct); return NoContent(); } /// Internal route token used by CreatedAtAction (form Create above). /// The actual GET detail endpoint is on this controller too (see GetDetail). private static string GetDetailRoute => nameof(GetDetail);` } /** Shape-correct default for a widget type, matching the frontend `WidgetResult` * contract (kpi → value/delta, line/bar → points, pie → slices, list → rows/columns). * Emitted so the widget renders its skeleton (zero/empty) rather than the "no data" * placeholder, and the per-widget TODO carries the real aggregation to fill in. */ function dashboardWidgetDefault(type: string | undefined): string { switch (type) { case 'chart-line': case 'chart-bar': return 'new { points = System.Array.Empty() }' case 'chart-pie': return 'new { slices = System.Array.Empty() }' case 'list': return 'new { rows = System.Array.Empty(), columns = System.Array.Empty() }' default: return 'new { value = 0 }' // kpi / counter } } function emitDashboardMethods(args: { entity: string permConst: string screenCode: string widgets: Array<{ key: string; type?: string; entity?: string; aggregation?: string; field?: string }> }): string { const { entity, permConst, screenCode, widgets } = args const dto = `${entity}DashboardDto` // One line per widget: a shape-correct default + a precise per-widget TODO carrying the // inferred aggregation (type · entity · field · aggregation) so the dev fills the real // number without re-deriving the spec. The dashboard renders instead of failing. const widgetLines = widgets.length > 0 ? widgets.map(w => { const agg = w.aggregation ? `${w.aggregation} of ` : '' const target = w.field ? `${w.entity ?? entity}.${w.field}` : (w.entity ?? entity) return ` // TODO[DASH:${w.key}]: ${w.type ?? 'kpi'} — aggregate ${agg}${target} over [startDate, endDate] and replace this default.\n dto.Widgets["${w.key}"] = ${dashboardWidgetDefault(w.type)};` }).join('\n') : ' // No widgets declared on this dashboard pagespec — nothing to populate.' return ` /// Dashboard — one result per SmartDashboard widget key for ${screenCode}. [HttpGet("dashboard")] [RequirePermission(${permConst})] public ActionResult<${dto}> GetDashboard( [FromQuery] DateTime? startDate = null, [FromQuery] DateTime? endDate = null) { // Each widget gets a shape-correct default (so the frontend renders the widget skeleton, // not its "no data" placeholder) + a per-widget TODO[DASH:] carrying the inferred // aggregation. Fill each in (via _service or a MediatR query) to surface real numbers — // WidgetResult = value/delta (kpi) · points (line/bar) · slices (pie) · rows/columns (list). var dto = new ${dto}(); ${widgetLines} return Ok(dto); }` } function emitDashboardDto(args: { entity: string; appNs: string; appDir: string }): GeneratedFile { const { entity, appNs, appDir } = args const className = `${entity}DashboardDto` return { path: `${appDir}/DTOs/Screens/${className}.cs`, content: `namespace ${appNs}.DTOs.Screens; /// /// Dashboard payload for ${entity}: one result per SmartDashboard widget key. /// Mirrors the frontend contract { widgets: { [key]: WidgetResult } } — each value /// is shaped by the widget type (kpi → value/delta, chart → points/slices, list → /// rows/columns). Values are loose (object) so a per-widget aggregation can return /// the shape its widget type needs without a per-widget DTO. /// public sealed record ${className} { public Dictionary Widgets { get; init; } = new(); } `, } } function emitCustomActionMethod(args: { entity: string; screenCode: string; action: PageSpecAction; mod: string; defaultPerm: string }): string { const { action, defaultPerm, screenCode } = args const endpoint = action.endpoint ?? toKebab(action.code) // toPascalCase handles both kebab ("submit-for-review" → "SubmitForReview") // and camelCase ("syncFromPce" → "SyncFromPce"). The action.code remains the // single source of truth — endpoint segment may differ (e.g. legacy URLs). const methodName = toPascalCase(action.code) const verb = action.httpMethod ?? 'POST' const httpAttr = `[Http${verb.charAt(0) + verb.slice(1).toLowerCase()}` const permConst = action.permission ? permissionConstant(action.permission, args.mod) : defaultPerm const responseType = action.responseDto && action.responseDto !== 'NoContent' ? `ActionResult<${action.responseDto}>` : 'ActionResult' const allowBody = verb !== 'GET' && action.payloadDto != null && action.payloadDto !== '' // Route from the SSOT `expectedControllerRoute` (same helper the audits use). // Row actions mount at `{id:guid}/` — identical to the integration // controller and to the URL the frontend api-client emits (`/${id}/`). // The previous `detail/{id:guid}/…` prefix had NO frontend counterpart, so every // screen-controller row action 404'd. const routeAttr = `${httpAttr}("${expectedControllerRoute(action.scope, endpoint)}")]` const params: string[] = [] let serviceArgs: string if (action.scope === 'row') { params.push('Guid id') if (allowBody) params.push(`[FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] ${action.payloadDto}? dto = null`) serviceArgs = allowBody ? 'id, dto ?? new(), ct' : 'id, ct' } else if (action.scope === 'bulk') { if (allowBody) params.push(`[FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] ${action.payloadDto}? dto = null`) serviceArgs = allowBody ? 'dto ?? new(), ct' : 'ct' } else { // header scope if (allowBody) params.push(`[FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] ${action.payloadDto}? dto = null`) serviceArgs = allowBody ? 'dto ?? new(), ct' : 'ct' } params.push('CancellationToken ct = default') const body = responseType === 'ActionResult' ? ` await _service.${methodName}Async(${serviceArgs}); return NoContent();` : ` var result = await _service.${methodName}Async(${serviceArgs}); return Ok(result);` return ` /// Custom action ${action.code} (${action.scope}) — ${screenCode}. ${routeAttr} [RequirePermission(${permConst})] public async Task<${responseType}> ${methodName}(${params.join(', ')}) { ${body} }` } // ─── DTO emitters ──────────────────────────────────────────────────────── function emitListDto(args: { entity: string; appNs: string; appDir: string; columns: PageSpecColumn[] }): GeneratedFile { const { entity, appNs, appDir, columns } = args const className = `${entity}ListScreenDto` const path = `${appDir}/DTOs/Screens/${className}.cs` const props = columns.length > 0 ? columns.map(c => ` public ${dotnetTypeFor(c)} ${toPascalCase(c.key)} { get; init; } = ${dotnetDefaultFor(c)};`).join('\n') : ' // No columns in pagespec — backend exposes only Id + CreatedAt as a safe minimum.' return { path, content: `namespace ${appNs}.DTOs.Screens; /// /// Shape of one row in the ${entity} list screen — fields derived from /// the pagespec columns[], not the full entity. Keep stable: any change /// here is a contract change with the frontend. /// public record ${className} { public Guid Id { get; init; } ${props} public DateTime CreatedAt { get; init; } } `, } } function emitDetailDto(args: { entity: string; appNs: string; appDir: string; columns: PageSpecColumn[] }): GeneratedFile { const { entity, appNs, appDir, columns } = args const className = `${entity}DetailScreenDto` const path = `${appDir}/DTOs/Screens/${className}.cs` // For detail, we still derive from columns[] in the MVP — the BA author can // enrich the detail pagespec with extra columns specific to the detail view. // Future refinement: a dedicated `fields[]` block on the detail pagespec. const props = columns.length > 0 ? columns.map(c => ` public ${dotnetTypeFor(c)} ${toPascalCase(c.key)} { get; init; } = ${dotnetDefaultFor(c)};`).join('\n') : ' // No columns in pagespec — backend exposes only Id + timestamps.' return { path, content: `namespace ${appNs}.DTOs.Screens; /// /// Shape of the ${entity} detail screen — fields derived from the pagespec. /// public record ${className} { public Guid Id { get; init; } ${props} public DateTime CreatedAt { get; init; } public DateTime? UpdatedAt { get; init; } } `, } } // ─── Helpers ───────────────────────────────────────────────────────────── function lowercaseFirst(s: string): string { return s.length > 0 ? s.charAt(0).toLowerCase() + s.slice(1) : s } function toKebab(s: string): string { return s .replace(/([a-z])([A-Z])/g, '$1-$2') .replace(/_/g, '-') .toLowerCase() } /** Convert `module.section.action` → `{Mod}Permissions.{Section}.{Action}`. * The Mod argument is the PascalCase module the controller belongs to. The * permission's own module segment does NOT survive compilation — when it * differs from the controller's module, the enforced constant is a * DIFFERENT permission than the authored one (silent rebind, audit H3). * The primary guard is upstream (derive-action-specs rejects the binding); * this deprecated stratum surfaces any residue with a visible marker * comment instead of hiding it. */ function permissionConstant(permission: string, mod: string): string { const parts = permission.split('.') if (parts.length !== 3) { // Defensive: malformed permission strings are passed through verbatim // wrapped in a comment so the C# compiler surfaces the error early. return `/* malformed permission: ${permission} */ ${mod}Permissions.Unknown.Unknown` } const [module, section, action] = parts const constant = `${mod}Permissions.${toPascalCase(section)}.${toPascalCase(action)}` if (module !== toKebab(mod)) { // Visible drift marker — the authored permission roots at another module; // what gets ENFORCED is this controller's constant, not the authored path. return `/* permission-module mismatch: authored '${permission}', enforcing '${toKebab(mod)}.${section}.${action}' */ ${constant}` } return constant } /** Map a pagespec column to a C# type — best-effort heuristic on formatHint. */ function dotnetTypeFor(c: PageSpecColumn): string { const hint = (c.formatHint ?? '').toLowerCase() if (hint === 'currency' || hint === 'number' || hint === 'decimal') return 'decimal' if (hint === 'integer' || hint === 'count') return 'int' if (hint === 'date' || hint === 'datetime') return 'DateTime' if (hint === 'bool' || hint === 'boolean') return 'bool' if (hint === 'guid' || hint === 'uuid') return 'Guid' return 'string' } function dotnetDefaultFor(c: PageSpecColumn): string { const t = dotnetTypeFor(c) switch (t) { case 'decimal': case 'int': return '0' case 'DateTime': return 'default' case 'bool': return 'false' case 'Guid': return 'Guid.Empty' default: return '""' } }