/** * cli:scaffold-data-scope — generate.ts * * Emits the `{Entity}ScopePolicy.cs` files and patches the two wiring hosts * idempotently (same marker discipline as scaffold-extension-search): * - DbContext `OnExtensionModelCreating` ← `ApplyDataScopeFilter(...)` lines * between `<<< DATA-SCOPE-FILTERS BEGIN/END >>>`; * - DependencyInjection.cs ← `services.AddSingleton(...)` * lines between `<<< DATA-SCOPE-POLICIES-DI BEGIN/END >>>`. * * Policy types are referenced FULLY-QUALIFIED in both patched hosts (mirror of * scaffold-time-entry-refs' entityName policy) so no per-module `using` is * needed; only the one `IDataScopePolicy` namespace is ensured in the DI host. * * Markers present → replace ONLY the inner block (full-spec, re-runs are clean). * Markers absent → insert at the structural anchor (end of * OnExtensionModelCreating / before the last `return services;`). */ import { applicationDir, applicationNs } from '../../../../../lib/app-classification.js'; import { ensureUsing, methodClosingBraceIndex, modelBuilderParam, replaceMarkerRegion, } from '../../../../../lib/dbcontext-markers.js'; import { DATA_SCOPES_NAMESPACE, DI_BEGIN_MARKER, DI_END_MARKER, FILTERS_BEGIN_MARKER, FILTERS_END_MARKER, type DataScopeEntity, type DataScopeSpec, type GeneratedFile, } from './types.js'; // ─── Policy file ─── /** Namespace of an entity's scope policy — `{ns}.Application.{App}.{Module}.Authorization`. */ export function policyNs(spec: DataScopeSpec, e: DataScopeEntity): string { return `${applicationNs(spec.appCode, e.applicationCode, e.module)}.Authorization`; } /** Fully-qualified policy type name, as referenced from the patched hosts. */ export function policyFqType(spec: DataScopeSpec, e: DataScopeEntity): string { return `${policyNs(spec, e)}.${e.entityName}ScopePolicy`; } /** The C# visibility lambda body for the entity's mode. */ export function visibilityExpression(e: DataScopeEntity): string { switch (e.mode) { case 'own': return `e.${e.ownerProperty} == userId`; case 'assigned': return `e.${e.assignedProperty} == userId`; case 'own-assigned': return `e.${e.ownerProperty} == userId || e.${e.assignedProperty} == userId`; } } /** Render one `{Entity}ScopePolicy.cs`. */ export function renderPolicy(spec: DataScopeSpec, e: DataScopeEntity): GeneratedFile { const content = `using System.Linq.Expressions; using ${DATA_SCOPES_NAMESPACE}; using ${spec.appCode}.Domain.Entities; namespace ${policyNs(spec, e)}; /// /// Row-level data scope of (mode: ${e.mode}). /// Mounted as the named "DataScope" EF query filter by /// ApplyDataScopeFilter in the DbContext — every list/detail query is /// filtered automatically unless the caller holds the bypass permission. /// Registered as IDataScopePolicy in DI for the platform registry. /// public sealed class ${e.entityName}ScopePolicy : DataScopePolicy<${e.entityName}> { public static readonly ${e.entityName}ScopePolicy Instance = new(); private ${e.entityName}ScopePolicy() { } /// /// The sibling bypass row ("…read.all") seeded by scaffold-core-seed for /// actors whose Portée is "toutes" — also implied by {module}.* / {app}.* / /// * wildcards via the platform PermissionMatcher. /// public override string ScopeAllPermission => "${e.readPermission}.all"; public override Expression> Visibility => (e, userId) => ${visibilityExpression(e)}; } `; const dir = `${applicationDir(spec.appCode, e.applicationCode, e.module)}/Authorization`; return { path: `${dir}/${e.entityName}ScopePolicy.cs`, content }; } /** All policy files for the spec. */ export function renderPolicies(spec: DataScopeSpec): GeneratedFile[] { return spec.entities.map(e => renderPolicy(spec, e)); } // ─── Shared marker/using helpers ─── // The primitives (ensureUsing, replaceMarkerRegion, modelBuilderParam, // methodClosingBraceIndex) moved to `lib/dbcontext-markers.ts` — shared with // scaffold-entity's TENANT-FILTERS patch. Re-exported for existing consumers. export { ensureUsing, modelBuilderParam }; // ─── DbContext patch (OnExtensionModelCreating) ─── const FILTER_IND = ' '; // 8 spaces — method-body statements /** One `ApplyDataScopeFilter({mb}, {Policy}.Instance);` line per entity. */ export function renderFilterLines(spec: DataScopeSpec, mbParam: string): string { return spec.entities .map(e => `${FILTER_IND}ApplyDataScopeFilter(${mbParam}, ${policyFqType(spec, e)}.Instance);`) .join('\n'); } /** The content that lives BETWEEN the filter markers (markers included). */ export function renderFilterBlock(spec: DataScopeSpec, mbParam: string): string { return `${FILTER_IND}${FILTERS_BEGIN_MARKER}\n${renderFilterLines(spec, mbParam)}\n${FILTER_IND}${FILTERS_END_MARKER}`; } /** * Idempotently patch the DbContext source. Returns the new source, or null when * no change was needed / no insertion point was found (caller warns). */ export function patchDbContext(source: string, spec: DataScopeSpec): string | null { const mbParam = modelBuilderParam(source) ?? 'modelBuilder'; const block = renderFilterBlock(spec, mbParam); // 1. Markers exist → replace the region (fully-qualified policies: no usings needed). const replaced = replaceMarkerRegion(source, FILTERS_BEGIN_MARKER, FILTERS_END_MARKER, block); if (replaced !== null) return replaced; if (source.includes(FILTERS_BEGIN_MARKER)) return null; // markers present and already up to date // 2. No markers → insert before the closing brace of OnExtensionModelCreating. const close = methodClosingBraceIndex(source); if (close === null) return null; const lineStart = source.lastIndexOf('\n', close) + 1; const before = source.slice(0, lineStart); const after = source.slice(lineStart); return `${before}\n${block}\n${after}`; } // ─── DI patch (AddSingleton) ─── const DI_IND = ' '; // 8 spaces — method-body statements /** One `services.AddSingleton({Policy}.Instance);` line per entity. */ export function renderDiLines(spec: DataScopeSpec): string { return spec.entities .map(e => `${DI_IND}services.AddSingleton(${policyFqType(spec, e)}.Instance);`) .join('\n'); } /** The content that lives BETWEEN the DI markers (markers included). */ export function renderDiBlock(spec: DataScopeSpec): string { return `${DI_IND}${DI_BEGIN_MARKER}\n${renderDiLines(spec)}\n${DI_IND}${DI_END_MARKER}`; } /** * Idempotently patch the DI source. Returns the new source, or null when no * change was needed / no insertion point was found (caller warns). */ export function patchDi(source: string, spec: DataScopeSpec): string | null { // 1. Markers exist → replace the region, ensuring the IDataScopePolicy using. const withUsing = ensureUsing(source, DATA_SCOPES_NAMESPACE); const replaced = replaceMarkerRegion(withUsing, DI_BEGIN_MARKER, DI_END_MARKER, renderDiBlock(spec)); if (replaced !== null) return replaced; if (withUsing.includes(DI_BEGIN_MARKER)) { // Markers up to date — the only possible change is the added using. return withUsing === source ? null : withUsing; } // 2. No markers → insert the block (with a heading comment) before the last `return services;`. const full = [ `${DI_IND}// ── Row-level data scopes (own/assigned lists) ──────────────────────────`, `${DI_IND}// One DataScopePolicy per scoped entity: the named "DataScope" EF filter is`, `${DI_IND}// mounted in the DbContext (ApplyDataScopeFilter); this registration feeds`, `${DI_IND}// the platform DataScopeRegistry. See references/data-scopes.md.`, renderDiBlock(spec), ].join('\n'); const returnRe = /\n[ \t]*return services;[ \t]*\n/g; let last: RegExpExecArray | null = null; let m: RegExpExecArray | null; while ((m = returnRe.exec(withUsing)) !== null) last = m; if (last) { return withUsing.slice(0, last.index) + '\n' + full + '\n' + withUsing.slice(last.index); } const lastBrace = withUsing.lastIndexOf('}'); if (lastBrace >= 0) return withUsing.slice(0, lastBrace) + full + '\n' + withUsing.slice(lastBrace); return null; }