/** * cli:scaffold-business — generate.ts * Generates CQRS stack: Commands, Queries, Handlers, DTOs, Validators, Service. * * Generated code consumes NuGet packages: * - MediatR (IRequest, IRequestHandler) * - FluentValidation (AbstractValidator) * - SmartStack.Application.Common.Interfaces.ICoreDbContext * - SmartStack.Application.Common.Exceptions.NotFoundException * - SmartStack.Application.Common.Models.PaginatedResult * * Compilation contract: every Command/Query records here has a matching Handler * class so MediatR can resolve `IRequestHandler` at runtime. */ import { applicationNs, applicationDir, servicesNs, servicesDir, legacyApplicationDir, legacyServicesDir, } from '../../../../../lib/app-classification.js' import { isFkFilterField } from '../../../../../lib/page-spec-related-tabs.js' import { screenFilterParams } from '../../../../../lib/page-spec-filters.js' import { derivedExpr } from '../../../../../lib/derived-field.js' import { createSurfaceSplit, isPureProjection as isPureProjectionShared, updateSurfaceFields } from '../../../../../lib/field-read-surface.js' import { PREFERRED_PROJECTED_DISPLAY_FIELDS, preferredDisplayFieldsFor } from '../../../../../lib/display-field.js' import { isSupplied } from '../../../../../lib/page-spec-coded-entity.js' import { toPascalCase, pluralize } from '../../../../../lib/string-utils.js' import type { ScaffoldBusinessInput, GeneratedFile, Field, BusinessRule, BusinessCustomAction, } from './types.js' export function generate(spec: ScaffoldBusinessInput): GeneratedFile[] { const ns = spec.namespace ?? spec.appCode const e = spec.name // Shared pluralizer — MUST match scaffold-controller / scaffold-entity / // scaffold-api-client (Category → Categories). A naive `e + 's'` here emitted // Get{E}sQuery records the controller (which pluralizes properly) could not // resolve → CS0246 on any irregular plural (client defect 2026-08-25 #1). const plural = spec.pluralName ?? pluralize(e) // App/Module classification: the .NET root (`${ns}.Application`, // `${ns}.Infrastructure`) is untouched — only the suffix gains `.${App}.${Module}`. // Namespaces + folders derive from the shared helper so the controller / // screen-controller `using`s that import these stay in lockstep. const appNs = applicationNs(ns, spec.applicationCode, spec.module) const appDir = applicationDir(ns, spec.applicationCode, spec.module) const svcNs = servicesNs(ns, spec.applicationCode, spec.module) const svcDir = servicesDir(ns, spec.applicationCode, spec.module) const reserved = new Set(['Id', 'CreatedAt', 'UpdatedAt', 'DeletedAt', 'TenantId']) // userFields includes computed (`formula`) and Core-projected (`source`) // fields — they appear in read DTOs (List/Detail) but pure projections are // NOT in Create/Update Commands, Dtos, Validators or factory calls. const userFields = spec.fields.filter(f => !reserved.has(f.name)) // Stored = a real column of the entity's own table. A `source` field is // stored ONLY in the person-optional overlay case (fallbackLocal === its own // name): the local column stays writable, only the read path coalesces. const storedFields = userFields.filter(f => !f.formula && !isPureProjection(f) && !f.derived) const requiredFields = storedFields.filter(f => f.required) // ── Data scope (own) — the owner column is SERVER-assigned, never client input ── // Mirrors scaffold-entity: the factory takes the owner positionally (after the // field args) and the entity's Update() excludes it. Create/Update DTOs, // Commands and Validators therefore never expose it (spoof-proof); CreateAsync // passes the current user's id at the owner's slot instead. const scope = spec.dataScope const ownerName = scope && (scope.mode === 'own' || scope.mode === 'own-assigned') ? scope.ownerProperty : null // ── Supplied-on-create (coded entities) — the entité.md line declares ────── // `surchargeable à la création`: the CREATE surface gains an OPTIONAL // `string? Code = null` validated through the socle's ISuppliedCodeGuard and // applied BEFORE SaveChangesAsync (HasCode short-circuits the engine — the // CodedEntitySaveHandler idempotency contract). The imports/reprises path. // Updates NEVER carry Code. Opt-in strict: without the facet, nothing moves. const codedSupplied = isSupplied(spec.codedEntity) // Create honnête: the Create surface carries the CREATION fields — required // PLUS optional non-phased, as nullable members (an optional value typed on // the create form used to be silently dropped by model binding against the // required-only DTO). Lifecycle-phased fields stay excluded BY CONSTRUCTION // (a draft invoice can never receive its paymentDate through Create — the // capturing workflow action / the status-gated edit surface writes it). // SSOT lib/field-read-surface — scaffold-api-client derives the TS twin // surfaces from the SAME functions, so the record and the interface can // never disagree on a member again. const { required: createRequiredFields, optional: optionalCreateFields } = createSurfaceSplit(userFields, ownerName) const createInputFields = [...createRequiredFields, ...optionalCreateFields] const updatableFields = updateSurfaceFields(userFields, ownerName) // The entity factory ends with: … [owner (own modes)] [tenantId (tenantMode != none)]. // Build the trailing SERVER-side args once; the call sites splice them in. const factoryArgs = [ ...requiredFields.map(f => (f.name === ownerName ? 'ownerUserId' : `command.${f.name}`)), ...(ownerName && !requiredFields.some(f => f.name === ownerName) ? ['ownerUserId'] : []), ...(spec.tenantMode !== 'none' ? ['tenantId'] : []), // Optional creation fields flow as NAMED arguments — order-proof against // the entity factory's own optional-parameter ordering (its column list may // carry FK/scope columns this spec does not). ...optionalCreateFields.map(f => `${f.name.charAt(0).toLowerCase()}${f.name.slice(1)}: command.${f.name}`), ] const storedNames = new Set(storedFields.map(f => f.name)) const fieldByName = new Map(spec.fields.map(f => [f.name.toLowerCase(), f])) const hasProjections = userFields.some(f => f.source) // Lookup projection: SQL-translatable display field for {Entity}RefDto. // Falls back to Id.ToString() when no string field is available. const sourceExprByName = new Map( userFields .filter(f => f.source && f.type.toLowerCase() === 'string') .map(f => [f.name, projectField(f, storedNames, fieldByName)] as const), ) const displayField = pickDisplayField(spec.displayNameExpr, storedFields, sourceExprByName, spec.name, spec.classification) // ── Server-side list search + sort (GetAllAsync) ────────────────────────── // Search: OR of case-insensitive LIKE across EVERY stored string column (not // just the first one — a single-column search was effectively useless). FK // Guids / dates / numerics are excluded (they are not text). When the // DISPLAY is a Core projection (Person entities — the identity lives on // auth_Users), the projected expression joins the OR: without it "search a // driver by his name in his own directory" matched nothing (the list only // LIKE'd MobilePhone/DeactivationReason). When the entity has no string // column at all we keep the legacy Id.ToString() fallback. const searchableFields = storedFields.filter(f => f.type.toLowerCase() === 'string') const searchLegs = [ ...searchableFields.map(f => `EF.Functions.Like(x.${f.name}, $"%{query.Search}%")`), ...(displayField.projected ? [`EF.Functions.Like(${displayField.searchExpr}, $"%{query.Search}%")`] : []), ] const searchPredicate = searchLegs.length > 0 ? searchLegs.join(' || ') : `EF.Functions.Like(x.Id.ToString(), $"%{query.Search}%")` // Sort: a whitelisted switch over stored scalar columns + Id + CreatedAt. The // whitelist is the anti-injection guard — an unknown/absent SortBy falls back // to the default `CreatedAt DESC`, so no user string ever reaches a dynamic // property lookup. Keys are lowercased so a camelCase (`firstName`) or // PascalCase (`FirstName`) SortBy from the client both resolve. const sortArms = [ `"id" => asc ? q.OrderBy(x => x.Id) : q.OrderByDescending(x => x.Id),`, ...storedFields.map(f => `"${f.name.toLowerCase()}" => asc ? q.OrderBy(x => x.${f.name}) : q.OrderByDescending(x => x.${f.name}),`), `"createdat" => asc ? q.OrderBy(x => x.CreatedAt) : q.OrderByDescending(x => x.CreatedAt),`, ].map(a => ` ${a}`).join('\n') // ── Relation (FK) filters ───────────────────────────────────────────────── // Every stored Guid FK becomes an optional whitelisted filter on BOTH list // queries (`?clientId=`), so an entity IN RELATION can be listed from // another entity's 360 detail tab (Client → its Invoices via Invoice.ClientId). // Explicit `Guid?` param + explicit Where per FK — same anti-injection // posture as the sort whitelist; the camelCase wire name === the pagespec's // `relatedTabs[].relationFk` (see lib/page-spec-related-tabs.ts invariant). const fkFilterNames = storedFields.filter(f => isFkFilterField(f)).map(f => toPascalCase(f.name)) const fkQueryParams = fkFilterNames.map(n => `, Guid? ${n} = null`).join('') const fkWhereBlock = fkFilterNames.map(n => ` if (query.${n} is not null) { q = q.Where(x => x.${n} == query.${n}); } `).join('') // ── Screen filter params (pagespec filters[] → BOTH list queries) ───────── // Shared SSOT derivation (lib/page-spec-filters.ts) — the SAME ordered list // scaffold-screen-controller AND scaffold-controller expose as [FromQuery] // params and pass as named args, so every side stays in lockstep. FK fields // already covered by the Guid? channel above are skipped inside the lib. const fkFilterCamels = fkFilterNames.map(n => n.charAt(0).toLowerCase() + n.slice(1)) const listScreenFilters = screenFilterParams(spec.screenFilters, fkFilterCamels) const screenFilterQueryParams = listScreenFilters.map(p => `, ${p.csType} ${p.pascal} = null`).join('') // ── Scheduled jobs (derive-job-specs) — the § "aucun runtime scheduled" ── // One Task Run{X}Async(DateOnly runDate, ct) per scheduled UC. runDate // is the TESTABILITY + REPLAY seam (an explicit date re-runs a period); the // stub throws with the TODO[UC] marker business.todo-uc auto-heal consumes, // and the idempotence contract is spelled against the emission entity. const scheduledJobSigs = (spec.scheduledJobs ?? []) .map(j => `\n /// Scheduled pass of ${j.ucCode} (job '${j.jobId}'). Returns the emission count.\n Task ${j.methodName}(DateOnly runDate, CancellationToken ct = default);`) .join('') const scheduledJobMethods = (spec.scheduledJobs ?? []).map(j => ` /// Scheduled job '${j.jobId}' — ${j.ucCode}. runDate defaults to /// today (UTC); pass an explicit date to REPLAY a period (idempotent). public async Task ${j.methodName}(DateOnly runDate, CancellationToken ct = default) { var on = runDate == default ? DateOnly.FromDateTime(DateTime.UtcNow) : runDate; ${j.emissionEntity ? ` // Idempotence contract (${j.ucCode}): record each emission in ${j.emissionEntity} // and SKIP rows already emitted for 'on' — a replay of the same period // emits nothing new.` : ` // TODO[${j.ucCode}]: no emission entity modelled — the idempotence contract // has nowhere to write (model it in entité.md, ba.gap).`} _ = on; // TODO[${j.ucCode}]: implement the scheduled pass (query the due rows,${j.emissionEntity ? ` // emit through ${j.emissionEntity},` : ''} return the emission count). await Task.CompletedTask; throw new NotImplementedException(); }`).join('') // ── Screen filter predicates (GetAllAsync — the integration stratum) ────── // The pagespec filters[] are server-side on BOTH strata: the screens stratum // hand-implements them in GetForListScreenAsync (Phase 2b), the integration // stratum gets them GENERATED here — one guarded Where per param, applied // BEFORE CountAsync. The predicate target goes through projectField, so a // formula column filters on its inline expression (EF-translatable by the // same rule that lets it project). A param whose field matches no fields[] // entry keeps its wire param (lockstep with the controller and the // api-client) but gets a TODO instead of a predicate — implement it or fix // the pagespec; never a silently-dead filter. const screenFilterWhereBlock = listScreenFilters.map(p => { const target = fieldByName.get(p.field.toLowerCase()) if (!target) { return ` // TODO[filter:${p.name}]: no entity field matches '${p.field}' (computed DTO property?) — implement this predicate or fix the pagespec filter.\n` } const expr = projectField(target, storedNames, fieldByName) const base = baseCsType(target) switch (p.kind) { case 'equals': { if (base === 'Guid') { return ` if (!string.IsNullOrEmpty(query.${p.pascal}) && Guid.TryParse(query.${p.pascal}, out var ${p.name}Value)) { q = q.Where(x => ${expr} == ${p.name}Value); } ` } const left = base === 'string' ? expr : `${expr}.ToString()` return ` if (!string.IsNullOrEmpty(query.${p.pascal})) { q = q.Where(x => ${left} == query.${p.pascal}); } ` } case 'contains': { const left = base === 'string' ? expr : `${expr}.ToString()` return ` if (!string.IsNullOrEmpty(query.${p.pascal})) { q = q.Where(x => EF.Functions.Like(${left}, $"%{query.${p.pascal}}%")); } ` } case 'boolean': return ` if (query.${p.pascal} is not null) { q = q.Where(x => ${expr} == query.${p.pascal}.Value); } ` case 'date-from': { const bound = base === 'DateOnly' ? `DateOnly.FromDateTime(query.${p.pascal}.Value)` : `query.${p.pascal}.Value` return ` if (query.${p.pascal} is not null) { q = q.Where(x => ${expr} >= ${bound}); } ` } case 'date-to': { const bound = base === 'DateOnly' ? `DateOnly.FromDateTime(query.${p.pascal}.Value)` : `query.${p.pascal}.Value` return ` if (query.${p.pascal} is not null) { q = q.Where(x => ${expr} <= ${bound}); } ` } } }).join('') // ── Versioned (rowversion optimistic concurrency — the offline-write 409 path) ── // Mirror of scaffold-entity's `versioned` flag (socle IVersionedEntity / MyTime // pattern): the Detail DTO exposes the token, Update echoes it back, and // UpdateAsync turns a stale write into ConflictException(current server state) // — HTTP 409 — instead of silently overwriting. RowVersion is EF/DB-managed: // it never reaches Create, validators, search, sort or entity.Update(). const versioned = spec.versioned === true const detailRowVersionMember = versioned ? `, // Optimistic-concurrency token (rowversion, base64 on the wire) — echo it back on Update // so a stale offline write is detected (409) instead of silently overwriting. byte[]? RowVersion` : '' const detailProjTail = versioned ? ', x.RowVersion' : '' const updateRowVersionMember = versioned ? `${updatableFields.length > 0 ? ',\n' : ''} // Optimistic-concurrency token the client last read (base64). Echoed by the offline outbox // on an edit replay; null for online edits (no conflict check — last write wins as before). byte[]? RowVersion = null` : '' const updateCommandRecord = versioned ? `public record Update${e}Command( Guid Id${updatableFields.length > 0 ? ',\n' : ''}${updatableFields.map(f => ` ${mapType(f)} ${f.name}`).join(',\n')}, // Optimistic-concurrency token the client last read (base64). Echoed by the offline outbox on an // edit replay; null for online edits (no conflict check — last write wins as before). byte[]? RowVersion = null) : IRequest;` : `public record Update${e}Command(Guid Id${updatableFields.length > 0 ? ', ' : ''}${updatableFields.map(f => `${mapType(f)} ${f.name}`).join(', ')}) : IRequest;` // The conflict guard goes through DbSet.Entry(entity) — IExtensionsDbContext // only exposes Set() + SaveChangesAsync (the socle's ICoreDbContext exposes // Entry(), the extension abstraction does not). Same semantics. const updateSaveBlock = versioned ? ` // Offline-write conflict guard: when the client echoes the RowVersion it last read (offline // outbox replay), check the write against THAT version so a stale edit is rejected with 409 // + the current server state — instead of silently overwriting a change made meanwhile. // Online edits send no RowVersion and keep last-write-wins. if (command.RowVersion is { Length: > 0 }) { _context.Set<${e}>().Entry(entity).Property(e => e.RowVersion).OriginalValue = command.RowVersion; } try { await _context.SaveChangesAsync(ct); } catch (DbUpdateConcurrencyException) { // Stale offline write: reload the CURRENT server state and surface it in the 409 body // (ErrorResponse.Details) so the client outbox's onConflict hook can reconcile. var current = await GetByIdAsync(command.Id, ct); throw new ConflictException( "This ${e} was changed on the server since you last loaded it.", current); }` : ` await _context.SaveChangesAsync(ct);` const files: GeneratedFile[] = [] // 1. DTOs files.push({ path: `${appDir}/DTOs/${e}Dtos.cs`, content: `namespace ${appNs}.DTOs; // ListDto carries EVERY user field (parity with DetailDto), not a first-5 slice. // The frontend list types its rows from the pagespec columns and resolves FK // columns client-side from the FK Guid (useXLookup → id→label map). Truncating to // 5 dropped any FK Guid / Core-projected label beyond position 5 → the cell rendered // blank (the integration list's "raw Guid / empty cross-module label" bug). The C# DTO // is a superset of the TS list columns; extra JSON fields are ignored by the client. public record ${e}ListDto( Guid Id, ${userFields.map(f => ` ${mapReadType(f)} ${f.name},`).join('\n')} DateTime CreatedAt ); public record ${e}DetailDto( Guid Id, ${userFields.map(f => ` ${mapReadType(f)} ${f.name},`).join('\n')} DateTime CreatedAt, DateTime? UpdatedAt${detailRowVersionMember} ); public record Create${e}Dto( ${createInputFields.map(f => ` ${mapType(f)} ${f.name}`).join(',\n')}${codedSupplied ? `, /// Optional user-supplied code (picked/edited suggestion, import, reprise); when absent the engine allocates at save. string? Code = null` : ''} ); public record Update${e}Dto( ${updatableFields.map(f => ` ${mapType(f)} ${f.name}`).join(',\n')}${updateRowVersionMember} ); /// /// Lightweight reference DTO returned by GET /api/{module}/{plural}/lookup. /// Consumed by the frontend EntityLookup combobox to render FK selectors /// (Id is the FK value to persist; DisplayName is the human label). /// public record ${e}RefDto(Guid Id, string DisplayName); `, }) // 2. Commands files.push({ path: `${appDir}/Commands/Create${e}Command.cs`, content: `using MediatR; namespace ${appNs}.Commands; public record Create${e}Command(${createInputFields.map(f => `${mapType(f)} ${f.name}`).join(', ')}${codedSupplied ? ', string? Code = null' : ''}) : IRequest; `, }) files.push({ path: `${appDir}/Commands/Update${e}Command.cs`, content: `using MediatR; namespace ${appNs}.Commands; ${updateCommandRecord} `, }) files.push({ path: `${appDir}/Commands/Delete${e}Command.cs`, content: `using MediatR; namespace ${appNs}.Commands; public record Delete${e}Command(Guid Id) : IRequest; `, }) // 3. Queries files.push({ path: `${appDir}/Queries/Get${e}Query.cs`, content: `using MediatR; using SmartStack.Application.Common.Models; using ${appNs}.DTOs; namespace ${appNs}.Queries; public record Get${e}Query(Guid Id) : IRequest<${e}DetailDto?>; /// /// Integration list query — pagination, search and sort are SERVER-side, and /// every pagespec filter rides the wire too: one optional member per filter /// (select/text → string?, boolean → bool?, date-range → From/To), applied by /// the generated GetAllAsync as a guarded Where BEFORE CountAsync. Same member /// set and order as Get${e}ListScreenQuery — both strata filter server-side. /// public record Get${plural}Query(int Page = 1, int PageSize = 20, string? Search = null, string? SortBy = null, string? SortDir = null${fkQueryParams}${screenFilterQueryParams}) : IRequest>; /// /// Paginated lookup query feeding the frontend EntityLookup combobox. When /// Search is a Guid it resolves by Id (the combobox passes the selected FK to /// render its label); otherwise it is a case-insensitive LIKE on the display /// field (see ${e}Service.GetLookupAsync). /// public record Get${plural}LookupQuery(string? Search = null, int Page = 1, int PageSize = 20) : IRequest>; /// /// Screen-driven list query — the input for the screen stratum's /// GetForListScreenAsync (scaffold-screen-controller's GET /list). Carries the /// SAME page/size/search/sort contract as Get${plural}Query so BOTH strata /// paginate, search and sort SERVER-side, plus one optional member per pagespec /// filter (select/text → string?, boolean → bool?, date-range → From/To) — the /// hand-written GetForListScreenAsync body applies one predicate per non-null /// member BEFORE CountAsync (stored column → Where on it; computed column → /// implement the derivation). Scaffolder-owned — do NOT hand-redefine it; the /// service method that consumes it projects the pagespec columns and must /// reuse the same server-side Where/Count/Skip/Take primitive as GetAllAsync. /// public record Get${e}ListScreenQuery(int Page = 1, int PageSize = 20, string? Search = null, string? SortBy = null, string? SortDir = null${fkQueryParams}${screenFilterQueryParams}); `, }) // 4. Handlers — MANDATORY: MediatR cannot resolve a request without a matching handler. files.push({ path: `${appDir}/Handlers/Create${e}CommandHandler.cs`, content: `using MediatR; using ${appNs}.Commands; using ${appNs}.Interfaces; namespace ${appNs}.Handlers; public class Create${e}CommandHandler : IRequestHandler { private readonly I${e}Service _service; public Create${e}CommandHandler(I${e}Service service) => _service = service; public Task Handle(Create${e}Command request, CancellationToken cancellationToken) => _service.CreateAsync(request, cancellationToken); } `, }) files.push({ path: `${appDir}/Handlers/Update${e}CommandHandler.cs`, content: `using MediatR; using ${appNs}.Commands; using ${appNs}.Interfaces; namespace ${appNs}.Handlers; public class Update${e}CommandHandler : IRequestHandler { private readonly I${e}Service _service; public Update${e}CommandHandler(I${e}Service service) => _service = service; public async Task Handle(Update${e}Command request, CancellationToken cancellationToken) { await _service.UpdateAsync(request, cancellationToken); return Unit.Value; } } `, }) files.push({ path: `${appDir}/Handlers/Delete${e}CommandHandler.cs`, content: `using MediatR; using ${appNs}.Commands; using ${appNs}.Interfaces; namespace ${appNs}.Handlers; public class Delete${e}CommandHandler : IRequestHandler { private readonly I${e}Service _service; public Delete${e}CommandHandler(I${e}Service service) => _service = service; public async Task Handle(Delete${e}Command request, CancellationToken cancellationToken) { await _service.DeleteAsync(request.Id, cancellationToken); return Unit.Value; } } `, }) files.push({ path: `${appDir}/Handlers/Get${e}QueryHandler.cs`, content: `using MediatR; using ${appNs}.DTOs; using ${appNs}.Interfaces; using ${appNs}.Queries; namespace ${appNs}.Handlers; public class Get${e}QueryHandler : IRequestHandler { private readonly I${e}Service _service; public Get${e}QueryHandler(I${e}Service service) => _service = service; public Task<${e}DetailDto?> Handle(Get${e}Query request, CancellationToken cancellationToken) => _service.GetByIdAsync(request.Id, cancellationToken); } `, }) files.push({ path: `${appDir}/Handlers/Get${plural}QueryHandler.cs`, content: `using MediatR; using SmartStack.Application.Common.Models; using ${appNs}.DTOs; using ${appNs}.Interfaces; using ${appNs}.Queries; namespace ${appNs}.Handlers; public class Get${plural}QueryHandler : IRequestHandler> { private readonly I${e}Service _service; public Get${plural}QueryHandler(I${e}Service service) => _service = service; public Task> Handle(Get${plural}Query request, CancellationToken cancellationToken) => _service.GetAllAsync(request, cancellationToken); } `, }) // Lookup handler — paginated reference list for the frontend EntityLookup combobox. files.push({ path: `${appDir}/Handlers/Get${plural}LookupQueryHandler.cs`, content: `using MediatR; using SmartStack.Application.Common.Models; using ${appNs}.DTOs; using ${appNs}.Interfaces; using ${appNs}.Queries; namespace ${appNs}.Handlers; public class Get${plural}LookupQueryHandler : IRequestHandler> { private readonly I${e}Service _service; public Get${plural}LookupQueryHandler(I${e}Service service) => _service = service; public Task> Handle(Get${plural}LookupQuery request, CancellationToken cancellationToken) => _service.GetLookupAsync(request, cancellationToken); } `, }) // 5. Validators const validationRules = spec.businessRules.map(r => renderBusinessRule(r, userFields)).join('\n\n') // A rule expression that referenced the ambient clock was rewritten onto // `_time` (rewriteClockRefs) — the Validator then carries the TimeProvider // ctor dependency (resolved from DI; diPatches registers TimeProvider.System). const validatorsUseClock = validationRules.includes('_time.') const clockField = validatorsUseClock ? ' private readonly TimeProvider _time;\n\n' : '' const clockParam = validatorsUseClock ? 'TimeProvider time' : '' const clockAssign = validatorsUseClock ? ' _time = time;\n' : '' files.push({ path: `${appDir}/Validators/Create${e}CommandValidator.cs`, content: `using FluentValidation; using ${appNs}.Commands; namespace ${appNs}.Validators; public class Create${e}CommandValidator : AbstractValidator { ${clockField} public Create${e}CommandValidator(${clockParam}) { ${clockAssign}${createInputFields.filter(f => f.required && f.type === 'string').map(f => ` RuleFor(x => x.${f.name}).NotEmpty()${f.maxLength ? `.MaximumLength(${f.maxLength})` : ''};` ).join('\n')} ${validationRules} } } `, }) // 5 (cont.) — Update validator. Business rules are entity invariants: they must // hold on modification too, not only on creation. Mirrors the Create validator // over updatableFields (primary key + computed fields excluded). Any rule that // fell back to a `// TODO[BR-…]` comment is refined by the agent's post-scaffold // business-logic pass (see business-layer/SKILL.md § Post-scaffold pass). files.push({ path: `${appDir}/Validators/Update${e}CommandValidator.cs`, content: `using FluentValidation; using ${appNs}.Commands; namespace ${appNs}.Validators; public class Update${e}CommandValidator : AbstractValidator { ${clockField} public Update${e}CommandValidator(${clockParam}) { ${clockAssign} RuleFor(x => x.Id).NotEmpty(); ${updatableFields.filter(f => f.required && f.type === 'string').map(f => ` RuleFor(x => x.${f.name}).NotEmpty()${f.maxLength ? `.MaximumLength(${f.maxLength})` : ''};` ).join('\n')} ${validationRules} } } `, }) // 5b. Custom action artefacts (per-page mode) — Commands + Handlers per action. // Service interface + impl methods are emitted alongside the CRUD ones below. for (const action of spec.customActions) { const cmdName = `${pascalizeCode(action.code)}${e}Command` const handlerName = `${cmdName}Handler` const cmdParams = buildCustomActionCommandParams(action) const cmdResponse = action.responseDto === 'NoContent' ? 'Unit' : action.responseDto // Payload DTO record — the Command/Service reference `action.payloadDto` // but NOTHING else in the chain emits the type; when the projection // supplies the field specs (mirrored from the pagespec's // payloadParameters), materialise the record here. camelCase wire names // bind onto the PascalCase properties (case-insensitive JSON options). if (action.payloadDto && action.payloadFields && action.payloadFields.length > 0) { // All-optional payload → every member defaults to null, so the record // keeps an applicable parameterless construction and the controller's // `dto ?? new()` (EmptyBodyBehavior.Allow branch) compiles — without the // defaults, `new()` on a positional record is CS7036 (client defect // 2026-08-25 #3). A payload with ≥1 REQUIRED member emits NO defaults // (C# forbids a required param after a defaulted one): the controller // binds a MANDATORY [FromBody] there and never calls `new()`. const allOptional = action.payloadFields.every(p => !p.required) files.push({ path: `${appDir}/DTOs/${action.payloadDto}.cs`, content: `namespace ${appNs}.DTOs; /// /// Request body of the '${action.code}' custom action — fields mirror the /// pagespec's payloadParameters (collected by CustomActionDialog). /// public record ${action.payloadDto}( ${action.payloadFields.map(p => ` ${payloadFieldCsType(p)} ${capitalize(p.name)}${allOptional ? ' = null' : ''}`).join(`, `)} ); `, }) } files.push({ path: `${appDir}/Commands/${cmdName}.cs`, content: `using MediatR; ${action.payloadDto ? `using ${appNs}.DTOs;\n` : ''} namespace ${appNs}.Commands; public record ${cmdName}(${cmdParams}) : IRequest<${cmdResponse}>; `, }) const serviceCallArgs = customActionServiceCallArgs(action) const handlerBody = action.responseDto === 'NoContent' ? ` await _service.${pascalizeCode(action.code)}Async(${serviceCallArgs}); return Unit.Value;` : ` return await _service.${pascalizeCode(action.code)}Async(${serviceCallArgs});` files.push({ path: `${appDir}/Handlers/${handlerName}.cs`, content: `using MediatR; using ${appNs}.Commands; using ${appNs}.Interfaces; namespace ${appNs}.Handlers; public class ${handlerName} : IRequestHandler<${cmdName}, ${cmdResponse}> { private readonly I${e}Service _service; public ${handlerName}(I${e}Service service) => _service = service; public async Task<${cmdResponse}> Handle(${cmdName} request, CancellationToken cancellationToken) { ${handlerBody} } } `, }) } // 6. Service Interface const customInterfaceMethods = spec.customActions.map(a => { const sig = customActionServiceMethodSignature(a) return ` ${sig};` }).join('\n') files.push({ path: `${appDir}/Interfaces/I${e}Service.cs`, content: `using SmartStack.Application.Common.Models; using ${appNs}.Commands; using ${appNs}.DTOs; using ${appNs}.Queries; namespace ${appNs}.Interfaces; public interface I${e}Service { Task CreateAsync(Create${e}Command command, CancellationToken ct = default); Task UpdateAsync(Update${e}Command command, CancellationToken ct = default); Task DeleteAsync(Guid id, CancellationToken ct = default); Task<${e}DetailDto?> GetByIdAsync(Guid id, CancellationToken ct = default); Task> GetAllAsync(Get${plural}Query query, CancellationToken ct = default); Task> GetLookupAsync(Get${plural}LookupQuery query, CancellationToken ct = default);${scheduledJobSigs} ${customInterfaceMethods} } `, }) // 7. Service Implementation — uses IExtensionsDbContext (extension entities live // in the 'extensions' schema; Core entities outside the V1 whitelist are reached // via ICoreDataService — see ExampleService.cs.template). Set() is exposed on // IExtensionsDbContext so the in-memory ExtensionsDbContext can back unit tests. // Server-resolved factory inputs — mirrors the healed real-world pattern: // the DTO/Command never carry tenantId nor the owner; the service resolves // both from the ambient request (ICurrentTenantService / ICurrentUserAccessor). const needsTenant = spec.tenantMode !== 'none' const needsOwner = ownerName !== null const serviceUsings = [ 'using Microsoft.EntityFrameworkCore;', // ISuppliedCodeGuard (Application) + ICodedEntity (Domain) — the // supplied-on-create path; the cast `((ICodedEntity)entity).CodeKey` needs // the Domain namespace (scaffold-entity emits the key as an EXPLICIT // interface member). ...(codedSupplied ? ['using SmartStack.Application.Common.CodeGeneration;'] : []), 'using SmartStack.Application.Common.Exceptions;', ...(needsOwner ? ['using SmartStack.Application.Common.Interfaces.Identity;'] : []), ...(needsTenant ? ['using SmartStack.Application.Common.Interfaces.Tenants;'] : []), 'using SmartStack.Application.Common.Models;', ...(codedSupplied ? ['using SmartStack.Domain.CodeGeneration;'] : []), ].join('\n') const serviceDeps = [ { type: 'IExtensionsDbContext', field: '_context', param: 'context' }, ...(needsTenant ? [{ type: 'ICurrentTenantService', field: '_tenantService', param: 'tenantService' }] : []), ...(needsOwner ? [{ type: 'ICurrentUserAccessor', field: '_currentUser', param: 'currentUser' }] : []), ...(codedSupplied ? [{ type: 'ISuppliedCodeGuard', field: '_codeGuard', param: 'codeGuard' }] : []), ] const serviceFields = serviceDeps.map(d => ` private readonly ${d.type} ${d.field};`).join('\n') const serviceCtor = serviceDeps.length === 1 ? ` public ${e}Service(IExtensionsDbContext context) => _context = context;` : ` public ${e}Service(${serviceDeps.map(d => `${d.type} ${d.param}`).join(', ')}) { ${serviceDeps.map(d => ` ${d.field} = ${d.param};`).join('\n')} }` const createResolvers = [ ...(needsTenant ? [ ` var tenantId = _tenantService.TenantId`, ` ?? throw new InvalidOperationException("No tenant context available.");`, ] : []), ...(needsOwner ? [ ` // SERVER-assigned owner — never client input (spoof-proof; row visibility`, ` // hinges on it via the ${e}ScopePolicy "DataScope" filter).`, ` var ownerUserId = _currentUser.HasRequestContext`, ` ? _currentUser.UserId`, ` : throw new InvalidOperationException("No user context available.");`, ] : []), ] const createResolverBlock = createResolvers.length ? createResolvers.join('\n') + '\n' : '' files.push({ path: `${svcDir}/${e}Service.cs`, content: `${serviceUsings} using ${ns}.Application.Common.Interfaces; using ${ns}.Domain.Entities; using ${appNs}.Commands; using ${appNs}.DTOs; using ${appNs}.Interfaces; using ${appNs}.Queries; namespace ${svcNs}; public class ${e}Service : I${e}Service { // Row visibility (tenant isolation, own/assigned scope, soft delete) rides // the ExtensionsDbContext NAMED query filters — the "Tenant" filter is // mounted by scaffold-entity (TENANT-FILTERS markers; DEV-API-032). Never // call IgnoreQueryFilters() or FindAsync() here: FindAsync bypasses every // query filter, turning updates/deletes cross-tenant.${hasProjections ? ` // Core-projected fields (e.g. User.FirstName, TenantOrganisation.Name) are read // through the navigation INSIDE the LINQ projections below — EF Core // translates them to SQL JOINs. Do NOT add EF Include() calls for them: // unnecessary for projections, and forbidden in this service.` : ''} ${serviceFields} ${serviceCtor} public async Task CreateAsync(Create${e}Command command, CancellationToken ct = default) { ${createResolverBlock} var entity = ${e}.Create(${factoryArgs.join(', ')});${codedSupplied ? ` // Optional supplied code (import/reprise/picked suggestion) — validated // (shape + free) by the socle's ISuppliedCodeGuard, applied BEFORE // SaveChangesAsync so HasCode short-circuits the engine's allocation // (the CodedEntitySaveHandler idempotency contract). Updates never // touch Code. NO mask-conformance check by design — the mask says how // codes are GENERATED, not which codes are legal. if (!string.IsNullOrWhiteSpace(command.Code)) entity.ApplyCode(await _codeGuard.EnsureAvailableAsync(((ICodedEntity)entity).CodeKey, command.Code, ct));` : ''} _context.Set<${e}>().Add(entity); await _context.SaveChangesAsync(ct); return entity.Id; } public async Task UpdateAsync(Update${e}Command command, CancellationToken ct = default) { var entity = await _context.Set<${e}>().FirstOrDefaultAsync(x => x.Id == command.Id, ct) ?? throw new NotFoundException(nameof(${e}), command.Id); entity.Update(${updatableFields.map(f => `command.${f.name}`).join(', ')});${updateSaveBlock} } public async Task DeleteAsync(Guid id, CancellationToken ct = default) { var entity = await _context.Set<${e}>().FirstOrDefaultAsync(x => x.Id == id, ct) ?? throw new NotFoundException(nameof(${e}), id); _context.Set<${e}>().Remove(entity); await _context.SaveChangesAsync(ct); } public async Task<${e}DetailDto?> GetByIdAsync(Guid id, CancellationToken ct = default) { return await _context.Set<${e}>() .Where(x => x.Id == id) .Select(x => new ${e}DetailDto(x.Id, ${userFields.map(f => projectField(f, storedNames, fieldByName)).join(', ')}, x.CreatedAt, x.UpdatedAt${detailProjTail})) .FirstOrDefaultAsync(ct); } public async Task> GetAllAsync(Get${plural}Query query, CancellationToken ct = default) { var q = _context.Set<${e}>().AsQueryable(); if (!string.IsNullOrEmpty(query.Search)) { q = q.Where(x => ${searchPredicate}); } ${fkWhereBlock}${screenFilterWhereBlock} var total = await q.CountAsync(ct); var asc = (query.SortDir ?? "").ToLowerInvariant() == "asc"; var ordered = (query.SortBy ?? "").ToLowerInvariant() switch { ${sortArms} _ => q.OrderByDescending(x => x.CreatedAt), }; var items = await ordered .Skip((query.Page - 1) * query.PageSize).Take(query.PageSize) .Select(x => new ${e}ListDto(x.Id, ${userFields.map(f => projectField(f, storedNames, fieldByName)).join(', ')}, x.CreatedAt)) .ToListAsync(ct); return new PaginatedResult<${e}ListDto>(items, total, query.Page, query.PageSize); } public async Task> GetLookupAsync(Get${plural}LookupQuery query, CancellationToken ct = default) { // Display field is resolved at scaffold time (see pickDisplayField) so the // projection stays SQL-translatable. Search is case-insensitive LIKE on the // same field — same column read, single round-trip, EF.Functions.Like only. var q = _context.Set<${e}>().AsQueryable(); if (!string.IsNullOrEmpty(query.Search)) { // The frontend EntityLookup resolves a selected FK by calling this // endpoint with the Guid as the search term — so a Guid filters by Id // (exact), letting the combobox show the human label, not the raw Guid. // Anything else is a case-insensitive LIKE on the display field. if (Guid.TryParse(query.Search, out var lookupId)) { q = q.Where(x => x.Id == lookupId); } else { q = q.Where(x => EF.Functions.Like(${displayField.searchExpr}, $"%{query.Search}%")); } } var total = await q.CountAsync(ct); var items = await q.OrderBy(x => ${displayField.orderExpr}) .Skip((query.Page - 1) * query.PageSize).Take(query.PageSize) .Select(x => new ${e}RefDto(x.Id, ${displayField.projectExpr})) .ToListAsync(ct); return new PaginatedResult<${e}RefDto>(items, total, query.Page, query.PageSize); } ${spec.customActions.map(a => emitCustomActionServiceMethod(a, e)).join('\n')}${scheduledJobMethods} } `, }) return files } /** * Locations a PREVIOUS run may have written this entity's files to, that the * current run no longer emits. Two legacy axes, both derived from `generate()` * so they track the variable file set (custom-action commands/handlers * included): * - pre-classification directories (module-only, no ``); * - pre-2026-08-25 naive-plural file NAMES (`Category + 's'` → `Categorys`, * before the fallback moved to the shared `pluralize()`). A stale * `Get{E}sQueryHandler.cs` next to the new `Get{E}iesQueryHandler.cs` is a * duplicate `IRequestHandler<>` registration MediatR rejects at boot. * The CLI deletes any that exist before writing — re-running /ba-develop MOVES * the slice instead of leaving duplicates in the assembly. */ export function legacyPaths(spec: ScaffoldBusinessInput): string[] { const ns = spec.namespace ?? spec.appCode const appDir = applicationDir(ns, spec.applicationCode, spec.module) const svcDir = servicesDir(ns, spec.applicationCode, spec.module) const legacyApp = legacyApplicationDir(ns, spec.module) const legacySvc = legacyServicesDir(ns, spec.module) const toLegacyDirs = (p: string): string => p.startsWith(`${appDir}/`) ? `${legacyApp}${p.slice(appDir.length)}` : p.startsWith(`${svcDir}/`) ? `${legacySvc}${p.slice(svcDir.length)}` : p const current = generate(spec) const paths = current.map(f => toLegacyDirs(f.path)) // Naive-plural rename cleanup: only when the effective plural differs from // the old `+ 's'` fallback, and only for paths the current run does NOT // emit (we never delete a file we are about to rewrite). const naivePlural = `${spec.name}s` if ((spec.pluralName ?? pluralize(spec.name)) !== naivePlural) { const currentSet = new Set(current.map(f => f.path)) for (const f of generate({ ...spec, pluralName: naivePlural })) { if (currentSet.has(f.path)) continue paths.push(f.path, toLegacyDirs(f.path)) } } return [...new Set(paths)] } /** * Resolve the display projection for `{Entity}RefDto.DisplayName` used by * GET /lookup. The frontend EntityLookup combobox renders this string — it is * how the entity presents its rows in EVERY column/combobox that references * it, and what lookup search matches on. * * Priority: * 1. `displayNameExpr` — caller-provided PascalCase property name (stored * OR Core-projected). `Id` is the CONSCIOUS opt-out (`**Affichage** : Id` * in entité.md) — GUID display, accepted without error. * 2. Stored string field matching `Name` / `Label` / `Code` / `Title`. * 3. Core-PROJECTED display: `FirstName + " " + LastName` when both are * projected (the Person-mandatory shape — the entity's identity lives on * auth_Users, it has NO local name column by design), else the first * projected match of `DisplayName`/`FullName`/`Name`/`Label`/`Title`/ * `LastName`/`FirstName`/`Email`. * 4. NO silent fallback. The historical "first stored string, else GUID" * produced Driver→MobilePhone, Assignment→BackfillReason and four * GUID-labelled entities on one project — it always produced *something*, * so nothing ever surfaced. Now: hard error telling the author to declare * `**Affichage** : ` (or `: Id` to accept the GUID consciously). * * The returned expressions are interpolated into the EF Core LINQ pipeline of * `GetLookupAsync` (EF translates conditional projections to CASE WHEN inside * Like/OrderBy); `projected: true` additionally feeds the LIST search * predicate — "cannot search a driver by name in his own directory" was the * list half of this same defect. */ function pickDisplayField( displayNameExpr: string | undefined, storedFields: Field[], sourceExprByName: ReadonlyMap = new Map(), entityName = 'Entity', classification?: string, ): { searchExpr: string; orderExpr: string; projectExpr: string; projected: boolean } { const stringFields = storedFields.filter(f => f.type.toLowerCase() === 'string') // Conscious opt-out — `**Affichage** : Id` — the ONLY way to a GUID label. if (displayNameExpr === 'Id') { return { searchExpr: 'x.Id.ToString()', orderExpr: 'x.Id', projectExpr: 'x.Id.ToString()', projected: false } } // Explicit displayNameExpr may name a Core-projected string field (e.g. a // person entity whose only display value is User.FirstName) — all three // expressions then ARE the projection. if (displayNameExpr && !storedFields.some(f => f.name === displayNameExpr)) { const sourceExpr = sourceExprByName.get(displayNameExpr) if (sourceExpr) return { searchExpr: sourceExpr, orderExpr: sourceExpr, projectExpr: sourceExpr, projected: true } throw new Error( `scaffold-business(${entityName}): displayNameExpr '${displayNameExpr}' is neither a stored field nor a ` + `projected string field. Fix the **Affichage** line in entité.md (or the spec's fields[].source).`, ) } let chosen: string | null = null if (displayNameExpr && storedFields.some(f => f.name === displayNameExpr)) { chosen = displayNameExpr } else { // On a reference table the cascade drops `Code`: the label names the // row. Without this, re-scaffolding re-introduces the `A_FAIRE`-instead-of // -« À faire » regression on every combobox and every column. for (const preferred of preferredDisplayFieldsFor(classification)) { if (stringFields.some(f => f.name === preferred)) { chosen = preferred; break } } } if (chosen) { return { searchExpr: `x.${chosen}`, orderExpr: `x.${chosen}`, projectExpr: `x.${chosen}`, projected: false } } // Projected cascade — the Person-mandatory shape reaches here with NO local // name column at all (identity projected from auth_Users). const first = sourceExprByName.get('FirstName') const last = sourceExprByName.get('LastName') if (first && last) { const expr = `(${first} + " " + ${last})` return { searchExpr: expr, orderExpr: sourceExprByName.get('LastName')!, projectExpr: expr, projected: true } } for (const preferred of PREFERRED_PROJECTED_DISPLAY_FIELDS) { const expr = sourceExprByName.get(preferred) if (expr) return { searchExpr: expr, orderExpr: expr, projectExpr: expr, projected: true } } // FAIL-CLOSED — no silent "first string / GUID" fallback: it always produced // *something* (a phone number, a free comment, a raw GUID), so it never // triggered anything anywhere. throw new Error( `scaffold-business(${entityName}): no display field. The entity has no Name/Label/Code/Title stored string, ` + `no projected identity (FirstName/LastName/…), and no displayNameExpr. Declare '**Affichage** : ' in ` + `entité.md (ba-develop Phase 2 passes it as displayNameExpr) — or '**Affichage** : Id' to CONSCIOUSLY accept ` + `a GUID label. Refusing to ship rows labelled by the first string column (phone numbers, free comments) or a GUID.`, ) } /** Pure projection = `source` field whose value lives ONLY in the Core entity * (no local column). The person-optional overlay (fallbackLocal === own name) * is NOT pure: its local column stays stored/writable. */ function isPureProjection(f: Field): boolean { // Delegated to the shared surface SSOT (lib/field-read-surface) — the // api-client imports the same predicate for its TS twin. return isPureProjectionShared(f) } function baseCsType(f: Field): string { const map: Record = { 'string': 'string', 'int': 'int', 'integer': 'int', 'number': 'decimal', 'decimal': 'decimal', 'bool': 'bool', 'boolean': 'bool', 'datetime': 'DateTime', 'date': 'DateOnly', 'guid': 'Guid', } return map[f.type.toLowerCase()] ?? f.type } function mapType(f: Field): string { const base = baseCsType(f) return !f.required ? `${base}?` : base } /** Read-DTO type: alias of mapType now that EVERY non-required field — * strings included — carries the explicit `?` (a Core-projected optional * string coalesces to null when the guarding FK is null). */ function mapReadType(f: Field): string { // A derived member is nullable on the READ surface regardless of `required`: // the projection legitimately yields null (no open period, no reading yet). if (f.derived) { const base = baseCsType(f) return base.endsWith('?') ? base : `${base}?` } return mapType(f) } /** * Render a field accessor inside a `.Select(x => new Dto(...))` projection. * - Stored fields → `x.FieldName` (regular column read). * - Computed fields (with `formula`) → the formula, with each stored property * reference prefixed by `x.` so EF Core can translate it to SQL. Tokens that * don't match a stored field name (Math.Round, literals, …) are left intact. * - Core-projected fields (with `source`) → a read through the navigation * property; EF Core translates it to a JOIN (no Include needed): * FK required → `x.User.FirstName` * FK nullable + fallback → `(x.UserId != null ? x.User!.Email : x.Email)` * FK nullable, no fallback→ `(x.CustomerCompanyId != null ? x.Customer!.Name : null)` * (value types get an explicit `(T?)` cast so the conditional types out) * * Wrapped in parentheses so binary expressions (e.g. `(A - B) / A`) compose * cleanly inside the comma-separated argument list of the DTO record ctor. */ function projectField(f: Field, storedNames: Set, fieldByName: ReadonlyMap = new Map()): string { if (f.derived) { // Derived column (lib/derived-field): nav hop or dated-child pick — the // fil-rouge « colonne dérivée ». Value types surface nullable so "no open // period yet" reads null, never a misleading zero. return derivedExpr(f.type, f.derived, fk => fieldByName.get(fk.toLowerCase())?.required === false) } if (f.source) { const fk = f.source.fkField ?? `${f.source.nav}Id` const fkNullable = fieldByName.get(fk.toLowerCase())?.required === false const navRead = `x.${f.source.nav}${fkNullable ? '!' : ''}.${f.source.property}` if (!fkNullable) return navRead if (f.source.fallbackLocal) return `(x.${fk} != null ? ${navRead} : x.${f.source.fallbackLocal})` const base = baseCsType(f) const cast = base === 'string' ? '' : `(${base}?)` return `(x.${fk} != null ? ${cast}${navRead} : null)` } if (!f.formula) return `x.${f.name}` const projected = f.formula.replace(/\b([A-Z][A-Za-z0-9]*)\b/g, (token) => storedNames.has(token) ? `x.${token}` : token, ) return `(${projected})` } function capitalize(s: string): string { return s.charAt(0).toUpperCase() + s.slice(1) } /** C# type of one payload field — mirrors what CustomActionDialog collects on * the JSON wire (lib/page-spec-actions.ts payloadParamTsType): strings for * text/textarea/select (and file — no multipart path, PRD-107 flags it), an * ISO date for date (binds DateOnly), a Guid string for lookup, a number for * number. Non-required fields are nullable (the dialog may leave them empty). */ function payloadFieldCsType(p: { type: string; required: boolean }): string { const base = p.type === 'number' ? 'decimal' : p.type === 'date' ? 'DateOnly' : p.type === 'lookup' ? 'Guid' : 'string' return p.required ? base : `${base}?` } /** Convert a kebab-case action code (`bulk-archive`) to PascalCase (`BulkArchive`) * for C# class / method names. */ function pascalizeCode(code: string): string { return code.split('-').map(part => capitalize(part)).join('') } // ─── Custom action emission ──────────────────────────────────────────── /** GET actions transport their `queryParameters` as scalar NULLABLE values — * same dialog-type mapping as the payload DTO members, always `?` (the * controller binds them `[FromQuery]`, where every parameter is optional). */ function queryParamCsType(type: string): string { return payloadFieldCsType({ type, required: false }) } /** * Build the parameter list for a custom action MediatR Command record. * - row scope → `(Guid Id)` or `(Guid Id, Payload)` * - bulk scope → `( Payload)` or `(IReadOnlyList Ids)` (no payload fallback) * - header scope → `( Payload)` or `()` (no payload fallback) * GET `queryParameters` append as PascalCase scalar members (`decimal? Year`). */ function buildCustomActionCommandParams(action: BusinessCustomAction): string { const parts: string[] = [] if (action.scope === 'row') { parts.push('Guid Id') if (action.payloadDto) parts.push(`${action.payloadDto} Payload`) } else if (action.scope === 'bulk') { parts.push(action.payloadDto ? `${action.payloadDto} Payload` : 'IReadOnlyList Ids') } else if (action.payloadDto) { // header scope parts.push(`${action.payloadDto} Payload`) } for (const q of action.queryParameters ?? []) { parts.push(`${queryParamCsType(q.type)} ${capitalize(q.name)}`) } return parts.join(', ') } /** * Build the argument list for the service call inside a Handler. Mirrors * `buildCustomActionCommandParams`'s field names ("Id" / "Payload" / "Ids" / * the PascalCase query members). Always appends `cancellationToken` at the end. */ function customActionServiceCallArgs(action: BusinessCustomAction): string { const parts: string[] = [] if (action.scope === 'row') { parts.push('request.Id') if (action.payloadDto) parts.push('request.Payload') } else if (action.scope === 'bulk') { parts.push(action.payloadDto ? 'request.Payload' : 'request.Ids') } else { if (action.payloadDto) parts.push('request.Payload') } for (const q of action.queryParameters ?? []) { parts.push(`request.${capitalize(q.name)}`) } parts.push('cancellationToken') return parts.join(', ') } /** Shared parameter list of the service SIGNATURE and IMPLEMENTATION for a * custom action — one derivation so interface and impl cannot drift, and so * the order matches the controller's call (`id, [dto,] [query…,] ct`). */ function customActionServiceParams(action: BusinessCustomAction): string[] { const params: string[] = [] if (action.scope === 'row') { params.push('Guid id') if (action.payloadDto) params.push(`${action.payloadDto} payload`) } else if (action.scope === 'bulk') { params.push(action.payloadDto ? `${action.payloadDto} payload` : 'IReadOnlyList ids') } else { if (action.payloadDto) params.push(`${action.payloadDto} payload`) } for (const q of action.queryParameters ?? []) { params.push(`${queryParamCsType(q.type)} ${q.name}`) } params.push('CancellationToken ct = default') return params } /** * Build the C# method signature on `I${e}Service` for a custom action. * - `archive` row no-payload → `Task ArchiveAsync(Guid id, CancellationToken ct = default)` * - `approve` row + payload → `Task ApproveAsync(Guid id, ApproveRequest payload, CancellationToken ct = default)` * - bulk-archive bulk no-pl → `Task ArchiveAsync(IReadOnlyList ids, CancellationToken ct = default)` * - `impact` GET + queryParams → `Task ImpactAsync(Guid id, decimal? year, CancellationToken ct = default)` */ function customActionServiceMethodSignature(action: BusinessCustomAction): string { const methodName = `${pascalizeCode(action.code)}Async` const returnType = action.responseDto === 'NoContent' ? 'Task' : `Task<${action.responseDto}>` return `${returnType} ${methodName}(${customActionServiceParams(action).join(', ')})` } /** * Emit the C# implementation block for a custom action service method. * Canonical recipes for known state-mutation codes; NotImplementedException * stub (with UC reference) for unknown codes — flags the body for manual * implementation while keeping the project compilable. */ function emitCustomActionServiceMethod(action: BusinessCustomAction, entity: string): string { const methodName = `${pascalizeCode(action.code)}Async` const returnType = action.responseDto === 'NoContent' ? 'Task' : `Task<${action.responseDto}>` const signature = `public async ${returnType} ${methodName}(${customActionServiceParams(action).join(', ')})` // Canonical recipes for state-mutation codes. Only fire when scope='row', // payloadDto is null, responseDto is NoContent — the simplest baseline. // More complex variants (scope=bulk, custom payload) fall through to the // generic stub regardless of code (the BA must specify the semantics). const isCanonicalRow = action.scope === 'row' && !action.payloadDto && action.responseDto === 'NoContent' let body: string if (isCanonicalRow && action.code === 'archive') { body = ` var entity = await _context.Set<${entity}>().FirstOrDefaultAsync(x => x.Id == id, ct) ?? throw new NotFoundException(nameof(${entity}), id); // Canonical archive recipe — set Status='archived'. If the entity // does not carry a Status property, replace this body manually. entity.GetType().GetProperty("Status")?.SetValue(entity, "archived"); await _context.SaveChangesAsync(ct);` } else if (isCanonicalRow && action.code === 'restore') { body = ` var entity = await _context.Set<${entity}>().FirstOrDefaultAsync(x => x.Id == id, ct) ?? throw new NotFoundException(nameof(${entity}), id); // Canonical restore recipe — flip Status back to 'active'. entity.GetType().GetProperty("Status")?.SetValue(entity, "active"); await _context.SaveChangesAsync(ct);` } else if (isCanonicalRow && action.code === 'activate') { body = ` var entity = await _context.Set<${entity}>().FirstOrDefaultAsync(x => x.Id == id, ct) ?? throw new NotFoundException(nameof(${entity}), id); // Canonical activate recipe — IsActive=true. entity.GetType().GetProperty("IsActive")?.SetValue(entity, true); await _context.SaveChangesAsync(ct);` } else if (isCanonicalRow && action.code === 'deactivate') { body = ` var entity = await _context.Set<${entity}>().FirstOrDefaultAsync(x => x.Id == id, ct) ?? throw new NotFoundException(nameof(${entity}), id); // Canonical deactivate recipe — IsActive=false. entity.GetType().GetProperty("IsActive")?.SetValue(entity, false); await _context.SaveChangesAsync(ct);` } else if ( action.scope === 'row' && !action.payloadDto && action.responseDto !== 'NoContent' && action.code === 'duplicate' ) { body = ` var source = await _context.Set<${entity}>().FirstOrDefaultAsync(x => x.Id == id, ct) ?? throw new NotFoundException(nameof(${entity}), id); // Canonical duplicate recipe — reflection-based deep clone with new Id. // Replace with an explicit copy if you need to alter specific fields // (e.g. label += " (copy)"). var clone = (${entity})source.GetType().GetMethod("Clone")?.Invoke(source, null)!; clone.GetType().GetProperty("Id")?.SetValue(clone, Guid.NewGuid()); _context.Set<${entity}>().Add(clone); await _context.SaveChangesAsync(ct); return await GetByIdAsync(clone.Id, ct) ?? throw new InvalidOperationException("Duplicated entity vanished");` } else if (action.transitionMatrix && action.transitionMatrix.length > 0) { // ─── Kanban move recipe — the FULL transition-matrix guard ───────── // The dynamic sibling of the single-edge workflow recipe below: the // target status arrives in the payload (the board drop / the table's // move dialog), so the guard is the whole BR Flow matrix — the SAME // edges the generated board inlines as ALLOWED_TRANSITIONS. A forged // POST can never take a transition the UI refuses. const lines: string[] = [] const ucTag = action.ucReference ? `[${action.ucReference}]` : '' const statusProperty = action.statusProperty ?? 'Status' // The wire param carrying the target: the fieldAssignments entry bound to // the status property, else the camel of the property itself. const statusAssignment = (action.fieldAssignments ?? []).find( fa => fa.field.charAt(0).toUpperCase() + fa.field.slice(1) === statusProperty, ) const statusParam = statusAssignment?.param ?? statusProperty.charAt(0).toLowerCase() + statusProperty.slice(1) const statusParamPascal = statusParam.charAt(0).toUpperCase() + statusParam.slice(1) if (action.crossModuleDeps && action.crossModuleDeps.length > 0) { for (const dep of action.crossModuleDeps) { lines.push(` // BLOCKED: depends on module '${dep}' — stub until delivered.`) } } lines.push(` var entity = await _context.Set<${entity}>().FirstOrDefaultAsync(x => x.Id == id, ct)`) lines.push(` ?? throw new NotFoundException(nameof(${entity}), id);`) lines.push(``) lines.push(` // Allowed transitions (BR Flow matrix — the board refuses the same drops) ${ucTag}`.trimEnd()) // Per-edge BR trace (DEV-API-008 reads the module-scoped BR-NNN token) — // NOT a TODO marker: the matrix below IS the enforcement. const ruleEdges = new Map() for (const t of action.transitionMatrix) { if (t.rule === undefined) continue const edges = ruleEdges.get(t.rule) ?? [] edges.push(`${t.from} → ${t.to}`) ruleEdges.set(t.rule, edges) } for (const [rule, edges] of [...ruleEdges.entries()].sort(([a], [b]) => a.localeCompare(b))) { lines.push(` // Enforced ${rule}: ${edges.join(', ')}`) } const byFrom = new Map() for (const t of action.transitionMatrix) { if (t.from === t.to) continue const targets = byFrom.get(t.from) ?? [] if (!targets.includes(t.to)) targets.push(t.to) byFrom.set(t.from, targets) } lines.push(` var allowedTransitions = new Dictionary`) lines.push(` {`) for (const from of [...byFrom.keys()].sort()) { const targets = byFrom.get(from)!.sort().map(t => `"${t}"`).join(', ') lines.push(` ["${from}"] = new[] { ${targets} },`) } lines.push(` };`) lines.push(` var targetStatus = payload.${statusParamPascal};`) const errPrefix = action.statusErrorCode ? `[${action.statusErrorCode}] ` : '' lines.push(` if (string.IsNullOrEmpty(targetStatus)`) lines.push(` || !allowedTransitions.TryGetValue(entity.${statusProperty}, out var allowedTargets)`) lines.push(` || !allowedTargets.Contains(targetStatus))`) lines.push(` throw new BusinessRuleException($"${errPrefix}Cannot '${action.code}': transition '{entity.${statusProperty}}' → '{targetStatus}' is not allowed.");`) // Non-status field assignments still apply (a move dialog may capture a // motif alongside the target status). const otherAssignments = (action.fieldAssignments ?? []).filter(fa => fa !== statusAssignment) if (otherAssignments.length > 0) { lines.push(``) for (const fa of otherAssignments) { const paramPascal = fa.param.charAt(0).toUpperCase() + fa.param.slice(1) const fieldPascal = fa.field.charAt(0).toUpperCase() + fa.field.slice(1) lines.push(` entity.${fieldPascal} = payload.${paramPascal};`) } } lines.push(``) lines.push(` entity.${statusProperty} = targetStatus;`) lines.push(` await _context.SaveChangesAsync(ct);`) body = lines.join('\n') } else if ((action.fromStatus && action.fromStatus.length > 0) || action.toStatus) { // ─── Workflow state-transition recipe (SK-001) ───────────────────── // When fromStatus/toStatus metadata is supplied, generate a real // state-transition body instead of NotImplementedException. const lines: string[] = [] const ucTag = action.ucReference ? `[${action.ucReference}]` : '' // Cross-module dependency warning (SK-004) if (action.crossModuleDeps && action.crossModuleDeps.length > 0) { for (const dep of action.crossModuleDeps) { lines.push(` // BLOCKED: depends on module '${dep}' — stub until delivered.`) } } lines.push(` var entity = await _context.Set<${entity}>().FirstOrDefaultAsync(x => x.Id == id, ct)`) lines.push(` ?? throw new NotFoundException(nameof(${entity}), id);`) // Guard: check current status (a transition may be reachable from several states) if (action.fromStatus && action.fromStatus.length > 0) { const froms = action.fromStatus const expected = froms.join(', ') const cond = froms.map((s) => `entity.Status != "${s}"`).join(' && ') lines.push(``) lines.push(` // Guard: entity must be in ${froms.length > 1 ? 'one of ' : ''}'${expected}' state ${ucTag}`) lines.push(` if (${cond})`) lines.push(` throw new BusinessRuleException($"Cannot '${action.code}': entity is in '{entity.Status}', expected '${expected}'.");`) } // Guard rules as comments if (action.guardRules && action.guardRules.length > 0) { lines.push(``) for (const rule of action.guardRules) { // Canonical TODO[BR-…] marker — the DEV-API-008 todo leg reads it; the // old `Guard BR-x — TODO:` shape escaped the marker regex while its // substring SATISFIED the trace leg (an unimplemented guard counted as // "enforced" — audit finding, chantier 2.3). lines.push(` // Guard ${rule} — TODO[${rule}]: implement rule check`) } } // Field assignments (lifecycle action↔field binding): explicit // `payloadParameters[].field` bindings first, then the legacy // identity-mapped flowParameters for any name not already covered (SK-002). const assignments = action.fieldAssignments ?? [] const coveredParams = new Set(assignments.map(fa => fa.param)) const legacyFlowParams = (action.flowParameters ?? []).filter(p => !coveredParams.has(p)) if (assignments.length > 0 || legacyFlowParams.length > 0) { lines.push(``) for (const fa of assignments) { const paramPascal = fa.param.charAt(0).toUpperCase() + fa.param.slice(1) const fieldPascal = fa.field.charAt(0).toUpperCase() + fa.field.slice(1) lines.push(` entity.${fieldPascal} = payload.${paramPascal};`) } for (const param of legacyFlowParams) { const pascal = param.charAt(0).toUpperCase() + param.slice(1) lines.push(` entity.${pascal} = payload.${pascal};`) } } // Transition: set new status if (action.toStatus) { lines.push(``) lines.push(` // Transition → '${action.toStatus}' ${ucTag}`) lines.push(` entity.Status = "${action.toStatus}";`) } lines.push(` await _context.SaveChangesAsync(ct);`) body = lines.join('\n') } else { // Unknown / non-canonical code — stub with UC reference comment. const ucComment = action.ucReference ? ` // TODO[${action.ucReference}]: implement '${action.code}' per the linked use case.` : ` // TODO: implement '${action.code}' — no canonical recipe and no UC reference provided.` // Cross-module dependency warning (SK-004) const depComments = (action.crossModuleDeps ?? []) .map(dep => ` // BLOCKED: depends on module '${dep}' — stub until delivered.`) .join('\n') const stubReturn = action.responseDto === 'NoContent' ? ' throw new NotImplementedException();' : ` throw new NotImplementedException(); // return default!;` body = `${depComments ? depComments + '\n' : ''}${ucComment} ${stubReturn}` } return ` ${signature} { ${body} } ` } /** * A business-rule expression that reads the ambient clock. Such a guard is * untestable at its boundaries (you cannot sit the test ON the threshold), so * the generator rewrites the reference onto an injected `TimeProvider` and * gives the Validator the ctor dependency (`rewriteClockRefs`). Shared with * `diPatches` (registers `TimeProvider.System` once) — one detection, three * consumers. */ export const CLOCK_REF_RE = /\bDateTime\.(UtcNow|Now|Today)\b/ function rewriteClockRefs(body: string): string { return body .replace(/\bDateTime\.UtcNow\b/g, '_time.GetUtcNow().UtcDateTime') .replace(/\bDateTime\.Now\b/g, '_time.GetLocalNow().DateTime') .replace(/\bDateTime\.Today\b/g, '_time.GetLocalNow().Date') } /** * Convert a PRD business rule into a FluentValidation rule block. * Order of precedence: * 1. Explicit `expression` field (raw FluentValidation code). * 2. Description heuristics (required / range / pattern / comparison). * 3. TODO comment (fallback — flagged for manual rewrite). */ // Allocator-spec expression shapes a `numbering` rule carries: the legacy // pseudo-code (`nextSeq(scope=…)`) and the declarative form // (`format = "AFF-{YY}-{SEQ:4}" ; scope = tenant ; …`). Belt-and-braces net // under the ruleType check below, for hand-assembled specs that dropped it. const NUMBERING_EXPRESSION_RE = /\bnextSeq\s*\(|\bformat\s*=\s*"[^"]*\{[A-Z]/ function renderBusinessRule(rule: BusinessRule, fields: Field[]): string { // The BA weight/kind ride the trace comment — they used to be dropped at // this boundary, leaving the reviewer blind to whether a `// BR-…` was an // err invariant or an info note. const tag = rule.severity || rule.ruleType ? ` [${[rule.severity, rule.ruleType].filter(Boolean).join('·')}]` : '' const header = ` // ${rule.id}${tag}: ${rule.description}` // A `numbering` rule is the SPEC of the socle's code allocator, never code // to emit here: CodedEntitySaveHandler allocates the Code atomically at // insert (Code pattern seam, gated by DEV-API-022). Splicing its expression // used to inject `nextSeq(...)` pseudo-code (does not compile → "repaired" // by hand) or fall through to the TODO comment — both read as "implement // me" and bred hand-rolled allocators (caught downstream by DEV-API-034). if (rule.ruleType?.toLowerCase() === 'numbering' || NUMBERING_EXPRESSION_RE.test(rule.expression ?? '')) { return ( `${header}\n` + ` // System-allocated code — the socle's CodedEntitySaveHandler allocates it at insert\n` + ` // (Code pattern seam, gate DEV-API-022). DO NOT implement: no RuleFor, no counter, no sequence query.` ) } if (rule.expression) { const body = rewriteClockRefs(rule.expression.trim()) const statement = body.endsWith(';') ? body : `${body};` return `${header}\n ${statement}` } const heuristic = deriveHeuristic(rule.description, fields) if (heuristic) { return `${header}\n${heuristic}` } return `${header}\n // TODO[${rule.id}]: No expression and no heuristic matched — translate this rule manually.` } function deriveHeuristic(description: string, fields: Field[]): string | null { const text = description.toLowerCase() // "X is required" / "X must not be empty" / "X cannot be null" const requiredField = fields.find(f => { const lower = f.name.toLowerCase() return text.includes(lower) && (text.includes('required') || text.includes('not be empty') || text.includes('cannot be null') || text.includes('must not be empty')) }) if (requiredField) { return ` RuleFor(x => x.${requiredField.name}).NotEmpty().WithMessage("${requiredField.name} is required");` } // "X must be positive" / "X must be greater than 0" const positiveField = fields.find(f => text.includes(f.name.toLowerCase()) && (text.includes('positive') || text.includes('greater than 0'))) if (positiveField) { return ` RuleFor(x => x.${positiveField.name}).GreaterThan(0).WithMessage("${positiveField.name} must be positive");` } // "X must be before Y" / "X must be less than Y" const beforeMatch = /(\w+)\s+(?:must be )?(?:before|less than)\s+(\w+)/.exec(text) if (beforeMatch) { const left = fields.find(f => f.name.toLowerCase() === beforeMatch[1]) const right = fields.find(f => f.name.toLowerCase() === beforeMatch[2]) if (left && right) { return ` RuleFor(x => x.${left.name}).LessThan(x => x.${right.name}).WithMessage("${left.name} must be before ${right.name}");` } } // "X must match pattern /regex/" — naive const patternMatch = /(\w+)\s+must\s+match\s+([^\s]+)/.exec(text) if (patternMatch) { const field = fields.find(f => f.name.toLowerCase() === patternMatch[1]) if (field) { return ` RuleFor(x => x.${field.name}).Matches(@"${patternMatch[2]}").WithMessage("${field.name} has invalid format");` } } // "X must be between A and B" const rangeMatch = /(\w+)\s+must\s+be\s+between\s+(-?\d+(?:\.\d+)?)\s+and\s+(-?\d+(?:\.\d+)?)/.exec(text) if (rangeMatch) { const field = fields.find(f => f.name.toLowerCase() === rangeMatch[1]) if (field) { return ` RuleFor(x => x.${field.name}).InclusiveBetween(${rangeMatch[2]}, ${rangeMatch[3]}).WithMessage("${field.name} must be between ${rangeMatch[2]} and ${rangeMatch[3]}");` } } return null } // ─── DI registration patches (§ services never registered) ────────────────── export interface DiPatch { /** Host file candidates relative to the project root — patch the FIRST that exists. */ hostCandidates: string[] begin: string end: string /** ONE line to union into the block (lib/di-markers.mergeLineIntoMarkerBlock). */ line?: string /** Whole-block replacement (lib/di-markers.spliceDiMarkerBlock). */ block?: string /** Skip splicing `block` when this needle already lives in the host OUTSIDE * the markers (the project wired it by hand — splicing would double it). */ skipIfPresent?: string } export const BUSINESS_DI_BEGIN = '// <<< BUSINESS-SERVICES-DI BEGIN >>>' export const BUSINESS_DI_END = '// <<< BUSINESS-SERVICES-DI END >>>' export const CLIENT_APP_DI_BEGIN = '// <<< CLIENT-APPLICATION-ASSEMBLY-DI BEGIN >>>' export const CLIENT_APP_DI_END = '// <<< CLIENT-APPLICATION-ASSEMBLY-DI END >>>' /** * The DI registrations THIS entity's generated slice needs to be resolvable at * runtime. Nothing else registers them: the controller injects `I{E}Service` * directly (no MediatR indirection for the service itself), so a missing * AddScoped is a 500 on the FIRST request — with a green build, green audits * and green unit tests behind it, since none of those exercise the container. * * Two patches: * 1. `AddScoped` — one line per entity, unioned into * the `BUSINESS-SERVICES-DI` marker block of the Infrastructure DI host. * Types are `global::`-qualified (scaffold-core-seed discipline): the host * needs no `using` we cannot safely inject. * 2. The client Application assembly scan (MediatR handlers + validators) — * the platform's AddSmartStack() scans ONLY its own assembly; without this * block every generated Handler/Validator of the client assembly is * invisible to MediatR. Whole-block, skipped when the project already * calls RegisterServicesFromAssembly by hand. */ export function diPatches(spec: ScaffoldBusinessInput): DiPatch[] { const ns = spec.namespace ?? spec.appCode const e = spec.name const appNs = applicationNs(ns, spec.applicationCode, spec.module) const svcNs = servicesNs(ns, spec.applicationCode, spec.module) const infraHosts = [ `src/${ns}.Infrastructure/DependencyInjection.cs`, `src/${ns}.Infrastructure/ServiceCollectionExtensions.cs`, `src/${ns}.Infrastructure/InfrastructureModule.cs`, ] const patches: DiPatch[] = [ { hostCandidates: infraHosts, begin: BUSINESS_DI_BEGIN, end: BUSINESS_DI_END, line: `services.AddScoped();`, }, { hostCandidates: [`src/${ns}.Application/DependencyInjection.cs`], begin: CLIENT_APP_DI_BEGIN, end: CLIENT_APP_DI_END, block: [ ` ${CLIENT_APP_DI_BEGIN}`, ' services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly));', ' services.AddValidatorsFromAssembly(typeof(DependencyInjection).Assembly);', ` ${CLIENT_APP_DI_END}`, ].join('\n'), skipIfPresent: 'RegisterServicesFromAssembl', }, ] // A clock-referencing rule gave the Validators a TimeProvider ctor // dependency — land its registration alongside the AddScoped lines. // TimeProvider.System is the production instance; tests swap in a // FakeTimeProvider through the same seam. Idempotent re-registration is // harmless (same singleton instance either way). if (spec.businessRules.some(r => r.expression && CLOCK_REF_RE.test(r.expression))) { patches.push({ hostCandidates: infraHosts, begin: BUSINESS_DI_BEGIN, end: BUSINESS_DI_END, line: 'services.AddSingleton(global::System.TimeProvider.System);', skipIfPresent: 'TimeProvider.System', }) } return patches }