@atlashub/smartstack-cli 3.46.0 → 3.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25919,7 +25919,9 @@ var init_types3 = __esm({
25919
25919
  navRoute: external_exports.string().optional().describe('Navigation route path for controller (e.g., "administration.users"). Required for controllers.'),
25920
25920
  navRouteSuffix: external_exports.string().optional().describe('Optional suffix for NavRoute (e.g., "dashboard" for sub-resources)'),
25921
25921
  withHierarchyFunction: external_exports.boolean().optional().describe("For entity type with self-reference (ParentId): generate TVF SQL script for hierarchy traversal"),
25922
- hierarchyDirection: external_exports.enum(["ancestors", "descendants", "both"]).optional().describe("Direction for hierarchy traversal function (default: both)")
25922
+ hierarchyDirection: external_exports.enum(["ancestors", "descendants", "both"]).optional().describe("Direction for hierarchy traversal function (default: both)"),
25923
+ isPersonRole: external_exports.boolean().optional().describe("If true, entity is a Person Extension linked to User via UserId FK"),
25924
+ userLinkMode: external_exports.enum(["mandatory", "optional"]).optional().describe("Person Extension mode: mandatory (always a User) or optional (may or may not have a User)")
25923
25925
  }).optional()
25924
25926
  });
25925
25927
  ApiDocsInputSchema = external_exports.object({
@@ -27012,6 +27014,25 @@ async function validateEntities(structure, _config, result) {
27012
27014
  suggestion: `Add factory method: public static ${entityName} Create(...)`
27013
27015
  });
27014
27016
  }
27017
+ const hasUserId = content.includes("UserId") && content.includes("private set");
27018
+ const hasUserNavProp = /public\s+User\?\s+User\s*{/.test(content);
27019
+ if (hasUserId && hasUserNavProp && hasAnyTenantInterface) {
27020
+ const personFields = ["FirstName", "LastName", "Email", "PhoneNumber"];
27021
+ const duplicatedFields = personFields.filter((field) => {
27022
+ const propPattern = new RegExp(`public\\s+string[?]?\\s+${field}\\s*{`);
27023
+ return propPattern.test(content);
27024
+ });
27025
+ const hasDisplayProps = content.includes("DisplayFirstName") || content.includes("DisplayLastName");
27026
+ if (duplicatedFields.length >= 2 && !hasDisplayProps) {
27027
+ result.warnings.push({
27028
+ type: "warning",
27029
+ category: "entities",
27030
+ message: `Entity "${entityName}" has UserId (Person Extension) but also declares person fields (${duplicatedFields.join(", ")}) \u2014 these may duplicate User data`,
27031
+ file: path8.relative(structure.root, file),
27032
+ suggestion: "For mandatory Person Extension: remove person fields and read them from User. For optional: add Display* computed properties (DisplayFirstName => User?.FirstName ?? FirstName)"
27033
+ });
27034
+ }
27035
+ }
27015
27036
  }
27016
27037
  }
27017
27038
  async function validateTenantAwareness(structure, _config, result) {
@@ -34771,6 +34792,10 @@ namespace {{implNamespace}};
34771
34792
  /// Scoped entity: EF global query filter includes shared + current tenant data. Scope controls visibility.
34772
34793
  {{/if}}
34773
34794
  /// IMPORTANT: GetAllAsync MUST support search parameter for frontend EntityLookup component.
34795
+ {{#if isPersonRole}}
34796
+ /// IMPORTANT: Person Extension \u2014 always include User for person data resolution.
34797
+ /// All queries must .Include(x => x.User).
34798
+ {{/if}}
34774
34799
  /// </summary>
34775
34800
  public class {{name}}Service : I{{name}}Service
34776
34801
  {
@@ -34833,7 +34858,8 @@ public class {{name}}Service : I{{name}}Service
34833
34858
  const diTemplate = `// Add to DependencyInjection.cs or ServiceCollectionExtensions.cs:
34834
34859
  services.AddScoped<I{{name}}Service, {{name}}Service>();
34835
34860
  `;
34836
- const context = { interfaceNamespace, implNamespace, name, methods, ...tmFlags };
34861
+ const isPersonRole = options?.isPersonRole || false;
34862
+ const context = { interfaceNamespace, implNamespace, name, methods, ...tmFlags, isPersonRole };
34837
34863
  const interfaceContent = import_handlebars.default.compile(interfaceTemplate)(context);
34838
34864
  const implementationContent = import_handlebars.default.compile(implementationTemplate)(context);
34839
34865
  const diContent = import_handlebars.default.compile(diTemplate)(context);
@@ -34866,6 +34892,8 @@ async function scaffoldEntity(name, options, structure, config2, result, dryRun
34866
34892
  const isSystemEntity = tmFlags.isSystemEntity;
34867
34893
  const tablePrefix = options?.tablePrefix || "ref_";
34868
34894
  const schema = options?.schema || config2.conventions.schemas.platform;
34895
+ const isPersonRole = options?.isPersonRole || false;
34896
+ const userLinkMode = options?.userLinkMode || "mandatory";
34869
34897
  const entityTemplate = `using System;
34870
34898
  using SmartStack.Domain.Common;
34871
34899
 
@@ -34937,6 +34965,38 @@ public class {{name}} : BaseEntity, IAuditableEntity
34937
34965
  /// </summary>
34938
34966
  public {{baseEntity}}? {{baseEntity}} { get; private set; }
34939
34967
 
34968
+ {{/if}}
34969
+ {{#if isPersonRoleMandatory}}
34970
+ // === USER LINK (Person Extension \u2014 mandatory) ===
34971
+
34972
+ /// <summary>
34973
+ /// Link to the User entity. This person is ALWAYS a system user.
34974
+ /// Person data (FirstName, LastName, Email) comes from User \u2014 never duplicated here.
34975
+ /// </summary>
34976
+ public Guid UserId { get; private set; }
34977
+ public User? User { get; private set; }
34978
+
34979
+ {{else if isPersonRoleOptional}}
34980
+ // === USER LINK (Person Extension \u2014 optional) ===
34981
+
34982
+ /// <summary>
34983
+ /// Optional link to a User entity. This person MAY have a system account.
34984
+ /// When linked, person data comes from User (via Display* properties).
34985
+ /// </summary>
34986
+ public Guid? UserId { get; private set; }
34987
+ public User? User { get; private set; }
34988
+
34989
+ // === PERSON FIELDS (standalone when no User linked) ===
34990
+ public string FirstName { get; private set; } = null!;
34991
+ public string LastName { get; private set; } = null!;
34992
+ public string? Email { get; private set; }
34993
+ public string? PhoneNumber { get; private set; }
34994
+
34995
+ // === COMPUTED (read from User if linked, else from own fields) ===
34996
+ public string DisplayFirstName => User?.FirstName ?? FirstName;
34997
+ public string DisplayLastName => User?.LastName ?? LastName;
34998
+ public string? DisplayEmail => User?.Email ?? Email;
34999
+
34940
35000
  {{/if}}
34941
35001
  // === BUSINESS PROPERTIES ===
34942
35002
  // TODO: Add {{name}} specific properties here
@@ -35073,6 +35133,35 @@ public class {{name}}Configuration : IEntityTypeConfiguration<{{name}}>
35073
35133
  .HasDatabaseName("IX_{{tablePrefix}}{{name}}s_{{baseEntity}}Id");
35074
35134
  {{/if}}
35075
35135
 
35136
+ {{#if isPersonRoleMandatory}}
35137
+
35138
+ // === PERSON EXTENSION (mandatory) \u2014 User link ===
35139
+ builder.HasOne(e => e.User)
35140
+ .WithMany()
35141
+ .HasForeignKey(e => e.UserId)
35142
+ .OnDelete(DeleteBehavior.Restrict);
35143
+
35144
+ builder.HasIndex(e => new { e.TenantId, e.UserId })
35145
+ .IsUnique();
35146
+ {{else if isPersonRoleOptional}}
35147
+
35148
+ // === PERSON EXTENSION (optional) \u2014 User link ===
35149
+ builder.HasOne(e => e.User)
35150
+ .WithMany()
35151
+ .HasForeignKey(e => e.UserId)
35152
+ .OnDelete(DeleteBehavior.Restrict);
35153
+
35154
+ builder.HasIndex(e => new { e.TenantId, e.UserId })
35155
+ .IsUnique()
35156
+ .HasFilter("[UserId] IS NOT NULL");
35157
+
35158
+ // Person fields
35159
+ builder.Property(e => e.FirstName).HasMaxLength(200);
35160
+ builder.Property(e => e.LastName).HasMaxLength(200);
35161
+ builder.Property(e => e.Email).HasMaxLength(256);
35162
+ builder.Property(e => e.PhoneNumber).HasMaxLength(50);
35163
+ {{/if}}
35164
+
35076
35165
  // TODO: Add business property configurations (HasMaxLength, IsRequired, indexes)
35077
35166
  }
35078
35167
  }
@@ -35086,7 +35175,10 @@ public class {{name}}Configuration : IEntityTypeConfiguration<{{name}}>
35086
35175
  schema,
35087
35176
  infrastructureNamespace: config2.conventions.namespaces.infrastructure,
35088
35177
  configNamespaceSuffix: hierarchy.infraPath ? hierarchy.infraPath.replace(/[\\/]/g, ".") : "",
35089
- domainNamespace: config2.conventions.namespaces.domain
35178
+ domainNamespace: config2.conventions.namespaces.domain,
35179
+ isPersonRoleMandatory: isPersonRole && userLinkMode === "mandatory",
35180
+ isPersonRoleOptional: isPersonRole && userLinkMode === "optional",
35181
+ isPersonRole
35090
35182
  };
35091
35183
  const entityContent = import_handlebars.default.compile(entityTemplate)(context);
35092
35184
  const configContent = import_handlebars.default.compile(configTemplate)(context);
@@ -36617,6 +36709,15 @@ var init_scaffold_extension = __esm({
36617
36709
  enum: ["ancestors", "descendants", "both"],
36618
36710
  description: "Direction for hierarchy traversal function (default: both)"
36619
36711
  },
36712
+ isPersonRole: {
36713
+ type: "boolean",
36714
+ description: "If true, entity is a Person Extension linked to User via UserId FK. Mandatory mode: no person fields, UserId required. Optional mode: own person fields + Display* computed properties."
36715
+ },
36716
+ userLinkMode: {
36717
+ type: "string",
36718
+ enum: ["mandatory", "optional"],
36719
+ description: "Person Extension mode: mandatory (UserId non-nullable, no person fields) or optional (UserId nullable, own person fields + Display* computed properties)"
36720
+ },
36620
36721
  codePattern: {
36621
36722
  type: "object",
36622
36723
  description: 'Code auto-generation pattern for this entity. When strategy != "manual", Code is auto-generated and removed from CreateDto.',
@@ -57630,7 +57731,8 @@ async function scaffoldRoutes(input, config2) {
57630
57731
  await writeText(clientRoutesFile, clientRoutesContent);
57631
57732
  }
57632
57733
  result.files.push({ path: clientRoutesFile, content: clientRoutesContent, type: "created" });
57633
- result.instructions.push(`Generated ${navRoutes.length} routes in clientRoutes format`);
57734
+ const routeCount = navRoutes.length * 4;
57735
+ result.instructions.push(`Generated ${routeCount} routes (4 CRUD per NavRoute) for ${navRoutes.length} NavRoutes in clientRoutes format`);
57634
57736
  result.instructions.push("");
57635
57737
  result.instructions.push("## App.tsx Wiring Instructions");
57636
57738
  result.instructions.push("");
@@ -57639,7 +57741,8 @@ async function scaffoldRoutes(input, config2) {
57639
57741
  const routeTree = buildRouteTree(navRoutes);
57640
57742
  result.instructions.push("### Pattern A: mergeRoutes (applicationRoutes pattern)");
57641
57743
  result.instructions.push("");
57642
- result.instructions.push("Add routes to `applicationRoutes.{application}[]` with **RELATIVE** paths (no leading `/`):");
57744
+ result.instructions.push("Routes are FLAT (no nested children) \u2014 compatible with `mergeRoutes()`.");
57745
+ result.instructions.push("Each NavRoute generates 4 routes: list, create, detail (:id), edit (:id/edit).");
57643
57746
  result.instructions.push("");
57644
57747
  result.instructions.push("**IMPORTANT:** Pages are lazy-loaded. Use `<Suspense fallback={<PageLoader />}>` wrapper.");
57645
57748
  result.instructions.push("");
@@ -57651,21 +57754,23 @@ async function scaffoldRoutes(input, config2) {
57651
57754
  for (const [_app, modules] of Object.entries(routeTree)) {
57652
57755
  for (const [, moduleRoutes] of Object.entries(modules)) {
57653
57756
  for (const route of moduleRoutes) {
57654
- const pageEntry = pageFiles.get(route.navRoute);
57655
- if (pageEntry) {
57656
- for (const entry of pageEntry) {
57657
- if (!importedComponents.has(entry.componentName)) {
57658
- importedComponents.add(entry.componentName);
57659
- result.instructions.push(`const ${entry.componentName} = lazy(() =>`);
57660
- result.instructions.push(` import('${entry.importPath}').then(m => ({ default: m.${entry.componentName} }))`);
57757
+ const discovery = pageFiles.get(route.navRoute);
57758
+ if (discovery) {
57759
+ for (const page of [discovery.list, discovery.create, discovery.detail, discovery.edit]) {
57760
+ if (page && !importedComponents.has(page.componentName)) {
57761
+ importedComponents.add(page.componentName);
57762
+ result.instructions.push(`const ${page.componentName} = lazy(() =>`);
57763
+ result.instructions.push(` import('${page.importPath}').then(m => ({ default: m.${page.componentName} }))`);
57661
57764
  result.instructions.push(");");
57662
57765
  }
57663
57766
  }
57664
57767
  } else {
57665
- const component = route.navRoute.split(".").map(capitalize).join("") + "Page";
57666
- if (!importedComponents.has(component)) {
57667
- importedComponents.add(component);
57668
- result.instructions.push(`// TODO: const ${component} = lazy(() => import('@/pages/...'));`);
57768
+ const { entityPlural, entitySingular, basePath } = deriveEntityNames(route.navRoute);
57769
+ for (const comp of [`${entityPlural}Page`, `${entitySingular}CreatePage`, `${entitySingular}DetailPage`, `${entitySingular}EditPage`]) {
57770
+ if (!importedComponents.has(comp)) {
57771
+ importedComponents.add(comp);
57772
+ result.instructions.push(`// TODO: const ${comp} = lazy(() => import('${basePath}/${comp}').then(m => ({ default: m.${comp} })));`);
57773
+ }
57669
57774
  }
57670
57775
  }
57671
57776
  }
@@ -57678,9 +57783,17 @@ async function scaffoldRoutes(input, config2) {
57678
57783
  for (const [, moduleRoutes] of Object.entries(modules)) {
57679
57784
  for (const route of moduleRoutes) {
57680
57785
  const modulePath = route.navRoute.split(".").slice(1).map(toKebabCase).join("/");
57681
- const pageEntry = pageFiles.get(route.navRoute);
57682
- const component = pageEntry?.[0]?.componentName || `${route.navRoute.split(".").map(capitalize).join("")}Page`;
57683
- result.instructions.push(` { path: '${modulePath}', element: <Suspense fallback={<PageLoader />}><${component} /></Suspense> },`);
57786
+ const discovery = pageFiles.get(route.navRoute);
57787
+ const { entityPlural, entitySingular } = deriveEntityNames(route.navRoute);
57788
+ const listComp = discovery?.list?.componentName || `${entityPlural}Page`;
57789
+ const createComp = discovery?.create?.componentName || `${entitySingular}CreatePage`;
57790
+ const detailComp = discovery?.detail?.componentName || `${entitySingular}DetailPage`;
57791
+ const editComp = discovery?.edit?.componentName || `${entitySingular}EditPage`;
57792
+ result.instructions.push(` // === ${entityPlural.toLowerCase()} (NavRoute: ${route.navRoute}) ===`);
57793
+ result.instructions.push(` { path: '${modulePath}', element: <Suspense fallback={<PageLoader />}><${listComp} /></Suspense> },`);
57794
+ result.instructions.push(` { path: '${modulePath}/create', element: <Suspense fallback={<PageLoader />}><${createComp} /></Suspense> },`);
57795
+ result.instructions.push(` { path: '${modulePath}/:id', element: <Suspense fallback={<PageLoader />}><${detailComp} /></Suspense> },`);
57796
+ result.instructions.push(` { path: '${modulePath}/:id/edit', element: <Suspense fallback={<PageLoader />}><${editComp} /></Suspense> },`);
57684
57797
  }
57685
57798
  }
57686
57799
  result.instructions.push(" ],");
@@ -57703,9 +57816,16 @@ async function scaffoldRoutes(input, config2) {
57703
57816
  for (const [, moduleRoutes] of Object.entries(modules)) {
57704
57817
  for (const route of moduleRoutes) {
57705
57818
  const modulePath = route.navRoute.split(".").slice(1).map(toKebabCase).join("/");
57706
- const pageEntry = pageFiles.get(route.navRoute);
57707
- const component = pageEntry?.[0]?.componentName || `${route.navRoute.split(".").map(capitalize).join("")}Page`;
57708
- result.instructions.push(`<Route path="${modulePath}" element={<Suspense fallback={<PageLoader />}><${component} /></Suspense>} />`);
57819
+ const discovery = pageFiles.get(route.navRoute);
57820
+ const { entityPlural, entitySingular } = deriveEntityNames(route.navRoute);
57821
+ const listComp = discovery?.list?.componentName || `${entityPlural}Page`;
57822
+ const createComp = discovery?.create?.componentName || `${entitySingular}CreatePage`;
57823
+ const detailComp = discovery?.detail?.componentName || `${entitySingular}DetailPage`;
57824
+ const editComp = discovery?.edit?.componentName || `${entitySingular}EditPage`;
57825
+ result.instructions.push(`<Route path="${modulePath}" element={<Suspense fallback={<PageLoader />}><${listComp} /></Suspense>} />`);
57826
+ result.instructions.push(`<Route path="${modulePath}/create" element={<Suspense fallback={<PageLoader />}><${createComp} /></Suspense>} />`);
57827
+ result.instructions.push(`<Route path="${modulePath}/:id" element={<Suspense fallback={<PageLoader />}><${detailComp} /></Suspense>} />`);
57828
+ result.instructions.push(`<Route path="${modulePath}/:id/edit" element={<Suspense fallback={<PageLoader />}><${editComp} /></Suspense>} />`);
57709
57829
  }
57710
57830
  }
57711
57831
  result.instructions.push("```");
@@ -57937,6 +58057,7 @@ function generateRouterConfig(routes, includeGuards) {
57937
58057
  " * React Router Configuration",
57938
58058
  " *",
57939
58059
  " * Auto-generated by SmartStack MCP - DO NOT EDIT MANUALLY",
58060
+ " * Each NavRoute generates 4 flat CRUD routes (list, create, detail, edit).",
57940
58061
  " */",
57941
58062
  "",
57942
58063
  "import { createBrowserRouter, RouteObject } from 'react-router-dom';",
@@ -57953,10 +58074,15 @@ function generateRouterConfig(routes, includeGuards) {
57953
58074
  lines.push(`const ${name} = lazy(() => import('../layouts/${name}').then(m => ({ default: m.${name} })));`);
57954
58075
  }
57955
58076
  lines.push("");
57956
- lines.push("// Page imports - lazy loaded (customize paths)");
58077
+ lines.push("// Page imports - lazy loaded");
58078
+ lines.push("// Uncomment and adjust import paths for your page components:");
57957
58079
  for (const route of routes) {
57958
- const pageName = route.navRoute.split(".").map(capitalize).join("");
57959
- lines.push(`// const ${pageName}Page = lazy(() => import('../pages/${pageName}Page').then(m => ({ default: m.${pageName}Page })));`);
58080
+ const { entityPlural, entitySingular, basePath } = deriveEntityNames(route.navRoute);
58081
+ const pagesBasePath = basePath.replace("@/pages/", "../pages/");
58082
+ lines.push(`// const ${entityPlural}Page = lazy(() => import('${pagesBasePath}/${entityPlural}Page').then(m => ({ default: m.${entityPlural}Page })));`);
58083
+ lines.push(`// const ${entitySingular}CreatePage = lazy(() => import('${pagesBasePath}/${entitySingular}CreatePage').then(m => ({ default: m.${entitySingular}CreatePage })));`);
58084
+ lines.push(`// const ${entitySingular}DetailPage = lazy(() => import('${pagesBasePath}/${entitySingular}DetailPage').then(m => ({ default: m.${entitySingular}DetailPage })));`);
58085
+ lines.push(`// const ${entitySingular}EditPage = lazy(() => import('${pagesBasePath}/${entitySingular}EditPage').then(m => ({ default: m.${entitySingular}EditPage })));`);
57960
58086
  }
57961
58087
  lines.push("");
57962
58088
  lines.push("const routes: RouteObject[] = [");
@@ -57968,23 +58094,14 @@ function generateRouterConfig(routes, includeGuards) {
57968
58094
  for (const [, moduleRoutes] of Object.entries(modules)) {
57969
58095
  for (const route of moduleRoutes) {
57970
58096
  const modulePath = route.navRoute.split(".").slice(1).map(toKebabCase).join("/");
57971
- const pageName = route.navRoute.split(".").map(capitalize).join("");
57972
- if (includeGuards && route.permissions.length > 0) {
57973
- lines.push(" {");
57974
- lines.push(` path: '${modulePath || ""}',`);
57975
- lines.push(` element: (`);
57976
- lines.push(` <PermissionGuard permissions={ROUTES['${route.navRoute}'].permissions}>`);
57977
- lines.push(` {/* <${pageName}Page /> */}`);
57978
- lines.push(` <div>TODO: ${pageName}Page</div>`);
57979
- lines.push(` </PermissionGuard>`);
57980
- lines.push(` ),`);
57981
- lines.push(" },");
57982
- } else {
57983
- lines.push(" {");
57984
- lines.push(` path: '${modulePath || ""}',`);
57985
- lines.push(` element: <div>TODO: ${pageName}Page</div>,`);
57986
- lines.push(" },");
57987
- }
58097
+ const { entityPlural, entitySingular } = deriveEntityNames(route.navRoute);
58098
+ const permGuardOpen = includeGuards && route.permissions.length > 0 ? `<PermissionGuard permissions={ROUTES['${route.navRoute}'].permissions}>` : "";
58099
+ const permGuardClose = permGuardOpen ? "</PermissionGuard>" : "";
58100
+ lines.push(` // === ${entityPlural.toLowerCase()} (NavRoute: ${route.navRoute}) ===`);
58101
+ lines.push(` // TODO: { path: '${modulePath}', element: <Suspense fallback={<PageLoader />}>${permGuardOpen}<${entityPlural}Page />${permGuardClose}</Suspense> },`);
58102
+ lines.push(` // TODO: { path: '${modulePath}/create', element: <Suspense fallback={<PageLoader />}>${permGuardOpen}<${entitySingular}CreatePage />${permGuardClose}</Suspense> },`);
58103
+ lines.push(` // TODO: { path: '${modulePath}/:id', element: <Suspense fallback={<PageLoader />}>${permGuardOpen}<${entitySingular}DetailPage />${permGuardClose}</Suspense> },`);
58104
+ lines.push(` // TODO: { path: '${modulePath}/:id/edit', element: <Suspense fallback={<PageLoader />}>${permGuardOpen}<${entitySingular}EditPage />${permGuardClose}</Suspense> },`);
57988
58105
  }
57989
58106
  }
57990
58107
  lines.push(" ],");
@@ -58154,14 +58271,24 @@ async function discoverPageFiles(webPath, routes) {
58154
58271
  const moduleDir = moduleParts.join("/");
58155
58272
  const pagesDir = path19.join(webPath, "src", "pages", app, moduleDir || "");
58156
58273
  try {
58157
- const pageFiles = await glob("*Page.tsx", { cwd: pagesDir, absolute: false });
58158
- if (pageFiles.length > 0) {
58159
- const entries = pageFiles.map((f) => {
58274
+ const files = await glob("*Page.tsx", { cwd: pagesDir, absolute: false });
58275
+ if (files.length > 0) {
58276
+ const discovery = {};
58277
+ for (const f of files) {
58160
58278
  const componentName = f.replace(".tsx", "");
58161
58279
  const importPath = `@/pages/${app}/${moduleDir ? moduleDir + "/" : ""}${componentName}`;
58162
- return { importPath, componentName };
58163
- });
58164
- pageMap.set(route.navRoute, entries);
58280
+ const entry = { importPath, componentName };
58281
+ if (componentName.endsWith("CreatePage")) {
58282
+ discovery.create = entry;
58283
+ } else if (componentName.endsWith("EditPage")) {
58284
+ discovery.edit = entry;
58285
+ } else if (componentName.endsWith("DetailPage")) {
58286
+ discovery.detail = entry;
58287
+ } else {
58288
+ discovery.list = entry;
58289
+ }
58290
+ }
58291
+ pageMap.set(route.navRoute, discovery);
58165
58292
  }
58166
58293
  } catch {
58167
58294
  logger.debug(`No pages found for route ${route.navRoute} at ${pagesDir}`);
@@ -58178,9 +58305,8 @@ function generateClientRoutesConfig(routes, pageFiles, includeGuards) {
58178
58305
  " * Auto-generated by SmartStack MCP - DO NOT EDIT MANUALLY",
58179
58306
  ' * Run `scaffold_routes` with outputFormat: "clientRoutes" to regenerate.',
58180
58307
  " *",
58181
- " * These routes must be added INSIDE the appropriate Layout wrapper in App.tsx.",
58182
- " * They are NOT standalone - they are children of the application layout routes.",
58183
- " * Routes must be added in BOTH the standard block and the tenant-prefixed block.",
58308
+ " * These routes are FLAT (not nested children) \u2014 compatible with mergeRoutes().",
58309
+ " * Each NavRoute generates up to 4 CRUD routes: list, create, detail, edit.",
58184
58310
  " */",
58185
58311
  "",
58186
58312
  "import type { RouteObject } from 'react-router-dom';",
@@ -58196,22 +58322,32 @@ function generateClientRoutesConfig(routes, pageFiles, includeGuards) {
58196
58322
  lines.push("");
58197
58323
  const importedComponents = /* @__PURE__ */ new Set();
58198
58324
  for (const route of routes) {
58199
- const pageEntries = pageFiles.get(route.navRoute);
58200
- if (pageEntries) {
58201
- for (const entry of pageEntries) {
58202
- if (!importedComponents.has(entry.componentName)) {
58203
- lines.push(`const ${entry.componentName} = lazy(() =>`);
58204
- lines.push(` import('${entry.importPath}').then(m => ({ default: m.${entry.componentName} }))`);
58325
+ const discovery = pageFiles.get(route.navRoute);
58326
+ if (discovery) {
58327
+ for (const page of [discovery.list, discovery.create, discovery.detail, discovery.edit]) {
58328
+ if (page && !importedComponents.has(page.componentName)) {
58329
+ importedComponents.add(page.componentName);
58330
+ lines.push(`const ${page.componentName} = lazy(() =>`);
58331
+ lines.push(` import('${page.importPath}').then(m => ({ default: m.${page.componentName} }))`);
58205
58332
  lines.push(");");
58206
- importedComponents.add(entry.componentName);
58207
58333
  }
58208
58334
  }
58209
58335
  }
58210
58336
  }
58211
58337
  for (const route of routes) {
58212
- if (!pageFiles.has(route.navRoute)) {
58213
- const pageName = route.navRoute.split(".").map(capitalize).join("") + "Page";
58214
- lines.push(`// TODO: const ${pageName} = lazy(() => import('@/pages/...').then(m => ({ default: m.${pageName} })));`);
58338
+ const discovery = pageFiles.get(route.navRoute);
58339
+ const { entityPlural, entitySingular, basePath } = deriveEntityNames(route.navRoute);
58340
+ if (!discovery?.list) {
58341
+ lines.push(`// TODO: const ${entityPlural}Page = lazy(() => import('${basePath}/${entityPlural}Page').then(m => ({ default: m.${entityPlural}Page })));`);
58342
+ }
58343
+ if (!discovery?.create) {
58344
+ lines.push(`// TODO: const ${entitySingular}CreatePage = lazy(() => import('${basePath}/${entitySingular}CreatePage').then(m => ({ default: m.${entitySingular}CreatePage })));`);
58345
+ }
58346
+ if (!discovery?.detail) {
58347
+ lines.push(`// TODO: const ${entitySingular}DetailPage = lazy(() => import('${basePath}/${entitySingular}DetailPage').then(m => ({ default: m.${entitySingular}DetailPage })));`);
58348
+ }
58349
+ if (!discovery?.edit) {
58350
+ lines.push(`// TODO: const ${entitySingular}EditPage = lazy(() => import('${basePath}/${entitySingular}EditPage').then(m => ({ default: m.${entitySingular}EditPage })));`);
58215
58351
  }
58216
58352
  }
58217
58353
  lines.push("");
@@ -58220,30 +58356,44 @@ function generateClientRoutesConfig(routes, pageFiles, includeGuards) {
58220
58356
  const layoutName = getLayoutName(app);
58221
58357
  lines.push("/**");
58222
58358
  lines.push(` * Routes for ${appUpper} application`);
58223
- lines.push(` * Add these as children of <Route path="/${app}" element={<${layoutName} />}>`);
58359
+ lines.push(` * Flat routes for mergeRoutes() \u2014 children of <Route path="/${app}" element={<${layoutName} />}>`);
58224
58360
  lines.push(" */");
58225
58361
  lines.push(`export const ${app}Routes: RouteObject[] = [`);
58226
58362
  for (const [, moduleRoutes] of Object.entries(modules)) {
58227
58363
  for (const route of moduleRoutes) {
58228
58364
  const modulePath = route.navRoute.split(".").slice(1).map(toKebabCase).join("/");
58229
- const pageName = route.navRoute.split(".").map(capitalize).join("") + "Page";
58230
- const pageEntry = pageFiles.get(route.navRoute);
58231
- const component = pageEntry?.[0]?.componentName || pageName;
58232
- const hasRealPage = pageFiles.has(route.navRoute);
58233
- if (includeGuards && route.permissions.length > 0) {
58234
- lines.push(` {`);
58235
- lines.push(` path: '${modulePath}',`);
58236
- if (hasRealPage) {
58237
- lines.push(` element: <Suspense fallback={<PageLoader />}><PermissionGuard permissions={ROUTES['${route.navRoute}'].permissions}><${component} /></PermissionGuard></Suspense>,`);
58238
- } else {
58239
- lines.push(` element: <PermissionGuard permissions={ROUTES['${route.navRoute}'].permissions}><div>TODO: ${component}</div></PermissionGuard>,`);
58240
- }
58241
- lines.push(` },`);
58365
+ const discovery = pageFiles.get(route.navRoute);
58366
+ const { entityPlural, entitySingular } = deriveEntityNames(route.navRoute);
58367
+ const permGuardOpen = includeGuards && route.permissions.length > 0 ? `<PermissionGuard permissions={ROUTES['${route.navRoute}'].permissions}>` : "";
58368
+ const permGuardClose = permGuardOpen ? "</PermissionGuard>" : "";
58369
+ lines.push(` // === ${entityPlural.toLowerCase()} (NavRoute: ${route.navRoute}) ===`);
58370
+ const listComp = discovery?.list?.componentName || `${entityPlural}Page`;
58371
+ const listElement = discovery?.list ? `<Suspense fallback={<PageLoader />}>${permGuardOpen}<${listComp} />${permGuardClose}</Suspense>` : void 0;
58372
+ if (listElement) {
58373
+ lines.push(` { path: '${modulePath}', element: ${listElement} },`);
58374
+ } else {
58375
+ lines.push(` // TODO: { path: '${modulePath}', element: <${listComp} /> },`);
58376
+ }
58377
+ const createComp = discovery?.create?.componentName || `${entitySingular}CreatePage`;
58378
+ const createElement = discovery?.create ? `<Suspense fallback={<PageLoader />}>${permGuardOpen}<${createComp} />${permGuardClose}</Suspense>` : void 0;
58379
+ if (createElement) {
58380
+ lines.push(` { path: '${modulePath}/create', element: ${createElement} },`);
58381
+ } else {
58382
+ lines.push(` // TODO: { path: '${modulePath}/create', element: <${createComp} /> },`);
58383
+ }
58384
+ const detailComp = discovery?.detail?.componentName || `${entitySingular}DetailPage`;
58385
+ const detailElement = discovery?.detail ? `<Suspense fallback={<PageLoader />}>${permGuardOpen}<${detailComp} />${permGuardClose}</Suspense>` : void 0;
58386
+ if (detailElement) {
58387
+ lines.push(` { path: '${modulePath}/:id', element: ${detailElement} },`);
58388
+ } else {
58389
+ lines.push(` // TODO: { path: '${modulePath}/:id', element: <${detailComp} /> },`);
58390
+ }
58391
+ const editComp = discovery?.edit?.componentName || `${entitySingular}EditPage`;
58392
+ const editElement = discovery?.edit ? `<Suspense fallback={<PageLoader />}>${permGuardOpen}<${editComp} />${permGuardClose}</Suspense>` : void 0;
58393
+ if (editElement) {
58394
+ lines.push(` { path: '${modulePath}/:id/edit', element: ${editElement} },`);
58242
58395
  } else {
58243
- lines.push(` {`);
58244
- lines.push(` path: '${modulePath}',`);
58245
- lines.push(` element: ${hasRealPage ? `<Suspense fallback={<PageLoader />}><${component} /></Suspense>` : `<div>TODO: ${component}</div>`},`);
58246
- lines.push(` },`);
58396
+ lines.push(` // TODO: { path: '${modulePath}/:id/edit', element: <${editComp} /> },`);
58247
58397
  }
58248
58398
  }
58249
58399
  }
@@ -58290,6 +58440,23 @@ function navRouteToUrlPath(navRoute) {
58290
58440
  function capitalize(str) {
58291
58441
  return str.charAt(0).toUpperCase() + str.slice(1);
58292
58442
  }
58443
+ function singularize(word) {
58444
+ if (word.endsWith("ies")) return word.slice(0, -3) + "y";
58445
+ if (word.endsWith("ses") || word.endsWith("xes") || word.endsWith("zes") || word.endsWith("ches") || word.endsWith("shes")) return word.slice(0, -2);
58446
+ if (word.endsWith("s") && !word.endsWith("ss")) return word.slice(0, -1);
58447
+ return word;
58448
+ }
58449
+ function deriveEntityNames(navRoute) {
58450
+ const parts = navRoute.split(".");
58451
+ const lastSegment = parts[parts.length - 1];
58452
+ const entityPlural = capitalize(lastSegment);
58453
+ const entitySingular = singularize(entityPlural);
58454
+ const app = capitalize(parts[0]);
58455
+ const moduleParts = parts.slice(1).map(capitalize);
58456
+ const moduleDir = moduleParts.join("/");
58457
+ const basePath = `@/pages/${app}/${moduleDir}`;
58458
+ return { entityPlural, entitySingular, basePath };
58459
+ }
58293
58460
  function formatResult5(result, input) {
58294
58461
  const lines = [];
58295
58462
  lines.push("# Scaffold Routes");
@@ -58461,6 +58628,7 @@ async function discoverBackendNavRoutes(structure) {
58461
58628
  if (content.includes("[HttpPost]")) methods.push("POST");
58462
58629
  if (content.includes("[HttpPut]")) methods.push("PUT");
58463
58630
  if (content.includes("[HttpDelete]")) methods.push("DELETE");
58631
+ if (content.includes("[HttpPatch]")) methods.push("PATCH");
58464
58632
  const permissions = [];
58465
58633
  const authorizeMatches = content.matchAll(/\[Authorize\s*\(\s*[^)]*Policy\s*=\s*"([^"]+)"/g);
58466
58634
  for (const match2 of authorizeMatches) {
@@ -58468,8 +58636,8 @@ async function discoverBackendNavRoutes(structure) {
58468
58636
  }
58469
58637
  routes.push({
58470
58638
  navRoute: fullNavRoute,
58471
- apiPath: `/api/${navRoute.replace(/\./g, "/")}${suffix ? `/${suffix}` : ""}`,
58472
- webPath: `/${navRoute.replace(/\./g, "/")}${suffix ? `/${suffix}` : ""}`,
58639
+ apiPath: `/api/${navRoute.split(".").map(toKebabCase2).join("/")}${suffix ? `/${toKebabCase2(suffix)}` : ""}`,
58640
+ webPath: `/${navRoute.split(".").map(toKebabCase2).join("/")}${suffix ? `/${toKebabCase2(suffix)}` : ""}`,
58473
58641
  permissions,
58474
58642
  controller: controllerName,
58475
58643
  methods
@@ -58560,14 +58728,26 @@ async function validateApiClients(webPath, backendRoutes, result) {
58560
58728
  }
58561
58729
  }
58562
58730
  async function validateRoutes(webPath, backendRoutes, result) {
58563
- const routesPath = path20.join(webPath, "src", "routes", "index.tsx");
58564
- if (!await fileExists(routesPath)) {
58731
+ const routeCandidates = ["clientRoutes.generated.tsx", "index.tsx"];
58732
+ let routesContent = "";
58733
+ for (const candidate of routeCandidates) {
58734
+ const candidatePath = path20.join(webPath, "src", "routes", candidate);
58735
+ if (await fileExists(candidatePath)) {
58736
+ try {
58737
+ routesContent = await readText(candidatePath);
58738
+ } catch {
58739
+ logger.debug(`Failed to read ${candidatePath}`);
58740
+ }
58741
+ break;
58742
+ }
58743
+ }
58744
+ if (!routesContent) {
58565
58745
  result.routes.total = 0;
58566
58746
  result.routes.missing = backendRoutes.map((r) => r.navRoute);
58567
58747
  return;
58568
58748
  }
58569
58749
  try {
58570
- const content = await readText(routesPath);
58750
+ const content = routesContent;
58571
58751
  const pathMatches = content.matchAll(/path:\s*['"`]([^'"`]+)['"`]/g);
58572
58752
  const frontendPaths = /* @__PURE__ */ new Set();
58573
58753
  for (const match2 of pathMatches) {
@@ -58676,7 +58856,7 @@ async function validateAppWiring(webPath, backendRoutes, result) {
58676
58856
  );
58677
58857
  }
58678
58858
  for (const route of backendRoutes) {
58679
- const modulePath = route.navRoute.split(".").slice(1).join("/");
58859
+ const modulePath = route.navRoute.split(".").slice(1).map(toKebabCase2).join("/");
58680
58860
  const lastSegment = route.navRoute.split(".").pop() || "";
58681
58861
  const pathInApp = appContent.includes(`path="${modulePath}"`) || appContent.includes(`path='${modulePath}'`) || appContent.includes(`path="${lastSegment}"`) || appContent.includes(`path='${lastSegment}'`);
58682
58862
  if (!pathInApp) {
@@ -58720,6 +58900,9 @@ function generateRecommendations2(result) {
58720
58900
  result.recommendations.push("All routes are synchronized between frontend and backend");
58721
58901
  }
58722
58902
  }
58903
+ function toKebabCase2(segment) {
58904
+ return segment.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
58905
+ }
58723
58906
  function formatResult6(result, _input) {
58724
58907
  const lines = [];
58725
58908
  const statusIcon = result.valid ? "\u2705" : "\u274C";
@@ -65090,6 +65273,35 @@ builder.HasOne(e => e.User)
65090
65273
  .HasForeignKey<UserExtension>(e => e.UserId);
65091
65274
  \`\`\`
65092
65275
 
65276
+ ### Person Extension Pattern (Person Roles)
65277
+
65278
+ When creating entities that represent person roles (Employee, Customer, Supplier...), use the Person Extension pattern to link to User instead of duplicating person data:
65279
+
65280
+ **Mandatory variant** \u2014 entity always corresponds to a User:
65281
+ \`\`\`csharp
65282
+ public class Employee : BaseEntity, ITenantEntity, IAuditableEntity
65283
+ {
65284
+ public Guid UserId { get; private set; } // FK to auth_Users (non-nullable)
65285
+ public User? User { get; private set; }
65286
+ // Business properties ONLY \u2014 no FirstName, LastName, Email
65287
+ }
65288
+ // Config: unique index on (TenantId, UserId)
65289
+ \`\`\`
65290
+
65291
+ **Optional variant** \u2014 entity may or may not have a User:
65292
+ \`\`\`csharp
65293
+ public class Customer : BaseEntity, ITenantEntity, IAuditableEntity
65294
+ {
65295
+ public Guid? UserId { get; private set; } // FK to auth_Users (nullable)
65296
+ public User? User { get; private set; }
65297
+ public string FirstName { get; private set; } = null!; // Own fields as fallback
65298
+ public string DisplayFirstName => User?.FirstName ?? FirstName; // Computed
65299
+ }
65300
+ // Config: unique filtered index on (TenantId, UserId) WHERE UserId IS NOT NULL
65301
+ \`\`\`
65302
+
65303
+ Use \`scaffold_extension\` with \`isPersonRole: true\` and \`userLinkMode: "mandatory" | "optional"\`.
65304
+
65093
65305
  ---
65094
65306
 
65095
65307
  ## 2. Migration Conventions