/** * cli:scaffold-seed — generate.ts * Generates IClientSeedDataProvider implementation. * * Consumes SmartStack NuGet packages: * - SmartStack.Application.Common.Interfaces.Seeding.IClientSeedDataProvider * - SmartStack.Domain.Navigation (NavigationApplication/Module/Section/Resource entities) * - SmartStack.Domain.Authorization (Permission) + SmartStack.Domain.Platform.Administration.Roles (Role) * - SmartStack.Application.Common.Interfaces.Persistence (ICoreDbContext) * * Idempotence rules (SmartStack convention): * - `Order` stable across runs * - GUIDs declared as `static readonly` once per provider instance; Guid.NewGuid() * is only called the first time the class is loaded. Re-running seed does NOT * regenerate GUIDs. * - Each `Seed*Async` checks existence before inserting (composite key when needed). * - Every navigation entry carries a `ComponentKey` aligned with the frontend * PageRegistry key (dotted form: {appCode}.{module}[.{section}[.{resource}]]). * * When `testUsers[]` is non-empty, additionally generates: * - `{Module}TestUserSeedDataProvider.cs` — gated by IHostEnvironment.IsDevelopment() * - `tests/ui-test/test-users.json` — manifest consumed by Phase 5 ui-test runner */ import type { ScaffoldSeedInput, GeneratedFile, NavigationEntry, NavigationLevel, TestUserEntry, RoleEntry, ReferenceDataEntry, ReferenceDataValue, TestDataEntry, TestDataValue, RowRef, ActorRef, CoreRef, } from './types.js' import { normalizePermissionEntry } from './types.js' import { slugifyRoleCode, toPascalCase } from '../../../../../lib/string-utils.js' /** Marker block hosting the module seed providers' DI registrations. */ export const SEED_PROVIDERS_DI_BEGIN = '// <<< SEED-PROVIDERS-DI BEGIN >>>' export const SEED_PROVIDERS_DI_END = '// <<< SEED-PROVIDERS-DI END >>>' export interface SeedDiPatch { hostCandidates: string[] begin: string end: string line: string } /** * The `IClientSeedDataProvider` registrations the generated providers need. * * The generator LANDS them; it never just prints a nextStep. An unregistered * provider is INERT — the file exists, `DEV-API-030` sees its * `Set<{Entity}>()` and calls the entity populatable, `gates.md` calls the * CONDITIONAL requirement satisfied, and the table stays at 0 rows. That is * the exact "table empty forever, every audit green" shape §25 exists to * close, reintroduced one layer down. */ export function seedDiPatches(spec: ScaffoldSeedInput): SeedDiPatch[] { const ns = spec.appCode const moduleClass = capitalize(spec.module) const appPascal = toPascalCase(spec.applicationCode) const seedNs = spec.namespace ?? `${ns}.Infrastructure.Persistence.Seeding.Applications.${appPascal}.Modules.${moduleClass}` const hostCandidates = [ `src/${ns}.Infrastructure/DependencyInjection.cs`, `src/${ns}.Infrastructure/ServiceCollectionExtensions.cs`, `src/${ns}.Infrastructure/InfrastructureModule.cs`, ] const providers = [`${moduleClass}SeedDataProvider`] if (spec.referenceData.length > 0) providers.push(`${moduleClass}ReferenceDataSeedDataProvider`) if (spec.testUsers.length > 0) providers.push(`${moduleClass}TestUserSeedDataProvider`) // The test-data provider is INERT unless registered — DEV-DAT-010 checks this line landed. if (spec.testData.length > 0) providers.push(`${moduleClass}TestDataSeedDataProvider`) return providers.map(cls => ({ hostCandidates, begin: SEED_PROVIDERS_DI_BEGIN, end: SEED_PROVIDERS_DI_END, line: `services.AddScoped();`, })) } export function generate(spec: ScaffoldSeedInput): GeneratedFile[] { // Multi-app layout (post core-seed Variante B): module-scoped providers live // under Persistence/Seeding/Applications/{AppPascal}/Modules/{ModuleCode}/. // The Seeding/Applications/{AppPascal}/Core/ siblings are owned by the 6 // {AppPascal}Core* providers emitted by scaffold-core-seed (Phase 0). const appPascal = toPascalCase(spec.applicationCode) const moduleFolder = capitalize(spec.module) const ns = spec.namespace ?? `${spec.appCode}.Infrastructure.Persistence.Seeding.Applications.${appPascal}.Modules.${moduleFolder}` const className = `${capitalize(spec.module)}SeedDataProvider` const byLevel = (level: NavigationLevel) => spec.navigation.filter(n => n.level === level) const apps = byLevel('application') const modules = byLevel('module') const sections = byLevel('section') const resources = byLevel('resource') const navByCode = new Map(spec.navigation.map(n => [`${n.level}:${n.code}`, n])) const content = `using Microsoft.EntityFrameworkCore; using SmartStack.Application.Common.Interfaces.Seeding; using SmartStack.Application.Common.Interfaces.Persistence; using SmartStack.Domain.Authorization; using SmartStack.Domain.Navigation; using SmartStack.Domain.Platform.Administration.Roles; namespace ${ns}; /// /// Seeds navigation, roles, permissions, and role-permission mappings /// for the ${capitalize(spec.module)} module into SmartStack core tables. /// Implementation is idempotent: safe to run multiple times. /// public class ${className} : IClientSeedDataProvider { public int Order => ${spec.order}; // ─── Static GUIDs (one per instance, stable across Seed* calls) ─── ${generateGuidFields(spec.navigation)} ${spec.roles.map(r => ` private static readonly Guid ${capitalize(r.code)}RoleId = Guid.NewGuid();`).join('\n')} public async Task SeedNavigationAsync(ICoreDbContext context, CancellationToken ct = default) { ${generateApplicationSeed(apps, spec.appCode)} ${generateModuleSeed(modules, spec.appCode, navByCode)} ${generateSectionSeed(sections, spec.appCode, navByCode)} ${generateResourceSeed(resources, spec.appCode, navByCode)} await context.SaveChangesAsync(ct); } public async Task SeedRolesAsync(ICoreDbContext context, CancellationToken ct = default) { ${spec.roles.length === 0 ? ' // No custom roles defined' : spec.roles.map(r => ` if (!await context.Roles.AnyAsync(x => x.Code == "${r.code}" && x.ApplicationId == ${capitalize(r.applicationCode)}ApplicationId, ct)) { var role = Role.Create( id: ${capitalize(r.code)}RoleId, code: "${r.code}", name: "${escapeString(r.name)}", applicationId: ${capitalize(r.applicationCode)}ApplicationId, isDefault: ${r.isDefault}); context.Roles.Add(role); }`).join('\n')} await context.SaveChangesAsync(ct); } public async Task SeedPermissionsAsync(ICoreDbContext context, CancellationToken ct = default) { ${spec.permissions.length === 0 ? ' // No custom permissions defined' : ` var permissionsToSeed = new (string Path, string Action, string SectionCode)[] { ${spec.permissions.map(p => normalizePermissionEntry(p)).filter(p => p.sectionCode !== undefined).map(p => ` ("${p.path}", "${p.action}", "${p.sectionCode}"),`).join('\n')} }; foreach (var perm in permissionsToSeed) { if (!await context.NavigationPermissions.AnyAsync( x => x.Path == perm.Path && x.Action == perm.Action && x.SectionCode == perm.SectionCode, ct)) { var permission = NavigationPermission.Create( id: Guid.NewGuid(), path: perm.Path, action: perm.Action, sectionCode: perm.SectionCode); context.NavigationPermissions.Add(permission); } } await context.SaveChangesAsync(ct);`} } public async Task SeedRolePermissionsAsync(ICoreDbContext context, CancellationToken ct = default) { ${spec.rolePermissions.length === 0 ? ' // No role-permission mappings defined' : ` var mappings = new (string RoleCode, string PermissionPath)[] { ${spec.rolePermissions.map(rp => ` ("${rp.roleCode}", "${rp.permissionPath}"),`).join('\n')} }; foreach (var mapping in mappings) { var role = await context.Roles.FirstOrDefaultAsync(r => r.Code == mapping.RoleCode, ct); var permission = await context.NavigationPermissions.FirstOrDefaultAsync( p => p.Path == mapping.PermissionPath, ct); if (role is null || permission is null) continue; if (!await context.RolePermissions.AnyAsync( rp => rp.RoleId == role.Id && rp.PermissionId == permission.Id, ct)) { context.RolePermissions.Add(RolePermission.Create(role.Id, permission.Id)); } } await context.SaveChangesAsync(ct);`} } } ` const seedingDir = `src/${spec.appCode}.Infrastructure/Persistence/Seeding/Applications/${appPascal}/Modules/${moduleFolder}` const files: GeneratedFile[] = [ { path: `${seedingDir}/${className}.cs`, content }, ] if (spec.referenceData.length > 0) { const refClassName = `${capitalize(spec.module)}ReferenceDataSeedDataProvider` files.push({ path: `${seedingDir}/${refClassName}.cs`, content: generateReferenceDataProvider(spec, refClassName, ns), }) } if (spec.testUsers.length > 0) { const testUserClassName = `${capitalize(spec.module)}TestUserSeedDataProvider` files.push({ path: `${seedingDir}/${testUserClassName}.cs`, content: generateTestUserProvider(spec, testUserClassName, ns), }) if (spec.emitDevCredentialsManifest) { files.push({ path: 'tests/ui-test/test-users.json', content: generateTestUsersManifest(spec), }) } } if (spec.testData.length > 0) { const testDataClassName = `${capitalize(spec.module)}TestDataSeedDataProvider` files.push({ path: `${seedingDir}/${testDataClassName}.cs`, content: generateTestDataProvider(spec, testDataClassName, ns), }) } return files } // ─── Business test dataset (jeu-de-test.md — the second seed tier) ─── const isRowRef = (v: TestDataValue): v is RowRef => typeof v === 'object' && v !== null && 'ref' in v const isActorRef = (v: TestDataValue): v is ActorRef => typeof v === 'object' && v !== null && 'actor' in v const isCoreRef = (v: TestDataValue): v is CoreRef => typeof v === 'object' && v !== null && 'core' in v /** The tenant mode of a cited entity — the reference's own (the TARGET's Portée), else its spec entry (testData or referenceData), else the citing entry's. */ function targetTenantMode(spec: ScaffoldSeedInput, ref: RowRef, fallback: 'tenant' | 'none'): 'tenant' | 'none' { return ref.tenantMode ?? spec.testData.find(e => e.entity === ref.entity)?.tenantMode ?? spec.referenceData.find(e => e.entity === ref.entity)?.tenantMode ?? fallback } /** * One `Seed{Entity}Async` method of the test-data provider. Per tenant, per * row: every reference is resolved to a Guid BEFORE the `Create(...)` call * (inside the tenant for tenant-scoped targets); a reference that resolves to * nothing SKIPS the row with a warning that names the entity, the key, the * target and the value — never an empty Guid, and the next startup retries it * (the socle seeds its demo Core rows after the client providers). */ function testSeedMethod(spec: ScaffoldSeedInput, entry: TestDataEntry): string { const e = entry.entity const key = entry.keyField // FULLY QUALIFIED entity types: the provider's namespace carries the // application segment (`…Applications.Client.Modules…`), so an entity named // like the application (`Client` in app `client` — the demo shape) would // resolve to that NAMESPACE, not the type (CS0118). Compiled once to prove it. const fq = (name: string): string => `global::${spec.appCode}.Domain.Entities.${name}` const tenantScoped = entry.tenantMode === 'tenant' const typeOf = (prop: string): string | undefined => entry.types[prop] const tenantClause = tenantScoped ? ' && x.TenantId == tenantId' : '' const tenantArg = tenantScoped ? ', tenantId: tenantId' : '' const rowBlock = (row: Record, outer: string): string => { const indent = outer + ' ' const keyLit = csLiteral(row[key] as ReferenceDataValue, typeOf(key)) const keyText = escapeString(String(row[key])) const lookups: string[] = [] const guards: string[] = [] const args: string[] = [] for (const [prop, value] of Object.entries(row)) { const local = camel(prop) if (isRowRef(value)) { const targetTenant = targetTenantMode(spec, value, entry.tenantMode) === 'tenant' && tenantScoped ? ' && x.TenantId == tenantId' : '' lookups.push( `${indent}var ${local} = await _extensions.Set<${fq(value.entity)}>().Where(x => x.${value.keyField} == "${escapeString(value.key)}"${targetTenant}).Select(x => (Guid?)x.Id).FirstOrDefaultAsync(ct);`, ) guards.push(`${indent}if (${local} == null) { _logger.LogWarning("Jeu de test {Entity} « {Key} » : FK {Target} « {Value} » introuvable — ligne sautée", "${e}", "${keyText}", "${value.entity}", "${escapeString(value.key)}"); _skipped++; }`) args.push(`${local}: ${local}.Value`) } else if (isActorRef(value)) { const role = slugifyRoleCode(value.label) const tu = spec.testUsers.find(u => u.roleCode === role) const email = tu ? emailFor(tu, spec.testUserEmailDomain) : `${role}.test@${spec.testUserEmailDomain}` lookups.push(`${indent}var ${local} = await context.Users.Where(u => u.Email == "${escapeString(email)}").Select(u => (Guid?)u.Id).FirstOrDefaultAsync(ct);`) guards.push(`${indent}if (${local} == null) { _logger.LogWarning("Jeu de test {Entity} « {Key} » : acteur {Actor} (test user {Email}) introuvable — ligne sautée", "${e}", "${keyText}", "${escapeString(value.actor)}", "${escapeString(email)}"); _skipped++; }`) args.push(`${local}: ${local}.Value`) } else if (isCoreRef(value)) { // v1: TenantOrganisation by Name (TenantId is optional on it: null = global). const dbSet = `${value.core}s` const coreTenant = tenantScoped ? ' && (x.TenantId == null || x.TenantId == tenantId)' : '' lookups.push(`${indent}var ${local} = await context.${dbSet}.Where(x => x.${value.by} == "${escapeString(value.value)}"${coreTenant}).Select(x => (Guid?)x.Id).FirstOrDefaultAsync(ct);`) guards.push(`${indent}if (${local} == null) { _logger.LogWarning("Jeu de test {Entity} « {Key} » : {Core} « {Value} » introuvable (seed Core après les providers client — réessayé au prochain démarrage) — ligne sautée", "${e}", "${keyText}", "${value.core}", "${escapeString(value.value)}"); _skipped++; }`) args.push(`${local}: ${local}.Value`) } else { args.push(`${local}: ${csLiteral(value as ReferenceDataValue, typeOf(prop))}`) } } const upsert = `${indent}${guards.length > 0 ? 'else ' : ''}if (!await _extensions.Set<${fq(e)}>().AnyAsync(x => x.${key} == ${keyLit}${tenantClause}, ct)) ${indent}{ ${indent} _extensions.Set<${fq(e)}>().Add(${fq(e)}.Create(${args.join(', ')}${tenantArg})); ${indent} _loaded++; ${indent}}` const guardChain = guards.map((g, i) => (i === 0 ? g : g.replace(indent + 'if', indent + 'else if'))).join('\n') return `${outer}{ // ${e} « ${keyText} » ${[...lookups, guardChain, upsert].filter(s => s !== '').join('\n')} ${outer}}` } if (tenantScoped) { return ` private async Task Seed${e}Async(ICoreDbContext context, IReadOnlyList tenantIds, CancellationToken ct) { foreach (var tenantId in tenantIds) { ${entry.rows.map(r => rowBlock(r, ' ')).join('\n\n')} } }` } return ` private async Task Seed${e}Async(ICoreDbContext context, CancellationToken ct) { ${entry.rows.map(r => rowBlock(r, ' ')).join('\n\n')} }` } /** * The module's business test dataset — dev/test/qual ONLY. Guarded by * `IHostEnvironment.IsDevelopment() || SmartStack:EnableDevSeeding` (the * socle's own dev-seeding switch — Development implicit, test sets the key, * qual on demand, never preprod/prod). `Order = 200 + rank`: after every * module's reference data (~105) and test users (~101), a cited module before * a citing one. `IConfiguration` is read directly (no dependency on the * `SmartStackOptions` type of SmartStack.Api). Writes go through the * constructor-injected IExtensionsDbContext; Core lookups (users, organisations) * through the ICoreDbContext the platform passes in. */ function generateTestDataProvider(spec: ScaffoldSeedInput, className: string, ns: string): string { const moduleLabel = capitalize(spec.module) const needsTenants = spec.testData.some(e => e.tenantMode === 'tenant') const calls = spec.testData .map(e => ` await Seed${e.entity}Async(context${e.tenantMode === 'tenant' ? ', tenantIds' : ''}, ct);\n await _extensions.SaveChangesAsync(ct); // the next entity resolves this one's ids`) .join('\n') return `using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using SmartStack.Application.Common.Interfaces.Seeding; using SmartStack.Application.Common.Interfaces.Persistence; using ${spec.appCode}.Application.Common.Interfaces; using ${spec.appCode}.Domain.Entities; namespace ${ns}; /// /// Business TEST DATASET (jeu-de-test.md) of ${moduleLabel} — the second seed tier. /// Runs in Development, and wherever SmartStack:EnableDevSeeding is true (test /// always, qual on demand) — NEVER in preprod/prod. Idempotent upserts by natural /// key (${spec.testData.map(e => `${e.entity}.${e.keyField}`).join(', ')}); every FK cell is /// resolved by key inside the tenant BEFORE Create(); a row whose reference resolves /// to nothing is skipped and logged (never an empty Guid) and retried at the next startup. /// Order = 200 + ${spec.testDataRank} (module rank): every reference-data provider ran first. /// Generated by scaffold-seed; do not hand-edit between runs. /// public class ${className} : IClientSeedDataProvider { public int Order => ${200 + spec.testDataRank}; private readonly IExtensionsDbContext _extensions; private readonly IHostEnvironment _env; private readonly IConfiguration _config; private readonly ILogger<${className}> _logger; private int _loaded; private int _skipped; public ${className}( IExtensionsDbContext extensions, IHostEnvironment env, IConfiguration config, ILogger<${className}> logger) { _extensions = extensions; _env = env; _config = config; _logger = logger; } public async Task SeedNavigationAsync(ICoreDbContext context, CancellationToken ct = default) { // The socle's own dev-seeding switch — Development implicit, explicit key elsewhere. if (!_env.IsDevelopment() && !_config.GetValue("SmartStack:EnableDevSeeding")) { _logger.LogInformation("Jeu de test ${moduleLabel} ignoré (SmartStack:EnableDevSeeding=false)"); return; } ${needsTenants ? ' var tenantIds = await context.Tenants.Select(t => t.Id).ToListAsync(ct);\n' : ''}${calls} _logger.LogInformation("Jeu de test ${moduleLabel} : {Loaded} ligne(s) chargée(s), {Skipped} sautée(s)", _loaded, _skipped); } public Task SeedRolesAsync(ICoreDbContext context, CancellationToken ct = default) => Task.CompletedTask; public Task SeedPermissionsAsync(ICoreDbContext context, CancellationToken ct = default) => Task.CompletedTask; public Task SeedRolePermissionsAsync(ICoreDbContext context, CancellationToken ct = default) => Task.CompletedTask; ${spec.testData.map(e => testSeedMethod(spec, e)).join('\n\n')} } ` } // ─── Helpers ─── function capitalize(s: string): string { return s.charAt(0).toUpperCase() + s.slice(1) } function escapeString(s: string): string { return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"') } function emailFor(tu: TestUserEntry, domain: string): string { const local = tu.emailLocalPart ?? tu.roleCode return `${local}.test@${domain}` } function guidVar(entry: NavigationEntry): string { const levelSuffix = entry.level === 'application' ? 'ApplicationId' : entry.level === 'module' ? 'ModuleId' : entry.level === 'section' ? 'SectionId' : 'ResourceId' return `${capitalize(entry.code)}${levelSuffix}` } function componentKeyFor(entry: NavigationEntry, appCode: string, navByCode: Map): string { if (entry.componentKey) return entry.componentKey const segments: string[] = [] let current: NavigationEntry | undefined = entry while (current) { segments.unshift(current.code) if (!current.parentCode) break const parentLevel = parentLevelOf(current.level) if (!parentLevel) break current = navByCode.get(`${parentLevel}:${current.parentCode}`) } // Navigation codes for 'application' level start at appCode root; for other // levels the chain above already walked up to the application, so prepend // appCode only if it's not already the head. if (segments[0] !== appCode) { segments.unshift(appCode) } return segments.join('.') } function parentLevelOf(level: NavigationLevel): NavigationLevel | null { switch (level) { case 'module': return 'application' case 'section': return 'module' case 'resource': return 'section' default: return null } } function generateGuidFields(navigation: NavigationEntry[]): string { return navigation .map(n => ` private static readonly Guid ${guidVar(n)} = Guid.NewGuid();`) .join('\n') } function generateApplicationSeed(apps: NavigationEntry[], appCode: string): string { if (apps.length === 0) return ` // No application entries` return apps.map(a => ` if (!await context.NavigationApplications.AnyAsync(x => x.Code == "${a.code}", ct)) { var app = NavigationApplication.Create( id: ${guidVar(a)}, code: "${a.code}", label: "${escapeString(a.label)}", icon: "${a.icon}", iconType: IconType.${a.iconType}, route: "${a.route}", displayOrder: ${a.displayOrder}, componentKey: "${componentKeyFor(a, appCode, new Map([['application:' + a.code, a]]))}"); context.NavigationApplications.Add(app); await context.SaveChangesAsync(ct); }`).join('\n\n') } function generateModuleSeed(modules: NavigationEntry[], appCode: string, navByCode: Map): string { if (modules.length === 0) return ` // No module entries` return modules.map(m => { const parentVar = m.parentCode ? guidVar({ ...m, code: m.parentCode, level: 'application' } as NavigationEntry) : 'null' return ` if (!await context.NavigationModules.AnyAsync(x => x.Code == "${m.code}" && x.ApplicationId == ${parentVar}, ct)) { var module = NavigationModule.Create( id: ${guidVar(m)}, code: "${m.code}", label: "${escapeString(m.label)}", icon: "${m.icon}", iconType: IconType.${m.iconType}, route: "${m.route}", displayOrder: ${m.displayOrder}, applicationId: ${parentVar}, componentKey: "${componentKeyFor(m, appCode, navByCode)}"); context.NavigationModules.Add(module); await context.SaveChangesAsync(ct); }` }).join('\n\n') } function generateSectionSeed(sections: NavigationEntry[], appCode: string, navByCode: Map): string { if (sections.length === 0) return ` // No section entries` return sections.map(s => { const parentVar = s.parentCode ? guidVar({ ...s, code: s.parentCode, level: 'module' } as NavigationEntry) : 'null' return ` if (!await context.NavigationSections.AnyAsync(x => x.Code == "${s.code}" && x.ModuleId == ${parentVar}, ct)) { var section = NavigationSection.Create( id: ${guidVar(s)}, code: "${s.code}", label: "${escapeString(s.label)}", icon: "${s.icon}", iconType: IconType.${s.iconType}, route: "${s.route}", displayOrder: ${s.displayOrder}, moduleId: ${parentVar}, componentKey: "${componentKeyFor(s, appCode, navByCode)}"); context.NavigationSections.Add(section); await context.SaveChangesAsync(ct); }` }).join('\n\n') } function generateResourceSeed(resources: NavigationEntry[], appCode: string, navByCode: Map): string { if (resources.length === 0) return ` // No resource entries` return resources.map(r => { const parentVar = r.parentCode ? guidVar({ ...r, code: r.parentCode, level: 'section' } as NavigationEntry) : 'null' return ` if (!await context.NavigationResources.AnyAsync(x => x.Code == "${r.code}" && x.SectionId == ${parentVar}, ct)) { var resource = NavigationResource.Create( id: ${guidVar(r)}, code: "${r.code}", label: "${escapeString(r.label)}", icon: "${r.icon}", iconType: IconType.${r.iconType}, route: "${r.route}", displayOrder: ${r.displayOrder}, sectionId: ${parentVar}, componentKey: "${componentKeyFor(r, appCode, navByCode)}"); context.NavigationResources.Add(resource); await context.SaveChangesAsync(ct); }` }).join('\n\n') } // ─── Test users (Phase 5 ui-test) ─── // ─── Reference data (Valeurs initiales — §25) ─── function camel(s: string): string { return s.charAt(0).toLowerCase() + s.slice(1) } /** C# numeric types whose literal needs an explicit suffix. */ const NUMERIC_SUFFIX: Record = { decimal: 'm', double: 'd', float: 'f', single: 'f', long: 'L', ulong: 'UL', } /** C# scalar type names that are NOT an enum (so a string value stays a string). */ const KNOWN_SCALARS = new Set([ 'string', 'bool', 'boolean', 'int', 'integer', 'short', 'byte', 'uint', ...Object.keys(NUMERIC_SUFFIX), ]) /** * C#-literalize a reference-data value, TYPED when the entry declares the * property's C# type. Untyped non-integers keep the historical `decimal` * assumption (validate warns) — the unconditional `m` suffix would not compile * against a `double` column. * `cs:` stays the escape hatch for an expression no type map can express. */ function csLiteral(v: ReferenceDataValue, csType?: string): string { if (v === null) return 'null' if (typeof v === 'boolean') return v ? 'true' : 'false' const t = csType?.replace(/\?$/, '').trim() const lower = t?.toLowerCase() if (typeof v === 'number') { if (lower && NUMERIC_SUFFIX[lower]) return `${v}${NUMERIC_SUFFIX[lower]}` if (lower) return String(v) // int/short/byte/… — no suffix return Number.isInteger(v) ? String(v) : `${v}m` } if (v.startsWith('cs:')) return v.slice(3) if (t && !KNOWN_SCALARS.has(lower!)) { // A non-scalar declared type is an enum / value object: `"Insurance"` with // type `AlertKind` becomes `AlertKind.Insurance` — no `cs:` dialect needed. if (lower === 'guid') return `Guid.Parse("${escapeString(v)}")` if (lower === 'dateonly') return `DateOnly.Parse("${escapeString(v)}")` if (lower === 'datetime') return `DateTime.Parse("${escapeString(v)}")` if (lower === 'timeonly') return `TimeOnly.Parse("${escapeString(v)}")` if (/^[A-Z][A-Za-z0-9_]*$/.test(t)) return `${t}.${v}` } return `"${escapeString(v)}"` } function refSeedMethod(entry: ReferenceDataEntry): string { const e = entry.entity const key = entry.keyField const typeOf = (prop: string): string | undefined => entry.types?.[prop] const rowUpsert = (row: Record, indent: string, tenantArg: string): string => { const keyValue = row[key] const args = Object.entries(row) .map(([k, v]) => `${camel(k)}: ${csLiteral(v, typeOf(k))}`) .join(', ') const tenantClause = tenantArg ? ` && x.TenantId == tenantId` : '' return `${indent}if (!await _extensions.Set<${e}>().AnyAsync(x => x.${key} == ${csLiteral(keyValue ?? null, typeOf(key))}${tenantClause}, ct)) ${indent}{ ${indent} _extensions.Set<${e}>().Add(${e}.Create(${args}${tenantArg})); ${indent}}` } if (entry.tenantMode === 'tenant') { return ` private async Task Seed${e}Async(IReadOnlyList tenantIds, CancellationToken ct) { foreach (var tenantId in tenantIds) { ${entry.rows.map(r => rowUpsert(r, ' ', ', tenantId: tenantId')).join('\n\n')} } }` } return ` private async Task Seed${e}Async(CancellationToken ct) { ${entry.rows.map(r => rowUpsert(r, ' ', '')).join('\n\n')} }` } /** * Dedicated provider for module reference data. Keeps the platform's * IClientSeedDataProvider contract (the 4 methods receive ICoreDbContext) but * writes through a CONSTRUCTOR-INJECTED IExtensionsDbContext — extension * tables (`extensions` schema) are unreachable from the Core context. Rows of * a tenant-scoped entity are seeded PER TENANT (the generated `Create(...)` * factory requires a tenantId, and per-tenant rows are what lets each tenant * edit its own paramétrage without leaking to the others). */ function generateReferenceDataProvider(spec: ScaffoldSeedInput, className: string, ns: string): string { const needsTenants = spec.referenceData.some(r => r.tenantMode === 'tenant') const calls = spec.referenceData .map(r => ` await Seed${r.entity}Async(${r.tenantMode === 'tenant' ? 'tenantIds, ' : ''}ct);`) .join('\n') return `using Microsoft.EntityFrameworkCore; using SmartStack.Application.Common.Interfaces.Seeding; using SmartStack.Application.Common.Interfaces.Persistence; using ${spec.appCode}.Application.Common.Interfaces; using ${spec.appCode}.Domain.Entities; namespace ${ns}; /// /// Module reference data (entité.md **Valeurs initiales**) for ${capitalize(spec.module)}. /// Idempotent upserts by natural key (${spec.referenceData.map(r => `${r.entity}.${r.keyField}`).join(', ')}); /// tenant-scoped entities are seeded once per tenant. Generated by scaffold-seed; /// do not hand-edit between runs. /// public class ${className} : IClientSeedDataProvider { public int Order => ${spec.order + 5}; private readonly IExtensionsDbContext _extensions; public ${className}(IExtensionsDbContext extensions) => _extensions = extensions; public async Task SeedNavigationAsync(ICoreDbContext context, CancellationToken ct = default) { // Reference rows ride the first seed phase; the Core context only // supplies the tenant list for tenant-scoped entities. ${needsTenants ? ' var tenantIds = await context.Tenants.Select(t => t.Id).ToListAsync(ct);\n' : ''}${calls} await _extensions.SaveChangesAsync(ct); } public Task SeedRolesAsync(ICoreDbContext context, CancellationToken ct = default) => Task.CompletedTask; public Task SeedPermissionsAsync(ICoreDbContext context, CancellationToken ct = default) => Task.CompletedTask; public Task SeedRolePermissionsAsync(ICoreDbContext context, CancellationToken ct = default) => Task.CompletedTask; ${spec.referenceData.map(refSeedMethod).join('\n\n')} } ` } function generateTestUserProvider(spec: ScaffoldSeedInput, className: string, ns: string): string { const rolesByCode = new Map(spec.roles.map(r => [r.code, r])) const userInserts = spec.testUsers.map(tu => { const role = rolesByCode.get(tu.roleCode) if (!role) return ` // Skipped test user: role "${tu.roleCode}" not found in spec` const email = emailFor(tu, spec.testUserEmailDomain) const localPart = tu.emailLocalPart ?? tu.roleCode return ` await EnsureTestUserAsync(context, "${email}", "${escapeString(localPart)} (test)", "${role.code}", ${capitalize(role.code)}RoleId, TestUserPassword, ct);` }).join('\n') const roleIdFields = Array.from(new Set(spec.testUsers.map(tu => tu.roleCode))) .map(code => { const role = rolesByCode.get(code) if (!role) return '' return ` private static readonly Guid ${capitalize(code)}RoleId = ${capitalize(spec.module)}SeedDataProvider.${capitalize(code)}RoleId_Public;` }).filter(Boolean).join('\n') return `using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Hosting; using SmartStack.Application.Common.Interfaces.Seeding; using SmartStack.Application.Common.Interfaces.Persistence; using SmartStack.Domain.Platform.Administration.Roles; namespace ${ns}; /// /// Seeds test users (one per role) for Phase 5 UI tests. Gated by /// IHostEnvironment.IsDevelopment() — production deployments never run /// this provider, so the hardcoded password below is dev-only. /// /// PROJECT-SPECIFIC CUSTOMIZATION REQUIRED: /// The User entity factory and password hashing API differ per project. /// Adapt EnsureTestUserAsync below to your User entity. Common patterns: /// - User.CreateForDevelopment(...) (factory method) /// - new User { Email = ..., PasswordHash = passwordHasher.Hash(...) } /// - IUserAdminService.CreateAsync(...) (application service) /// /// Order is set ABOVE the main seeder so navigation/roles exist when this runs. /// public class ${className} : IClientSeedDataProvider { private readonly IHostEnvironment _env; public ${className}(IHostEnvironment env) { _env = env; } public int Order => ${spec.order + 1}; // Hardcoded dev password — only used when IsDevelopment() is true. private const string TestUserPassword = "${escapeString(spec.testUserPassword)}"; // Re-resolves the same role GUIDs used by ${className.replace('TestUser', '')} — // see the public re-export pattern in the sibling provider class. ${roleIdFields || ' // (no test users with matching roles)'} public Task SeedNavigationAsync(ICoreDbContext context, CancellationToken ct = default) => Task.CompletedTask; public Task SeedRolesAsync(ICoreDbContext context, CancellationToken ct = default) => Task.CompletedTask; public Task SeedPermissionsAsync(ICoreDbContext context, CancellationToken ct = default) => Task.CompletedTask; public async Task SeedRolePermissionsAsync(ICoreDbContext context, CancellationToken ct = default) { // Test users live here so they run AFTER role-permission mappings. // Do nothing in production — credentials in this file are dev-only. if (!_env.IsDevelopment()) { return; } ${userInserts || ' // No test users defined'} await context.SaveChangesAsync(ct); } /// /// PROJECT-SPECIFIC: adapt this to your User entity API. The default below /// uses a generic pattern that may or may not compile against your codebase /// — update the factory call to match your actual User type. Once the file /// compiles and runs cleanly, remove this comment. /// private static async Task EnsureTestUserAsync( ICoreDbContext context, string email, string displayName, string roleCode, Guid roleId, string password, CancellationToken ct) { if (await context.Users.AnyAsync(u => u.Email == email, ct)) { return; } // TODO(adapt): replace with your project's User factory + password hashing. // Example shapes seen in SmartStack apps: // var user = User.CreateForDevelopment(Guid.NewGuid(), email, displayName, password); // var user = new User { Id = Guid.NewGuid(), Email = email, ... }; user.SetPassword(password); var user = User.CreateForDevelopment( id: Guid.NewGuid(), email: email, displayName: displayName, password: password); context.Users.Add(user); // Idempotent role assignment. if (!await context.UserRoles.AnyAsync(ur => ur.UserId == user.Id && ur.RoleId == roleId, ct)) { context.UserRoles.Add(UserRole.Create(user.Id, roleId)); } } } ` } function generateTestUsersManifest(spec: ScaffoldSeedInput): string { const rolesByCode = new Map(spec.roles.map(r => [r.code, r])) const users = spec.testUsers .map(tu => { const role = rolesByCode.get(tu.roleCode) if (!role) return null return { role: tu.roleCode, email: emailFor(tu, spec.testUserEmailDomain), password: spec.testUserPassword, applicationCode: role.applicationCode, roleName: role.name, } }) .filter((u): u is NonNullable => u !== null) return JSON.stringify( { generatedAt: new Date().toISOString(), module: spec.module, appCode: spec.appCode, // Gitignored. Used by skills/development/testing/ui-test/cli/run-ui-test. // Regenerated on every scaffold-seed run — do not edit by hand. users, }, null, 2, ) + '\n' }