@atlashub/smartstack-mcp 1.3.0 → 1.4.1

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.
package/dist/index.js CHANGED
@@ -81,6 +81,26 @@ import path2 from "path";
81
81
  import fs from "fs-extra";
82
82
  import path from "path";
83
83
  import { glob } from "glob";
84
+ var FileSystemError = class extends Error {
85
+ constructor(message, operation, path14, cause) {
86
+ super(message);
87
+ this.operation = operation;
88
+ this.path = path14;
89
+ this.cause = cause;
90
+ this.name = "FileSystemError";
91
+ }
92
+ };
93
+ function validatePathSecurity(targetPath, baseDir) {
94
+ const normalizedTarget = path.resolve(targetPath);
95
+ const normalizedBase = path.resolve(baseDir);
96
+ if (!normalizedTarget.startsWith(normalizedBase + path.sep) && normalizedTarget !== normalizedBase) {
97
+ throw new FileSystemError(
98
+ `Path traversal detected: "${targetPath}" is outside allowed directory "${baseDir}"`,
99
+ "validatePathSecurity",
100
+ targetPath
101
+ );
102
+ }
103
+ }
84
104
  async function fileExists(filePath) {
85
105
  try {
86
106
  const stat = await fs.stat(filePath);
@@ -101,15 +121,57 @@ async function ensureDirectory(dirPath) {
101
121
  await fs.ensureDir(dirPath);
102
122
  }
103
123
  async function readJson(filePath) {
104
- const content = await fs.readFile(filePath, "utf-8");
105
- return JSON.parse(content);
124
+ try {
125
+ const content = await fs.readFile(filePath, "utf-8");
126
+ try {
127
+ return JSON.parse(content);
128
+ } catch (parseError) {
129
+ throw new FileSystemError(
130
+ `Invalid JSON in file: ${filePath}`,
131
+ "readJson",
132
+ filePath,
133
+ parseError instanceof Error ? parseError : void 0
134
+ );
135
+ }
136
+ } catch (error) {
137
+ if (error instanceof FileSystemError) {
138
+ throw error;
139
+ }
140
+ const err = error instanceof Error ? error : new Error(String(error));
141
+ throw new FileSystemError(
142
+ `Failed to read JSON file: ${filePath} - ${err.message}`,
143
+ "readJson",
144
+ filePath,
145
+ err
146
+ );
147
+ }
106
148
  }
107
149
  async function readText(filePath) {
108
- return fs.readFile(filePath, "utf-8");
150
+ try {
151
+ return await fs.readFile(filePath, "utf-8");
152
+ } catch (error) {
153
+ const err = error instanceof Error ? error : new Error(String(error));
154
+ throw new FileSystemError(
155
+ `Failed to read file: ${filePath} - ${err.message}`,
156
+ "readText",
157
+ filePath,
158
+ err
159
+ );
160
+ }
109
161
  }
110
162
  async function writeText(filePath, content) {
111
- await fs.ensureDir(path.dirname(filePath));
112
- await fs.writeFile(filePath, content, "utf-8");
163
+ try {
164
+ await fs.ensureDir(path.dirname(filePath));
165
+ await fs.writeFile(filePath, content, "utf-8");
166
+ } catch (error) {
167
+ const err = error instanceof Error ? error : new Error(String(error));
168
+ throw new FileSystemError(
169
+ `Failed to write file: ${filePath} - ${err.message}`,
170
+ "writeText",
171
+ filePath,
172
+ err
173
+ );
174
+ }
113
175
  }
114
176
  async function findFiles(pattern, options = {}) {
115
177
  const { cwd = process.cwd(), ignore = [] } = options;
@@ -126,7 +188,8 @@ async function findFiles(pattern, options = {}) {
126
188
  var defaultConfig = {
127
189
  version: "1.0.0",
128
190
  smartstack: {
129
- projectPath: process.env.SMARTSTACK_PROJECT_PATH || "D:/SmartStack.app/02-Develop",
191
+ // Will be resolved in getConfig() - never use a hard-coded path
192
+ projectPath: "",
130
193
  apiUrl: process.env.SMARTSTACK_API_URL || "https://localhost:5001",
131
194
  apiEnabled: process.env.SMARTSTACK_API_ENABLED !== "false"
132
195
  },
@@ -146,7 +209,8 @@ var defaultConfig = {
146
209
  "entra_",
147
210
  "ref_",
148
211
  "loc_",
149
- "lic_"
212
+ "lic_",
213
+ "tenant_"
150
214
  ],
151
215
  codePrefixes: {
152
216
  core: "core_",
@@ -189,6 +253,17 @@ var defaultConfig = {
189
253
  }
190
254
  };
191
255
  var cachedConfig = null;
256
+ function resolveProjectPath(configPath) {
257
+ if (process.env.SMARTSTACK_PROJECT_PATH) {
258
+ return process.env.SMARTSTACK_PROJECT_PATH;
259
+ }
260
+ if (configPath && configPath.trim()) {
261
+ return configPath;
262
+ }
263
+ const cwd = process.cwd();
264
+ logger.warn("No SMARTSTACK_PROJECT_PATH configured, using current directory", { cwd });
265
+ return cwd;
266
+ }
192
267
  async function getConfig() {
193
268
  if (cachedConfig) {
194
269
  return cachedConfig;
@@ -201,18 +276,23 @@ async function getConfig() {
201
276
  logger.info("Configuration loaded from file", { path: configPath });
202
277
  } catch (error) {
203
278
  logger.warn("Failed to load config file, using defaults", { error });
204
- cachedConfig = defaultConfig;
279
+ cachedConfig = { ...defaultConfig };
205
280
  }
206
281
  } else {
207
282
  logger.debug("No config file found, using defaults");
208
- cachedConfig = defaultConfig;
209
- }
210
- if (process.env.SMARTSTACK_PROJECT_PATH) {
211
- cachedConfig.smartstack.projectPath = process.env.SMARTSTACK_PROJECT_PATH;
283
+ cachedConfig = { ...defaultConfig };
212
284
  }
285
+ cachedConfig.smartstack.projectPath = resolveProjectPath(
286
+ cachedConfig.smartstack.projectPath
287
+ );
213
288
  if (process.env.SMARTSTACK_API_URL) {
214
289
  cachedConfig.smartstack.apiUrl = process.env.SMARTSTACK_API_URL;
215
290
  }
291
+ if (!cachedConfig.smartstack.projectPath) {
292
+ throw new Error(
293
+ "SmartStack project path not configured. Set SMARTSTACK_PROJECT_PATH environment variable or configure in config file."
294
+ );
295
+ }
216
296
  return cachedConfig;
217
297
  }
218
298
  function mergeConfig(base, override) {
@@ -335,15 +415,21 @@ var ConfigSchema = z.object({
335
415
  });
336
416
  var ValidateConventionsInputSchema = z.object({
337
417
  path: z.string().optional().describe("Project path to validate (default: SmartStack.app path)"),
338
- checks: z.array(z.enum(["tables", "migrations", "services", "namespaces", "entities", "all"])).default(["all"]).describe("Types of checks to perform")
418
+ checks: z.array(z.enum(["tables", "migrations", "services", "namespaces", "entities", "tenants", "controllers", "all"])).default(["all"]).describe("Types of checks to perform")
339
419
  });
340
420
  var CheckMigrationsInputSchema = z.object({
341
421
  projectPath: z.string().optional().describe("EF Core project path"),
342
422
  branch: z.string().optional().describe("Git branch to check (default: current)"),
343
423
  compareBranch: z.string().optional().describe("Branch to compare against")
344
424
  });
425
+ var EntityPropertySchema = z.object({
426
+ name: z.string().describe("Property name (PascalCase)"),
427
+ type: z.string().describe("C# type (string, int, Guid, DateTime, etc.)"),
428
+ required: z.boolean().optional().describe("If true, property is required"),
429
+ maxLength: z.number().optional().describe("Max length for string properties")
430
+ });
345
431
  var ScaffoldExtensionInputSchema = z.object({
346
- type: z.enum(["service", "entity", "controller", "component"]).describe("Type of extension to scaffold"),
432
+ type: z.enum(["feature", "service", "entity", "controller", "component", "test", "dto", "validator", "repository"]).describe('Type of extension to scaffold. Use "feature" for full-stack generation.'),
347
433
  name: z.string().describe('Name of the extension (e.g., "UserProfile", "Order")'),
348
434
  options: z.object({
349
435
  namespace: z.string().optional().describe("Custom namespace"),
@@ -352,7 +438,17 @@ var ScaffoldExtensionInputSchema = z.object({
352
438
  outputPath: z.string().optional().describe("Custom output path"),
353
439
  isSystemEntity: z.boolean().optional().describe("If true, creates a system entity without TenantId"),
354
440
  tablePrefix: z.string().optional().describe('Domain prefix for table name (e.g., "auth_", "nav_", "cfg_")'),
355
- schema: z.enum(["core", "extensions"]).optional().describe("Database schema (default: core)")
441
+ schema: z.enum(["core", "extensions"]).optional().describe("Database schema (default: core)"),
442
+ dryRun: z.boolean().optional().describe("If true, preview generated code without writing files"),
443
+ skipService: z.boolean().optional().describe("For feature type: skip service generation"),
444
+ skipController: z.boolean().optional().describe("For feature type: skip controller generation"),
445
+ skipComponent: z.boolean().optional().describe("For feature type: skip React component generation"),
446
+ clientExtension: z.boolean().optional().describe("If true, use extensions schema for client-specific code"),
447
+ withTests: z.boolean().optional().describe("For feature type: also generate unit tests"),
448
+ withDtos: z.boolean().optional().describe("For feature type: generate DTOs (Create, Update, Response)"),
449
+ withValidation: z.boolean().optional().describe("For feature type: generate FluentValidation validators"),
450
+ withRepository: z.boolean().optional().describe("For feature type: generate repository pattern"),
451
+ entityProperties: z.array(EntityPropertySchema).optional().describe("Entity properties for DTO/Validator generation")
356
452
  }).optional()
357
453
  });
358
454
  var ApiDocsInputSchema = z.object({
@@ -369,10 +465,30 @@ import { exec } from "child_process";
369
465
  import { promisify } from "util";
370
466
  import path3 from "path";
371
467
  var execAsync = promisify(exec);
468
+ var GitError = class extends Error {
469
+ constructor(message, command, cwd, cause) {
470
+ super(message);
471
+ this.command = command;
472
+ this.cwd = cwd;
473
+ this.cause = cause;
474
+ this.name = "GitError";
475
+ }
476
+ };
372
477
  async function git(command, cwd) {
373
- const options = cwd ? { cwd } : {};
374
- const { stdout } = await execAsync(`git ${command}`, options);
375
- return stdout.trim();
478
+ const options = cwd ? { cwd, maxBuffer: 10 * 1024 * 1024 } : { maxBuffer: 10 * 1024 * 1024 };
479
+ try {
480
+ const { stdout } = await execAsync(`git ${command}`, options);
481
+ return stdout.trim();
482
+ } catch (error) {
483
+ const err = error instanceof Error ? error : new Error(String(error));
484
+ const stderr = error?.stderr || "";
485
+ throw new GitError(
486
+ `Git command failed: git ${command}${stderr ? ` - ${stderr.trim()}` : ""}`,
487
+ command,
488
+ cwd,
489
+ err
490
+ );
491
+ }
376
492
  }
377
493
  async function isGitRepo(cwd) {
378
494
  const gitDir = path3.join(cwd || process.cwd(), ".git");
@@ -547,7 +663,7 @@ async function findControllerFiles(apiPath) {
547
663
  import path5 from "path";
548
664
  var validateConventionsTool = {
549
665
  name: "validate_conventions",
550
- description: "Validate AtlasHub/SmartStack conventions: SQL schemas (core/extensions), domain table prefixes (auth_, nav_, ai_, etc.), migration naming ({context}_v{version}_{sequence}_*), service interfaces (I*Service), namespace structure",
666
+ description: "Validate AtlasHub/SmartStack conventions: SQL schemas (core/extensions), domain table prefixes (auth_, nav_, ai_, etc.), migration naming ({context}_v{version}_{sequence}_*), service interfaces (I*Service), namespace structure, controller routes (NavRoute)",
551
667
  inputSchema: {
552
668
  type: "object",
553
669
  properties: {
@@ -559,7 +675,7 @@ var validateConventionsTool = {
559
675
  type: "array",
560
676
  items: {
561
677
  type: "string",
562
- enum: ["tables", "migrations", "services", "namespaces", "all"]
678
+ enum: ["tables", "migrations", "services", "namespaces", "entities", "tenants", "controllers", "all"]
563
679
  },
564
680
  description: "Types of checks to perform",
565
681
  default: ["all"]
@@ -570,7 +686,7 @@ var validateConventionsTool = {
570
686
  async function handleValidateConventions(args, config) {
571
687
  const input = ValidateConventionsInputSchema.parse(args);
572
688
  const projectPath = input.path || config.smartstack.projectPath;
573
- const checks = input.checks.includes("all") ? ["tables", "migrations", "services", "namespaces"] : input.checks;
689
+ const checks = input.checks.includes("all") ? ["tables", "migrations", "services", "namespaces", "entities", "tenants", "controllers"] : input.checks;
574
690
  logger.info("Validating conventions", { projectPath, checks });
575
691
  const result = {
576
692
  valid: true,
@@ -591,6 +707,15 @@ async function handleValidateConventions(args, config) {
591
707
  if (checks.includes("namespaces")) {
592
708
  await validateNamespaces(structure, config, result);
593
709
  }
710
+ if (checks.includes("entities")) {
711
+ await validateEntities(structure, config, result);
712
+ }
713
+ if (checks.includes("tenants")) {
714
+ await validateTenantAwareness(structure, config, result);
715
+ }
716
+ if (checks.includes("controllers")) {
717
+ await validateControllerRoutes(structure, config, result);
718
+ }
594
719
  result.valid = result.errors.length === 0;
595
720
  result.summary = generateSummary(result, checks);
596
721
  return formatResult(result);
@@ -785,6 +910,242 @@ async function validateNamespaces(structure, config, result) {
785
910
  }
786
911
  }
787
912
  }
913
+ async function validateEntities(structure, _config, result) {
914
+ if (!structure.domain) {
915
+ result.warnings.push({
916
+ type: "warning",
917
+ category: "entities",
918
+ message: "Domain project not found, skipping entity validation"
919
+ });
920
+ return;
921
+ }
922
+ const entityFiles = await findFiles("**/*.cs", { cwd: structure.domain });
923
+ for (const file of entityFiles) {
924
+ const content = await readText(file);
925
+ const fileName = path5.basename(file, ".cs");
926
+ if (fileName.endsWith("Dto") || fileName.endsWith("Command") || fileName.endsWith("Query") || fileName.endsWith("Handler") || fileName.endsWith("Validator") || fileName.endsWith("Exception") || fileName.startsWith("I")) {
927
+ continue;
928
+ }
929
+ const classMatch = content.match(/public\s+(?:class|record)\s+(\w+)(?:\s*:\s*([^{]+))?/);
930
+ if (!classMatch) continue;
931
+ const entityName = classMatch[1];
932
+ const inheritance = classMatch[2]?.trim() || "";
933
+ const hasBaseEntity = inheritance.includes("BaseEntity");
934
+ const hasSystemEntity = inheritance.includes("SystemEntity");
935
+ const hasITenantEntity = inheritance.includes("ITenantEntity");
936
+ if (!hasBaseEntity && !hasSystemEntity) {
937
+ continue;
938
+ }
939
+ if (hasBaseEntity && !hasSystemEntity && !hasITenantEntity) {
940
+ result.warnings.push({
941
+ type: "warning",
942
+ category: "entities",
943
+ message: `Entity "${entityName}" inherits BaseEntity but doesn't implement ITenantEntity`,
944
+ file: path5.relative(structure.root, file),
945
+ suggestion: "Add ITenantEntity interface for multi-tenant support, or use SystemEntity for platform-level entities"
946
+ });
947
+ }
948
+ if (!content.includes(`private ${entityName}()`)) {
949
+ result.warnings.push({
950
+ type: "warning",
951
+ category: "entities",
952
+ message: `Entity "${entityName}" is missing private constructor for EF Core`,
953
+ file: path5.relative(structure.root, file),
954
+ suggestion: `Add: private ${entityName}() { }`
955
+ });
956
+ }
957
+ if (!content.includes(`public static ${entityName} Create(`)) {
958
+ result.warnings.push({
959
+ type: "warning",
960
+ category: "entities",
961
+ message: `Entity "${entityName}" is missing factory method`,
962
+ file: path5.relative(structure.root, file),
963
+ suggestion: `Add factory method: public static ${entityName} Create(...)`
964
+ });
965
+ }
966
+ }
967
+ }
968
+ async function validateTenantAwareness(structure, _config, result) {
969
+ if (!structure.domain) {
970
+ result.warnings.push({
971
+ type: "warning",
972
+ category: "tenants",
973
+ message: "Domain project not found, skipping tenant validation"
974
+ });
975
+ return;
976
+ }
977
+ const entityFiles = await findFiles("**/*.cs", { cwd: structure.domain });
978
+ let tenantAwareCount = 0;
979
+ let systemEntityCount = 0;
980
+ let ambiguousCount = 0;
981
+ for (const file of entityFiles) {
982
+ const content = await readText(file);
983
+ const classMatch = content.match(/public\s+(?:class|record)\s+(\w+)(?:\s*:\s*([^{]+))?/);
984
+ if (!classMatch) continue;
985
+ const entityName = classMatch[1];
986
+ const inheritance = classMatch[2]?.trim() || "";
987
+ if (!inheritance.includes("Entity") && !inheritance.includes("ITenant")) {
988
+ continue;
989
+ }
990
+ const hasITenantEntity = inheritance.includes("ITenantEntity");
991
+ const hasSystemEntity = inheritance.includes("SystemEntity");
992
+ const hasTenantId = content.includes("TenantId");
993
+ const hasTenantIdProperty = content.includes("public Guid TenantId") || content.includes("public required Guid TenantId");
994
+ if (hasITenantEntity) {
995
+ tenantAwareCount++;
996
+ if (!hasTenantIdProperty) {
997
+ result.errors.push({
998
+ type: "error",
999
+ category: "tenants",
1000
+ message: `Entity "${entityName}" implements ITenantEntity but is missing TenantId property`,
1001
+ file: path5.relative(structure.root, file),
1002
+ suggestion: "Add: public Guid TenantId { get; private set; }"
1003
+ });
1004
+ }
1005
+ const createMethod = content.match(/public static \w+ Create\s*\(([^)]*)\)/);
1006
+ if (createMethod && !createMethod[1].includes("tenantId") && !createMethod[1].includes("TenantId")) {
1007
+ result.errors.push({
1008
+ type: "error",
1009
+ category: "tenants",
1010
+ message: `Entity "${entityName}" implements ITenantEntity but Create() doesn't require tenantId`,
1011
+ file: path5.relative(structure.root, file),
1012
+ suggestion: "Add tenantId as first parameter: Create(Guid tenantId, ...)"
1013
+ });
1014
+ }
1015
+ }
1016
+ if (hasSystemEntity) {
1017
+ systemEntityCount++;
1018
+ if (hasTenantId) {
1019
+ result.errors.push({
1020
+ type: "error",
1021
+ category: "tenants",
1022
+ message: `System entity "${entityName}" should not have TenantId`,
1023
+ file: path5.relative(structure.root, file),
1024
+ suggestion: "Remove TenantId from system entities"
1025
+ });
1026
+ }
1027
+ }
1028
+ if (!hasITenantEntity && !hasSystemEntity && hasTenantId) {
1029
+ ambiguousCount++;
1030
+ result.warnings.push({
1031
+ type: "warning",
1032
+ category: "tenants",
1033
+ message: `Entity "${entityName}" has TenantId but doesn't implement ITenantEntity`,
1034
+ file: path5.relative(structure.root, file),
1035
+ suggestion: "Add ITenantEntity interface for explicit tenant-awareness"
1036
+ });
1037
+ }
1038
+ }
1039
+ if (tenantAwareCount + systemEntityCount > 0) {
1040
+ result.warnings.push({
1041
+ type: "warning",
1042
+ category: "tenants",
1043
+ message: `Tenant summary: ${tenantAwareCount} tenant-aware, ${systemEntityCount} system, ${ambiguousCount} ambiguous`
1044
+ });
1045
+ }
1046
+ }
1047
+ async function validateControllerRoutes(structure, _config, result) {
1048
+ if (!structure.api) {
1049
+ result.warnings.push({
1050
+ type: "warning",
1051
+ category: "controllers",
1052
+ message: "API project not found, skipping controller route validation"
1053
+ });
1054
+ return;
1055
+ }
1056
+ const controllerFiles = await findFiles("**/Controllers/**/*Controller.cs", {
1057
+ cwd: structure.api
1058
+ });
1059
+ const systemControllers = [
1060
+ // Auth controllers
1061
+ "AuthController",
1062
+ "RegistrationController",
1063
+ "OnboardingController",
1064
+ "NavigationController",
1065
+ "LogsController",
1066
+ "EntraController",
1067
+ // User-level controllers (not in navigation hierarchy)
1068
+ "PreferencesController",
1069
+ "ApplicationTrackingController",
1070
+ // Support module controllers (using api/support/* routes)
1071
+ "NotificationsController",
1072
+ "SlaController",
1073
+ "TemplatesController",
1074
+ "TicketsController",
1075
+ // DashboardController exists in both User and Support - both use hardcoded routes
1076
+ "DashboardController",
1077
+ // Structure controllers (using api/structure/* routes - navigation paths don't exist yet)
1078
+ "CostCentersController",
1079
+ "OrganisationsController",
1080
+ "OrganizationalDomainsController",
1081
+ // Business controllers (using api/business/* routes - navigation paths don't exist yet)
1082
+ "MyTicketsController"
1083
+ ];
1084
+ let navRouteCount = 0;
1085
+ let hardcodedRouteCount = 0;
1086
+ let systemControllerCount = 0;
1087
+ for (const file of controllerFiles) {
1088
+ const content = await readText(file);
1089
+ const fileName = path5.basename(file, ".cs");
1090
+ if (systemControllers.includes(fileName)) {
1091
+ systemControllerCount++;
1092
+ continue;
1093
+ }
1094
+ const hasNavRoute = content.includes("[NavRoute(");
1095
+ const hasHardcodedRoute = content.includes('[Route("api/[controller]")]') || content.includes('[Route("api/') || /\[Route\s*\(\s*"[^"]+"\s*\)\]/.test(content);
1096
+ if (hasNavRoute) {
1097
+ navRouteCount++;
1098
+ const navRouteMatch = content.match(/\[NavRoute\s*\(\s*"([^"]+)"(?:\s*,\s*Suffix\s*=\s*"([^"]+)")?\s*\)\]/);
1099
+ if (navRouteMatch) {
1100
+ const routePath = navRouteMatch[1];
1101
+ const parts = routePath.split(".");
1102
+ if (parts.length < 2) {
1103
+ result.warnings.push({
1104
+ type: "warning",
1105
+ category: "controllers",
1106
+ message: `Controller "${fileName}" has NavRoute with insufficient depth: "${routePath}"`,
1107
+ file: path5.relative(structure.root, file),
1108
+ suggestion: 'NavRoute should have at least 2 levels: "context.application" (e.g., "platform.administration")'
1109
+ });
1110
+ }
1111
+ const hasUppercase = parts.some((part) => part !== part.toLowerCase());
1112
+ if (hasUppercase) {
1113
+ result.errors.push({
1114
+ type: "error",
1115
+ category: "controllers",
1116
+ message: `Controller "${fileName}" has NavRoute with uppercase characters: "${routePath}"`,
1117
+ file: path5.relative(structure.root, file),
1118
+ suggestion: 'NavRoute paths must be lowercase (e.g., "platform.administration.users")'
1119
+ });
1120
+ }
1121
+ }
1122
+ } else if (hasHardcodedRoute) {
1123
+ hardcodedRouteCount++;
1124
+ result.warnings.push({
1125
+ type: "warning",
1126
+ category: "controllers",
1127
+ message: `Controller "${fileName}" uses hardcoded Route instead of NavRoute`,
1128
+ file: path5.relative(structure.root, file),
1129
+ suggestion: 'Use [NavRoute("context.application.module")] for navigation-based routing'
1130
+ });
1131
+ }
1132
+ }
1133
+ const totalControllers = controllerFiles.length;
1134
+ const businessControllers = totalControllers - systemControllerCount;
1135
+ const navRoutePercentage = businessControllers > 0 ? Math.round(navRouteCount / businessControllers * 100) : 0;
1136
+ result.warnings.push({
1137
+ type: "warning",
1138
+ category: "controllers",
1139
+ message: `Route summary: ${navRouteCount}/${businessControllers} business controllers use NavRoute (${navRoutePercentage}%), ${systemControllerCount} system controllers excluded`
1140
+ });
1141
+ if (navRoutePercentage < 80 && businessControllers > 0) {
1142
+ result.warnings.push({
1143
+ type: "warning",
1144
+ category: "controllers",
1145
+ message: `NavRoute adoption is below 80% (${navRoutePercentage}%). Consider migrating remaining controllers.`
1146
+ });
1147
+ }
1148
+ }
788
1149
  function generateSummary(result, checks) {
789
1150
  const parts = [];
790
1151
  parts.push(`Checks performed: ${checks.join(", ")}`);
@@ -928,9 +1289,23 @@ async function parseMigrations(migrationsPath, rootPath) {
928
1289
  return a.sequence.localeCompare(b.sequence);
929
1290
  });
930
1291
  }
1292
+ function parseSemver(version) {
1293
+ const match = version.match(/^(\d+)\.(\d+)\.(\d+)$/);
1294
+ if (!match) {
1295
+ return null;
1296
+ }
1297
+ return [
1298
+ parseInt(match[1], 10),
1299
+ parseInt(match[2], 10),
1300
+ parseInt(match[3], 10)
1301
+ ];
1302
+ }
931
1303
  function compareVersions(a, b) {
932
- const partsA = a.split(".").map(Number);
933
- const partsB = b.split(".").map(Number);
1304
+ const partsA = parseSemver(a);
1305
+ const partsB = parseSemver(b);
1306
+ if (!partsA && !partsB) return 0;
1307
+ if (!partsA) return 1;
1308
+ if (!partsB) return -1;
934
1309
  for (let i = 0; i < 3; i++) {
935
1310
  if (partsA[i] > partsB[i]) return 1;
936
1311
  if (partsA[i] < partsB[i]) return -1;
@@ -955,6 +1330,14 @@ function checkNamingConventions(result, _config) {
955
1330
  resolution: `Use format: {context}_v{version}_{sequence}_{Description} where version is semver (1.0.0, 1.2.0, etc.)`
956
1331
  });
957
1332
  }
1333
+ if (migration.version !== "0.0.0" && !parseSemver(migration.version)) {
1334
+ result.conflicts.push({
1335
+ type: "naming",
1336
+ description: `Migration "${migration.name}" has invalid version format: "${migration.version}"`,
1337
+ files: [migration.file],
1338
+ resolution: `Use semver format (major.minor.patch): 1.0.0, 1.2.0, 2.0.0, etc.`
1339
+ });
1340
+ }
958
1341
  }
959
1342
  }
960
1343
  function checkChronologicalOrder(result) {
@@ -1118,14 +1501,14 @@ import Handlebars from "handlebars";
1118
1501
  import path7 from "path";
1119
1502
  var scaffoldExtensionTool = {
1120
1503
  name: "scaffold_extension",
1121
- description: "Generate code to extend SmartStack: service (interface + implementation), entity (class + EF config), controller (REST endpoints), or React component",
1504
+ description: "Generate code to extend SmartStack: feature (full-stack), entity, service, controller, component, dto, validator, repository, or test",
1122
1505
  inputSchema: {
1123
1506
  type: "object",
1124
1507
  properties: {
1125
1508
  type: {
1126
1509
  type: "string",
1127
- enum: ["service", "entity", "controller", "component"],
1128
- description: "Type of extension to scaffold"
1510
+ enum: ["feature", "service", "entity", "controller", "component", "test", "dto", "validator", "repository"],
1511
+ description: 'Type of extension to scaffold. Use "feature" for full-stack generation.'
1129
1512
  },
1130
1513
  name: {
1131
1514
  type: "string",
@@ -1163,6 +1546,63 @@ var scaffoldExtensionTool = {
1163
1546
  type: "string",
1164
1547
  enum: ["core", "extensions"],
1165
1548
  description: "Database schema (default: core)"
1549
+ },
1550
+ dryRun: {
1551
+ type: "boolean",
1552
+ description: "If true, preview generated code without writing files"
1553
+ },
1554
+ skipService: {
1555
+ type: "boolean",
1556
+ description: "For feature type: skip service generation"
1557
+ },
1558
+ skipController: {
1559
+ type: "boolean",
1560
+ description: "For feature type: skip controller generation"
1561
+ },
1562
+ skipComponent: {
1563
+ type: "boolean",
1564
+ description: "For feature type: skip React component generation"
1565
+ },
1566
+ clientExtension: {
1567
+ type: "boolean",
1568
+ description: "If true, use extensions schema for client-specific code"
1569
+ },
1570
+ withTests: {
1571
+ type: "boolean",
1572
+ description: "For feature type: also generate unit tests"
1573
+ },
1574
+ withDtos: {
1575
+ type: "boolean",
1576
+ description: "For feature type: generate DTOs (Create, Update, Response)"
1577
+ },
1578
+ withValidation: {
1579
+ type: "boolean",
1580
+ description: "For feature type: generate FluentValidation validators"
1581
+ },
1582
+ withRepository: {
1583
+ type: "boolean",
1584
+ description: "For feature type: generate repository pattern"
1585
+ },
1586
+ entityProperties: {
1587
+ type: "array",
1588
+ items: {
1589
+ type: "object",
1590
+ properties: {
1591
+ name: { type: "string" },
1592
+ type: { type: "string" },
1593
+ required: { type: "boolean" },
1594
+ maxLength: { type: "number" }
1595
+ }
1596
+ },
1597
+ description: "Entity properties for DTO/Validator generation"
1598
+ },
1599
+ navRoute: {
1600
+ type: "string",
1601
+ description: 'Navigation route path for controller (e.g., "platform.administration.users"). Required for controllers.'
1602
+ },
1603
+ navRouteSuffix: {
1604
+ type: "string",
1605
+ description: 'Optional suffix for NavRoute (e.g., "dashboard" for sub-resources)'
1166
1606
  }
1167
1607
  }
1168
1608
  }
@@ -1181,35 +1621,149 @@ Handlebars.registerHelper("kebabCase", (str) => {
1181
1621
  });
1182
1622
  async function handleScaffoldExtension(args, config) {
1183
1623
  const input = ScaffoldExtensionInputSchema.parse(args);
1184
- logger.info("Scaffolding extension", { type: input.type, name: input.name });
1624
+ const dryRun = input.options?.dryRun || false;
1625
+ logger.info("Scaffolding extension", { type: input.type, name: input.name, dryRun });
1185
1626
  const structure = await findSmartStackStructure(config.smartstack.projectPath);
1186
1627
  const result = {
1187
1628
  success: true,
1188
1629
  files: [],
1189
1630
  instructions: []
1190
1631
  };
1632
+ if (input.options?.clientExtension) {
1633
+ input.options.schema = "extensions";
1634
+ input.options.tablePrefix = input.options.tablePrefix || "ext_";
1635
+ }
1191
1636
  try {
1192
1637
  switch (input.type) {
1638
+ case "feature":
1639
+ await scaffoldFeature(input.name, input.options, structure, config, result, dryRun);
1640
+ break;
1193
1641
  case "service":
1194
- await scaffoldService(input.name, input.options, structure, config, result);
1642
+ await scaffoldService(input.name, input.options, structure, config, result, dryRun);
1195
1643
  break;
1196
1644
  case "entity":
1197
- await scaffoldEntity(input.name, input.options, structure, config, result);
1645
+ await scaffoldEntity(input.name, input.options, structure, config, result, dryRun);
1198
1646
  break;
1199
1647
  case "controller":
1200
- await scaffoldController(input.name, input.options, structure, config, result);
1648
+ await scaffoldController(input.name, input.options, structure, config, result, dryRun);
1201
1649
  break;
1202
1650
  case "component":
1203
- await scaffoldComponent(input.name, input.options, structure, config, result);
1651
+ await scaffoldComponent(input.name, input.options, structure, config, result, dryRun);
1652
+ break;
1653
+ case "test":
1654
+ await scaffoldTest(input.name, input.options, structure, config, result, dryRun);
1655
+ break;
1656
+ case "dto":
1657
+ await scaffoldDtos(input.name, input.options, structure, config, result, dryRun);
1658
+ break;
1659
+ case "validator":
1660
+ await scaffoldValidator(input.name, input.options, structure, config, result, dryRun);
1661
+ break;
1662
+ case "repository":
1663
+ await scaffoldRepository(input.name, input.options, structure, config, result, dryRun);
1204
1664
  break;
1205
1665
  }
1206
1666
  } catch (error) {
1207
1667
  result.success = false;
1208
1668
  result.instructions.push(`Error: ${error instanceof Error ? error.message : "Unknown error"}`);
1209
1669
  }
1210
- return formatResult3(result, input.type, input.name);
1670
+ return formatResult3(result, input.type, input.name, dryRun);
1671
+ }
1672
+ async function scaffoldFeature(name, options, structure, config, result, dryRun = false) {
1673
+ const skipService = options?.skipService || false;
1674
+ const skipController = options?.skipController || false;
1675
+ const skipComponent = options?.skipComponent || false;
1676
+ const isClientExtension = options?.clientExtension || false;
1677
+ const withTests = options?.withTests || false;
1678
+ const withDtos = options?.withDtos || false;
1679
+ const withValidation = options?.withValidation || false;
1680
+ const withRepository = options?.withRepository || false;
1681
+ let stepNumber = 1;
1682
+ result.instructions.push(`# Full-Stack Feature: ${name}`);
1683
+ result.instructions.push("");
1684
+ if (isClientExtension) {
1685
+ result.instructions.push("> **Client Extension**: Using `extensions` schema and `ext_` prefix");
1686
+ result.instructions.push("");
1687
+ }
1688
+ result.instructions.push(`## ${stepNumber}. Domain Entity`);
1689
+ await scaffoldEntity(name, options, structure, config, result, dryRun);
1690
+ result.instructions.push("");
1691
+ stepNumber++;
1692
+ if (withDtos) {
1693
+ result.instructions.push(`## ${stepNumber}. DTOs (Data Transfer Objects)`);
1694
+ await scaffoldDtos(name, options, structure, config, result, dryRun);
1695
+ result.instructions.push("");
1696
+ stepNumber++;
1697
+ }
1698
+ if (withValidation) {
1699
+ result.instructions.push(`## ${stepNumber}. FluentValidation Validators`);
1700
+ await scaffoldValidator(name, options, structure, config, result, dryRun);
1701
+ result.instructions.push("");
1702
+ stepNumber++;
1703
+ }
1704
+ if (withRepository) {
1705
+ result.instructions.push(`## ${stepNumber}. Repository Pattern`);
1706
+ await scaffoldRepository(name, options, structure, config, result, dryRun);
1707
+ result.instructions.push("");
1708
+ stepNumber++;
1709
+ }
1710
+ if (!skipService) {
1711
+ result.instructions.push(`## ${stepNumber}. Application Service`);
1712
+ const serviceMethods = [
1713
+ `GetByIdAsync`,
1714
+ `GetAllAsync`,
1715
+ `CreateAsync`,
1716
+ `UpdateAsync`,
1717
+ `DeleteAsync`
1718
+ ];
1719
+ await scaffoldService(name, { ...options, methods: serviceMethods }, structure, config, result, dryRun);
1720
+ result.instructions.push("");
1721
+ stepNumber++;
1722
+ }
1723
+ if (!skipController) {
1724
+ result.instructions.push(`## ${stepNumber}. API Controller`);
1725
+ await scaffoldController(name, options, structure, config, result, dryRun);
1726
+ result.instructions.push("");
1727
+ stepNumber++;
1728
+ }
1729
+ if (!skipComponent) {
1730
+ result.instructions.push(`## ${stepNumber}. React Component`);
1731
+ await scaffoldComponent(name, options, structure, config, result, dryRun);
1732
+ result.instructions.push("");
1733
+ stepNumber++;
1734
+ }
1735
+ if (withTests && !skipService) {
1736
+ result.instructions.push(`## ${stepNumber}. Unit Tests`);
1737
+ await scaffoldTest(name, options, structure, config, result, dryRun);
1738
+ result.instructions.push("");
1739
+ }
1740
+ const generated = ["Entity"];
1741
+ if (withDtos) generated.push("DTOs");
1742
+ if (withValidation) generated.push("Validators");
1743
+ if (withRepository) generated.push("Repository");
1744
+ if (!skipService) generated.push("Service");
1745
+ if (!skipController) generated.push("Controller");
1746
+ if (!skipComponent) generated.push("Component");
1747
+ if (withTests && !skipService) generated.push("Tests");
1748
+ result.instructions.push("---");
1749
+ result.instructions.push(`## Summary: Generated ${generated.join(" + ")}`);
1750
+ result.instructions.push("");
1751
+ result.instructions.push("### Next Steps:");
1752
+ result.instructions.push(`1. Add DbSet to ApplicationDbContext: \`public DbSet<${name}> ${name}s => Set<${name}>();\``);
1753
+ if (withRepository) {
1754
+ result.instructions.push(`2. Register repository: \`services.AddScoped<I${name}Repository, ${name}Repository>();\``);
1755
+ }
1756
+ result.instructions.push(`${withRepository ? "3" : "2"}. Register service: \`services.AddScoped<I${name}Service, ${name}Service>();\``);
1757
+ if (withValidation) {
1758
+ result.instructions.push(`${withRepository ? "4" : "3"}. Register validators: \`services.AddValidatorsFromAssemblyContaining<Create${name}DtoValidator>();\``);
1759
+ }
1760
+ result.instructions.push(`${withRepository ? withValidation ? "5" : "4" : withValidation ? "4" : "3"}. Create migration: \`dotnet ef migrations add ${options?.tablePrefix || "ref_"}vX.X.X_XXX_Add${name}\``);
1761
+ result.instructions.push(`${withRepository ? withValidation ? "6" : "5" : withValidation ? "5" : "4"}. Run migration: \`dotnet ef database update\``);
1762
+ if (!skipComponent) {
1763
+ result.instructions.push(`Import component: \`import { ${name} } from './components/${name}';\``);
1764
+ }
1211
1765
  }
1212
- async function scaffoldService(name, options, structure, config, result) {
1766
+ async function scaffoldService(name, options, structure, config, result, dryRun = false) {
1213
1767
  const namespace = options?.namespace || `${config.conventions.namespaces.application}.Services`;
1214
1768
  const methods = options?.methods || ["GetByIdAsync", "GetAllAsync", "CreateAsync", "UpdateAsync", "DeleteAsync"];
1215
1769
  const interfaceTemplate = `using System.Threading;
@@ -1271,19 +1825,24 @@ services.AddScoped<I{{name}}Service, {{name}}Service>();
1271
1825
  const interfaceContent = Handlebars.compile(interfaceTemplate)(context);
1272
1826
  const implementationContent = Handlebars.compile(implementationTemplate)(context);
1273
1827
  const diContent = Handlebars.compile(diTemplate)(context);
1274
- const basePath = structure.application || config.smartstack.projectPath;
1828
+ const projectRoot = config.smartstack.projectPath;
1829
+ const basePath = structure.application || projectRoot;
1275
1830
  const servicesPath = path7.join(basePath, "Services");
1276
- await ensureDirectory(servicesPath);
1277
1831
  const interfacePath = path7.join(servicesPath, `I${name}Service.cs`);
1278
1832
  const implementationPath = path7.join(servicesPath, `${name}Service.cs`);
1279
- await writeText(interfacePath, interfaceContent);
1833
+ validatePathSecurity(interfacePath, projectRoot);
1834
+ validatePathSecurity(implementationPath, projectRoot);
1835
+ if (!dryRun) {
1836
+ await ensureDirectory(servicesPath);
1837
+ await writeText(interfacePath, interfaceContent);
1838
+ await writeText(implementationPath, implementationContent);
1839
+ }
1280
1840
  result.files.push({ path: interfacePath, content: interfaceContent, type: "created" });
1281
- await writeText(implementationPath, implementationContent);
1282
1841
  result.files.push({ path: implementationPath, content: implementationContent, type: "created" });
1283
1842
  result.instructions.push("Register service in DI container:");
1284
1843
  result.instructions.push(diContent);
1285
1844
  }
1286
- async function scaffoldEntity(name, options, structure, config, result) {
1845
+ async function scaffoldEntity(name, options, structure, config, result, dryRun = false) {
1287
1846
  const namespace = options?.namespace || config.conventions.namespaces.domain;
1288
1847
  const baseEntity = options?.baseEntity;
1289
1848
  const isSystemEntity = options?.isSystemEntity || false;
@@ -1297,14 +1856,30 @@ namespace {{namespace}};
1297
1856
 
1298
1857
  /// <summary>
1299
1858
  /// {{name}} entity{{#if baseEntity}} extending {{baseEntity}}{{/if}}
1859
+ {{#unless isSystemEntity}}
1860
+ /// Tenant-scoped: data is isolated per tenant
1861
+ {{else}}
1862
+ /// System entity: platform-level, no tenant isolation
1863
+ {{/unless}}
1300
1864
  /// </summary>
1301
1865
  {{#if isSystemEntity}}
1302
1866
  public class {{name}} : SystemEntity
1303
1867
  {{else}}
1304
- public class {{name}} : BaseEntity
1868
+ public class {{name}} : BaseEntity, ITenantEntity
1305
1869
  {{/if}}
1306
1870
  {
1871
+ {{#unless isSystemEntity}}
1872
+ // === MULTI-TENANT ===
1873
+
1874
+ /// <summary>
1875
+ /// Tenant identifier for multi-tenant isolation (required)
1876
+ /// </summary>
1877
+ public Guid TenantId { get; private set; }
1878
+
1879
+ {{/unless}}
1307
1880
  {{#if baseEntity}}
1881
+ // === RELATIONSHIPS ===
1882
+
1308
1883
  /// <summary>
1309
1884
  /// Foreign key to {{baseEntity}}
1310
1885
  /// </summary>
@@ -1341,11 +1916,17 @@ public class {{name}} : BaseEntity
1341
1916
  };
1342
1917
  }
1343
1918
  {{else}}
1919
+ /// <param name="tenantId">Required tenant identifier</param>
1920
+ /// <param name="code">Unique code within tenant (will be lowercased)</param>
1921
+ /// <param name="createdBy">User who created this entity</param>
1344
1922
  public static {{name}} Create(
1345
1923
  Guid tenantId,
1346
1924
  string code,
1347
1925
  string? createdBy = null)
1348
1926
  {
1927
+ if (tenantId == Guid.Empty)
1928
+ throw new ArgumentException("TenantId is required", nameof(tenantId));
1929
+
1349
1930
  return new {{name}}
1350
1931
  {
1351
1932
  Id = Guid.NewGuid(),
@@ -1395,6 +1976,14 @@ using {{domainNamespace}};
1395
1976
 
1396
1977
  namespace {{infrastructureNamespace}}.Persistence.Configurations;
1397
1978
 
1979
+ /// <summary>
1980
+ /// EF Core configuration for {{name}}
1981
+ {{#unless isSystemEntity}}
1982
+ /// Tenant-aware: includes tenant isolation query filter
1983
+ {{else}}
1984
+ /// System entity: no tenant isolation
1985
+ {{/unless}}
1986
+ /// </summary>
1398
1987
  public class {{name}}Configuration : IEntityTypeConfiguration<{{name}}>
1399
1988
  {
1400
1989
  public void Configure(EntityTypeBuilder<{{name}}> builder)
@@ -1406,9 +1995,17 @@ public class {{name}}Configuration : IEntityTypeConfiguration<{{name}}>
1406
1995
  builder.HasKey(e => e.Id);
1407
1996
 
1408
1997
  {{#unless isSystemEntity}}
1409
- // Multi-tenant
1998
+ // ============================================
1999
+ // MULTI-TENANT CONFIGURATION
2000
+ // ============================================
2001
+
2002
+ // TenantId is required for tenant isolation
1410
2003
  builder.Property(e => e.TenantId).IsRequired();
1411
- builder.HasIndex(e => e.TenantId);
2004
+ builder.HasIndex(e => e.TenantId)
2005
+ .HasDatabaseName("IX_{{tablePrefix}}{{name}}s_TenantId");
2006
+
2007
+ // Tenant relationship (configured in Tenant configuration)
2008
+ // builder.HasOne<Tenant>().WithMany().HasForeignKey(e => e.TenantId);
1412
2009
 
1413
2010
  // Code: lowercase, unique per tenant (filtered for soft delete)
1414
2011
  builder.Property(e => e.Code).HasMaxLength(100).IsRequired();
@@ -1417,7 +2014,7 @@ public class {{name}}Configuration : IEntityTypeConfiguration<{{name}}>
1417
2014
  .HasFilter("[IsDeleted] = 0")
1418
2015
  .HasDatabaseName("IX_{{tablePrefix}}{{name}}s_Tenant_Code_Unique");
1419
2016
  {{else}}
1420
- // Code: lowercase, unique (filtered for soft delete)
2017
+ // Code: lowercase, unique globally (filtered for soft delete)
1421
2018
  builder.Property(e => e.Code).HasMaxLength(100).IsRequired();
1422
2019
  builder.HasIndex(e => e.Code)
1423
2020
  .IsUnique()
@@ -1434,6 +2031,10 @@ public class {{name}}Configuration : IEntityTypeConfiguration<{{name}}>
1434
2031
  builder.Property(e => e.DeletedBy).HasMaxLength(256);
1435
2032
 
1436
2033
  {{#if baseEntity}}
2034
+ // ============================================
2035
+ // RELATIONSHIPS
2036
+ // ============================================
2037
+
1437
2038
  // Relationship to {{baseEntity}} (1:1)
1438
2039
  builder.HasOne(e => e.{{baseEntity}})
1439
2040
  .WithOne()
@@ -1445,7 +2046,17 @@ public class {{name}}Configuration : IEntityTypeConfiguration<{{name}}>
1445
2046
  .IsUnique();
1446
2047
  {{/if}}
1447
2048
 
1448
- // TODO: Add additional configuration
2049
+ // ============================================
2050
+ // QUERY FILTERS
2051
+ // ============================================
2052
+ // Note: Global query filters are applied in DbContext.OnModelCreating
2053
+ // - Soft delete: .HasQueryFilter(e => !e.IsDeleted)
2054
+ {{#unless isSystemEntity}}
2055
+ // - Tenant isolation: .HasQueryFilter(e => e.TenantId == _tenantId)
2056
+ // Combined filter applied via ITenantEntity interface check
2057
+ {{/unless}}
2058
+
2059
+ // TODO: Add additional business-specific configuration
1449
2060
  }
1450
2061
  }
1451
2062
  `;
@@ -1461,15 +2072,20 @@ public class {{name}}Configuration : IEntityTypeConfiguration<{{name}}>
1461
2072
  };
1462
2073
  const entityContent = Handlebars.compile(entityTemplate)(context);
1463
2074
  const configContent = Handlebars.compile(configTemplate)(context);
1464
- const domainPath = structure.domain || path7.join(config.smartstack.projectPath, "Domain");
1465
- const infraPath = structure.infrastructure || path7.join(config.smartstack.projectPath, "Infrastructure");
1466
- await ensureDirectory(domainPath);
1467
- await ensureDirectory(path7.join(infraPath, "Persistence", "Configurations"));
2075
+ const projectRoot = config.smartstack.projectPath;
2076
+ const domainPath = structure.domain || path7.join(projectRoot, "Domain");
2077
+ const infraPath = structure.infrastructure || path7.join(projectRoot, "Infrastructure");
1468
2078
  const entityFilePath = path7.join(domainPath, `${name}.cs`);
1469
2079
  const configFilePath = path7.join(infraPath, "Persistence", "Configurations", `${name}Configuration.cs`);
1470
- await writeText(entityFilePath, entityContent);
2080
+ validatePathSecurity(entityFilePath, projectRoot);
2081
+ validatePathSecurity(configFilePath, projectRoot);
2082
+ if (!dryRun) {
2083
+ await ensureDirectory(domainPath);
2084
+ await ensureDirectory(path7.join(infraPath, "Persistence", "Configurations"));
2085
+ await writeText(entityFilePath, entityContent);
2086
+ await writeText(configFilePath, configContent);
2087
+ }
1471
2088
  result.files.push({ path: entityFilePath, content: entityContent, type: "created" });
1472
- await writeText(configFilePath, configContent);
1473
2089
  result.files.push({ path: configFilePath, content: configContent, type: "created" });
1474
2090
  result.instructions.push(`Add DbSet to ApplicationDbContext:`);
1475
2091
  result.instructions.push(`public DbSet<${name}> ${name}s => Set<${name}>();`);
@@ -1483,19 +2099,23 @@ public class {{name}}Configuration : IEntityTypeConfiguration<{{name}}>
1483
2099
  result.instructions.push("- IsDeleted, DeletedAt, DeletedBy (soft delete)");
1484
2100
  result.instructions.push("- RowVersion (concurrency)");
1485
2101
  }
1486
- async function scaffoldController(name, options, structure, config, result) {
2102
+ async function scaffoldController(name, options, structure, config, result, dryRun = false) {
1487
2103
  const namespace = options?.namespace || `${config.conventions.namespaces.api}.Controllers`;
2104
+ const navRoute = options?.navRoute;
2105
+ const navRouteSuffix = options?.navRouteSuffix;
2106
+ const routeAttribute = navRoute ? navRouteSuffix ? `[NavRoute("${navRoute}", Suffix = "${navRouteSuffix}")]` : `[NavRoute("${navRoute}")]` : `[Route("api/[controller]")]`;
2107
+ const navRouteUsing = navRoute ? "using SmartStack.Api.Core.Routing;\n" : "";
1488
2108
  const controllerTemplate = `using Microsoft.AspNetCore.Authorization;
1489
2109
  using Microsoft.AspNetCore.Mvc;
1490
2110
  using Microsoft.Extensions.Logging;
1491
-
2111
+ ${navRouteUsing}
1492
2112
  namespace {{namespace}};
1493
2113
 
1494
2114
  /// <summary>
1495
2115
  /// API controller for {{name}} operations
1496
2116
  /// </summary>
1497
2117
  [ApiController]
1498
- [Route("api/[controller]")]
2118
+ {{routeAttribute}}
1499
2119
  [Authorize]
1500
2120
  public class {{name}}Controller : ControllerBase
1501
2121
  {
@@ -1570,23 +2190,42 @@ public record Update{{name}}Request();
1570
2190
  const context = {
1571
2191
  namespace,
1572
2192
  name,
1573
- nameLower: name.charAt(0).toLowerCase() + name.slice(1)
2193
+ nameLower: name.charAt(0).toLowerCase() + name.slice(1),
2194
+ routeAttribute
1574
2195
  };
1575
2196
  const controllerContent = Handlebars.compile(controllerTemplate)(context);
1576
- const apiPath = structure.api || path7.join(config.smartstack.projectPath, "Api");
2197
+ const projectRoot = config.smartstack.projectPath;
2198
+ const apiPath = structure.api || path7.join(projectRoot, "Api");
1577
2199
  const controllersPath = path7.join(apiPath, "Controllers");
1578
- await ensureDirectory(controllersPath);
1579
2200
  const controllerFilePath = path7.join(controllersPath, `${name}Controller.cs`);
1580
- await writeText(controllerFilePath, controllerContent);
2201
+ validatePathSecurity(controllerFilePath, projectRoot);
2202
+ if (!dryRun) {
2203
+ await ensureDirectory(controllersPath);
2204
+ await writeText(controllerFilePath, controllerContent);
2205
+ }
1581
2206
  result.files.push({ path: controllerFilePath, content: controllerContent, type: "created" });
1582
- result.instructions.push("Controller created. API endpoints:");
1583
- result.instructions.push(` GET /api/${name.toLowerCase()}`);
1584
- result.instructions.push(` GET /api/${name.toLowerCase()}/{id}`);
1585
- result.instructions.push(` POST /api/${name.toLowerCase()}`);
1586
- result.instructions.push(` PUT /api/${name.toLowerCase()}/{id}`);
1587
- result.instructions.push(` DELETE /api/${name.toLowerCase()}/{id}`);
1588
- }
1589
- async function scaffoldComponent(name, options, structure, config, result) {
2207
+ if (navRoute) {
2208
+ result.instructions.push("Controller created with NavRoute (Navigation-based routing).");
2209
+ result.instructions.push(`NavRoute: ${navRoute}${navRouteSuffix ? ` (Suffix: ${navRouteSuffix})` : ""}`);
2210
+ result.instructions.push("");
2211
+ result.instructions.push("The actual API route will be resolved from Navigation entities at startup.");
2212
+ result.instructions.push("Ensure the navigation path exists in the database:");
2213
+ result.instructions.push(` Context > Application > Module > Section matching "${navRoute}"`);
2214
+ } else {
2215
+ result.instructions.push("Controller created with traditional routing.");
2216
+ result.instructions.push("");
2217
+ result.instructions.push("\u26A0\uFE0F Consider using NavRoute for navigation-based routing:");
2218
+ result.instructions.push(` [NavRoute("context.application.module")]`);
2219
+ result.instructions.push("");
2220
+ result.instructions.push("API endpoints (with traditional routing):");
2221
+ result.instructions.push(` GET /api/${name.toLowerCase()}`);
2222
+ result.instructions.push(` GET /api/${name.toLowerCase()}/{id}`);
2223
+ result.instructions.push(` POST /api/${name.toLowerCase()}`);
2224
+ result.instructions.push(` PUT /api/${name.toLowerCase()}/{id}`);
2225
+ result.instructions.push(` DELETE /api/${name.toLowerCase()}/{id}`);
2226
+ }
2227
+ }
2228
+ async function scaffoldComponent(name, options, structure, config, result, dryRun = false) {
1590
2229
  const componentTemplate = `import React, { useState, useEffect } from 'react';
1591
2230
 
1592
2231
  interface {{name}}Props {
@@ -1737,51 +2376,553 @@ export function use{{name}}(options: Use{{name}}Options = {}) {
1737
2376
  };
1738
2377
  const componentContent = Handlebars.compile(componentTemplate)(context);
1739
2378
  const hookContent = Handlebars.compile(hookTemplate)(context);
1740
- const webPath = structure.web || path7.join(config.smartstack.projectPath, "web", "smartstack-web");
2379
+ const projectRoot = config.smartstack.projectPath;
2380
+ const webPath = structure.web || path7.join(projectRoot, "web", "smartstack-web");
1741
2381
  const componentsPath = options?.outputPath || path7.join(webPath, "src", "components");
1742
2382
  const hooksPath = path7.join(webPath, "src", "hooks");
1743
- await ensureDirectory(componentsPath);
1744
- await ensureDirectory(hooksPath);
1745
2383
  const componentFilePath = path7.join(componentsPath, `${name}.tsx`);
1746
2384
  const hookFilePath = path7.join(hooksPath, `use${name}.ts`);
1747
- await writeText(componentFilePath, componentContent);
2385
+ validatePathSecurity(componentFilePath, projectRoot);
2386
+ validatePathSecurity(hookFilePath, projectRoot);
2387
+ if (!dryRun) {
2388
+ await ensureDirectory(componentsPath);
2389
+ await ensureDirectory(hooksPath);
2390
+ await writeText(componentFilePath, componentContent);
2391
+ await writeText(hookFilePath, hookContent);
2392
+ }
1748
2393
  result.files.push({ path: componentFilePath, content: componentContent, type: "created" });
1749
- await writeText(hookFilePath, hookContent);
1750
2394
  result.files.push({ path: hookFilePath, content: hookContent, type: "created" });
1751
2395
  result.instructions.push("Import and use the component:");
1752
2396
  result.instructions.push(`import { ${name} } from './components/${name}';`);
1753
2397
  result.instructions.push(`import { use${name} } from './hooks/use${name}';`);
1754
2398
  }
1755
- function formatResult3(result, type, name) {
1756
- const lines = [];
1757
- lines.push(`# Scaffold ${type}: ${name}`);
1758
- lines.push("");
1759
- if (result.success) {
1760
- lines.push("## \u2705 Files Generated");
1761
- lines.push("");
1762
- for (const file of result.files) {
1763
- lines.push(`### ${file.type === "created" ? "\u{1F4C4}" : "\u270F\uFE0F"} ${path7.basename(file.path)}`);
1764
- lines.push(`**Path**: \`${file.path}\``);
1765
- lines.push("");
1766
- lines.push("```" + (file.path.endsWith(".cs") ? "csharp" : "typescript"));
1767
- const contentLines = file.content.split("\n").slice(0, 50);
1768
- lines.push(contentLines.join("\n"));
1769
- if (file.content.split("\n").length > 50) {
1770
- lines.push("// ... (truncated)");
1771
- }
1772
- lines.push("```");
1773
- lines.push("");
2399
+ async function scaffoldTest(name, options, structure, config, result, dryRun = false) {
2400
+ const isSystemEntity = options?.isSystemEntity || false;
2401
+ const serviceTestTemplate = `using System;
2402
+ using System.Threading;
2403
+ using System.Threading.Tasks;
2404
+ using Microsoft.Extensions.Logging;
2405
+ using Moq;
2406
+ using Xunit;
2407
+ using FluentAssertions;
2408
+ using ${config.conventions.namespaces.application}.Services;
2409
+
2410
+ namespace ${config.conventions.namespaces.application}.Tests.Services;
2411
+
2412
+ /// <summary>
2413
+ /// Unit tests for {{name}}Service
2414
+ /// </summary>
2415
+ public class {{name}}ServiceTests
2416
+ {
2417
+ private readonly Mock<ILogger<{{name}}Service>> _loggerMock;
2418
+ private readonly {{name}}Service _sut;
2419
+
2420
+ public {{name}}ServiceTests()
2421
+ {
2422
+ _loggerMock = new Mock<ILogger<{{name}}Service>>();
2423
+ _sut = new {{name}}Service(_loggerMock.Object);
1774
2424
  }
1775
- if (result.instructions.length > 0) {
1776
- lines.push("## \u{1F4CB} Next Steps");
1777
- lines.push("");
1778
- for (const instruction of result.instructions) {
1779
- if (instruction.startsWith("services.") || instruction.startsWith("public DbSet")) {
1780
- lines.push("```csharp");
1781
- lines.push(instruction);
1782
- lines.push("```");
1783
- } else if (instruction.startsWith("dotnet ")) {
1784
- lines.push("```bash");
2425
+
2426
+ [Fact]
2427
+ public async Task GetByIdAsync_ShouldReturnEntity_WhenExists()
2428
+ {
2429
+ // Arrange
2430
+ var id = Guid.NewGuid();
2431
+ {{#unless isSystemEntity}}
2432
+ var tenantId = Guid.NewGuid();
2433
+ {{/unless}}
2434
+
2435
+ // Act
2436
+ var result = await _sut.GetByIdAsync(CancellationToken.None);
2437
+
2438
+ // Assert
2439
+ // TODO: Implement actual assertion
2440
+ result.Should().NotBeNull();
2441
+ }
2442
+
2443
+ [Fact]
2444
+ public async Task GetAllAsync_ShouldReturnList()
2445
+ {
2446
+ // Arrange
2447
+ {{#unless isSystemEntity}}
2448
+ var tenantId = Guid.NewGuid();
2449
+ {{/unless}}
2450
+
2451
+ // Act
2452
+ var result = await _sut.GetAllAsync(CancellationToken.None);
2453
+
2454
+ // Assert
2455
+ // TODO: Implement actual assertion
2456
+ result.Should().NotBeNull();
2457
+ }
2458
+
2459
+ [Fact]
2460
+ public async Task CreateAsync_ShouldCreateEntity()
2461
+ {
2462
+ // Arrange
2463
+ {{#unless isSystemEntity}}
2464
+ var tenantId = Guid.NewGuid();
2465
+ {{/unless}}
2466
+
2467
+ // Act
2468
+ var result = await _sut.CreateAsync(CancellationToken.None);
2469
+
2470
+ // Assert
2471
+ // TODO: Implement actual assertion
2472
+ result.Should().NotBeNull();
2473
+ }
2474
+
2475
+ [Fact]
2476
+ public async Task UpdateAsync_ShouldUpdateEntity_WhenExists()
2477
+ {
2478
+ // Arrange
2479
+ var id = Guid.NewGuid();
2480
+
2481
+ // Act
2482
+ var result = await _sut.UpdateAsync(CancellationToken.None);
2483
+
2484
+ // Assert
2485
+ // TODO: Implement actual assertion
2486
+ result.Should().NotBeNull();
2487
+ }
2488
+
2489
+ [Fact]
2490
+ public async Task DeleteAsync_ShouldSoftDelete_WhenExists()
2491
+ {
2492
+ // Arrange
2493
+ var id = Guid.NewGuid();
2494
+
2495
+ // Act
2496
+ var result = await _sut.DeleteAsync(CancellationToken.None);
2497
+
2498
+ // Assert
2499
+ // TODO: Implement actual assertion
2500
+ result.Should().NotBeNull();
2501
+ }
2502
+ }
2503
+ `;
2504
+ const context = {
2505
+ name,
2506
+ isSystemEntity
2507
+ };
2508
+ const testContent = Handlebars.compile(serviceTestTemplate)(context);
2509
+ const testsPath = structure.application ? path7.join(path7.dirname(structure.application), `${path7.basename(structure.application)}.Tests`, "Services") : path7.join(config.smartstack.projectPath, "Application.Tests", "Services");
2510
+ const testFilePath = path7.join(testsPath, `${name}ServiceTests.cs`);
2511
+ if (!dryRun) {
2512
+ await ensureDirectory(testsPath);
2513
+ await writeText(testFilePath, testContent);
2514
+ }
2515
+ result.files.push({ path: testFilePath, content: testContent, type: "created" });
2516
+ result.instructions.push("Run tests:");
2517
+ result.instructions.push(`dotnet test --filter "FullyQualifiedName~${name}ServiceTests"`);
2518
+ result.instructions.push("");
2519
+ result.instructions.push("Required packages:");
2520
+ result.instructions.push("- xunit");
2521
+ result.instructions.push("- Moq");
2522
+ result.instructions.push("- FluentAssertions");
2523
+ }
2524
+ async function scaffoldDtos(name, options, structure, config, result, dryRun = false) {
2525
+ const namespace = options?.namespace || `${config.conventions.namespaces.application}.DTOs`;
2526
+ const isSystemEntity = options?.isSystemEntity || false;
2527
+ const properties = options?.entityProperties || [
2528
+ { name: "Name", type: "string", required: true, maxLength: 200 },
2529
+ { name: "Description", type: "string?", required: false, maxLength: 500 }
2530
+ ];
2531
+ const responseDtoTemplate = `using System;
2532
+
2533
+ namespace {{namespace}};
2534
+
2535
+ /// <summary>
2536
+ /// Response DTO for {{name}}
2537
+ /// </summary>
2538
+ public record {{name}}ResponseDto
2539
+ {
2540
+ /// <summary>Unique identifier</summary>
2541
+ public Guid Id { get; init; }
2542
+
2543
+ {{#unless isSystemEntity}}
2544
+ /// <summary>Tenant identifier</summary>
2545
+ public Guid TenantId { get; init; }
2546
+
2547
+ {{/unless}}
2548
+ /// <summary>Unique code</summary>
2549
+ public string Code { get; init; } = string.Empty;
2550
+
2551
+ {{#each properties}}
2552
+ /// <summary>{{name}}</summary>
2553
+ public {{type}} {{name}} { get; init; }{{#if (eq type "string")}} = string.Empty;{{/if}}
2554
+
2555
+ {{/each}}
2556
+ /// <summary>Creation timestamp</summary>
2557
+ public DateTime CreatedAt { get; init; }
2558
+
2559
+ /// <summary>Last update timestamp</summary>
2560
+ public DateTime? UpdatedAt { get; init; }
2561
+
2562
+ /// <summary>Created by user</summary>
2563
+ public string? CreatedBy { get; init; }
2564
+ }
2565
+ `;
2566
+ const createDtoTemplate = `using System;
2567
+ using System.ComponentModel.DataAnnotations;
2568
+
2569
+ namespace {{namespace}};
2570
+
2571
+ /// <summary>
2572
+ /// DTO for creating a new {{name}}
2573
+ /// </summary>
2574
+ public record Create{{name}}Dto
2575
+ {
2576
+ /// <summary>Unique code (will be lowercased)</summary>
2577
+ [Required]
2578
+ [MaxLength(100)]
2579
+ public string Code { get; init; } = string.Empty;
2580
+
2581
+ {{#each properties}}
2582
+ {{#if required}}
2583
+ /// <summary>{{name}} (required)</summary>
2584
+ [Required]
2585
+ {{#if maxLength}}
2586
+ [MaxLength({{maxLength}})]
2587
+ {{/if}}
2588
+ public {{type}} {{name}} { get; init; }{{#if (eq type "string")}} = string.Empty;{{/if}}
2589
+
2590
+ {{else}}
2591
+ /// <summary>{{name}} (optional)</summary>
2592
+ {{#if maxLength}}
2593
+ [MaxLength({{maxLength}})]
2594
+ {{/if}}
2595
+ public {{type}} {{name}} { get; init; }
2596
+
2597
+ {{/if}}
2598
+ {{/each}}
2599
+ }
2600
+ `;
2601
+ const updateDtoTemplate = `using System;
2602
+ using System.ComponentModel.DataAnnotations;
2603
+
2604
+ namespace {{namespace}};
2605
+
2606
+ /// <summary>
2607
+ /// DTO for updating an existing {{name}}
2608
+ /// </summary>
2609
+ public record Update{{name}}Dto
2610
+ {
2611
+ {{#each properties}}
2612
+ {{#if required}}
2613
+ /// <summary>{{name}} (required)</summary>
2614
+ [Required]
2615
+ {{#if maxLength}}
2616
+ [MaxLength({{maxLength}})]
2617
+ {{/if}}
2618
+ public {{type}} {{name}} { get; init; }{{#if (eq type "string")}} = string.Empty;{{/if}}
2619
+
2620
+ {{else}}
2621
+ /// <summary>{{name}} (optional)</summary>
2622
+ {{#if maxLength}}
2623
+ [MaxLength({{maxLength}})]
2624
+ {{/if}}
2625
+ public {{type}} {{name}} { get; init; }
2626
+
2627
+ {{/if}}
2628
+ {{/each}}
2629
+ }
2630
+ `;
2631
+ Handlebars.registerHelper("eq", (a, b) => a === b);
2632
+ const context = {
2633
+ namespace,
2634
+ name,
2635
+ isSystemEntity,
2636
+ properties
2637
+ };
2638
+ const responseContent = Handlebars.compile(responseDtoTemplate)(context);
2639
+ const createContent = Handlebars.compile(createDtoTemplate)(context);
2640
+ const updateContent = Handlebars.compile(updateDtoTemplate)(context);
2641
+ const basePath = structure.application || config.smartstack.projectPath;
2642
+ const dtosPath = path7.join(basePath, "DTOs", name);
2643
+ const responseFilePath = path7.join(dtosPath, `${name}ResponseDto.cs`);
2644
+ const createFilePath = path7.join(dtosPath, `Create${name}Dto.cs`);
2645
+ const updateFilePath = path7.join(dtosPath, `Update${name}Dto.cs`);
2646
+ if (!dryRun) {
2647
+ await ensureDirectory(dtosPath);
2648
+ await writeText(responseFilePath, responseContent);
2649
+ await writeText(createFilePath, createContent);
2650
+ await writeText(updateFilePath, updateContent);
2651
+ }
2652
+ result.files.push({ path: responseFilePath, content: responseContent, type: "created" });
2653
+ result.files.push({ path: createFilePath, content: createContent, type: "created" });
2654
+ result.files.push({ path: updateFilePath, content: updateContent, type: "created" });
2655
+ result.instructions.push("DTOs generated:");
2656
+ result.instructions.push(`- ${name}ResponseDto: For API responses`);
2657
+ result.instructions.push(`- Create${name}Dto: For POST requests`);
2658
+ result.instructions.push(`- Update${name}Dto: For PUT requests`);
2659
+ }
2660
+ async function scaffoldValidator(name, options, structure, config, result, dryRun = false) {
2661
+ const namespace = options?.namespace || `${config.conventions.namespaces.application}.Validators`;
2662
+ const properties = options?.entityProperties || [
2663
+ { name: "Name", type: "string", required: true, maxLength: 200 },
2664
+ { name: "Description", type: "string?", required: false, maxLength: 500 }
2665
+ ];
2666
+ const createValidatorTemplate = `using FluentValidation;
2667
+ using ${config.conventions.namespaces.application}.DTOs;
2668
+
2669
+ namespace {{namespace}};
2670
+
2671
+ /// <summary>
2672
+ /// Validator for Create{{name}}Dto
2673
+ /// </summary>
2674
+ public class Create{{name}}DtoValidator : AbstractValidator<Create{{name}}Dto>
2675
+ {
2676
+ public Create{{name}}DtoValidator()
2677
+ {
2678
+ RuleFor(x => x.Code)
2679
+ .NotEmpty().WithMessage("Code is required")
2680
+ .MaximumLength(100).WithMessage("Code must not exceed 100 characters")
2681
+ .Matches("^[a-z0-9_]+$").WithMessage("Code must be lowercase alphanumeric with underscores");
2682
+
2683
+ {{#each properties}}
2684
+ {{#if required}}
2685
+ RuleFor(x => x.{{name}})
2686
+ .NotEmpty().WithMessage("{{name}} is required"){{#if maxLength}}
2687
+ .MaximumLength({{maxLength}}).WithMessage("{{name}} must not exceed {{maxLength}} characters"){{/if}};
2688
+
2689
+ {{else}}
2690
+ {{#if maxLength}}
2691
+ RuleFor(x => x.{{name}})
2692
+ .MaximumLength({{maxLength}}).WithMessage("{{name}} must not exceed {{maxLength}} characters")
2693
+ .When(x => !string.IsNullOrEmpty(x.{{name}}));
2694
+
2695
+ {{/if}}
2696
+ {{/if}}
2697
+ {{/each}}
2698
+ }
2699
+ }
2700
+ `;
2701
+ const updateValidatorTemplate = `using FluentValidation;
2702
+ using ${config.conventions.namespaces.application}.DTOs;
2703
+
2704
+ namespace {{namespace}};
2705
+
2706
+ /// <summary>
2707
+ /// Validator for Update{{name}}Dto
2708
+ /// </summary>
2709
+ public class Update{{name}}DtoValidator : AbstractValidator<Update{{name}}Dto>
2710
+ {
2711
+ public Update{{name}}DtoValidator()
2712
+ {
2713
+ {{#each properties}}
2714
+ {{#if required}}
2715
+ RuleFor(x => x.{{name}})
2716
+ .NotEmpty().WithMessage("{{name}} is required"){{#if maxLength}}
2717
+ .MaximumLength({{maxLength}}).WithMessage("{{name}} must not exceed {{maxLength}} characters"){{/if}};
2718
+
2719
+ {{else}}
2720
+ {{#if maxLength}}
2721
+ RuleFor(x => x.{{name}})
2722
+ .MaximumLength({{maxLength}}).WithMessage("{{name}} must not exceed {{maxLength}} characters")
2723
+ .When(x => !string.IsNullOrEmpty(x.{{name}}));
2724
+
2725
+ {{/if}}
2726
+ {{/if}}
2727
+ {{/each}}
2728
+ }
2729
+ }
2730
+ `;
2731
+ const context = {
2732
+ namespace,
2733
+ name,
2734
+ properties
2735
+ };
2736
+ const createValidatorContent = Handlebars.compile(createValidatorTemplate)(context);
2737
+ const updateValidatorContent = Handlebars.compile(updateValidatorTemplate)(context);
2738
+ const basePath = structure.application || config.smartstack.projectPath;
2739
+ const validatorsPath = path7.join(basePath, "Validators");
2740
+ const createValidatorFilePath = path7.join(validatorsPath, `Create${name}DtoValidator.cs`);
2741
+ const updateValidatorFilePath = path7.join(validatorsPath, `Update${name}DtoValidator.cs`);
2742
+ if (!dryRun) {
2743
+ await ensureDirectory(validatorsPath);
2744
+ await writeText(createValidatorFilePath, createValidatorContent);
2745
+ await writeText(updateValidatorFilePath, updateValidatorContent);
2746
+ }
2747
+ result.files.push({ path: createValidatorFilePath, content: createValidatorContent, type: "created" });
2748
+ result.files.push({ path: updateValidatorFilePath, content: updateValidatorContent, type: "created" });
2749
+ result.instructions.push("Register validators in DI:");
2750
+ result.instructions.push(`services.AddValidatorsFromAssemblyContaining<Create${name}DtoValidator>();`);
2751
+ result.instructions.push("");
2752
+ result.instructions.push("Required package: FluentValidation.DependencyInjectionExtensions");
2753
+ }
2754
+ async function scaffoldRepository(name, options, structure, config, result, dryRun = false) {
2755
+ const isSystemEntity = options?.isSystemEntity || false;
2756
+ const interfaceTemplate = `using System;
2757
+ using System.Collections.Generic;
2758
+ using System.Threading;
2759
+ using System.Threading.Tasks;
2760
+ using ${config.conventions.namespaces.domain};
2761
+
2762
+ namespace ${config.conventions.namespaces.application}.Repositories;
2763
+
2764
+ /// <summary>
2765
+ /// Repository interface for {{name}} entity
2766
+ /// </summary>
2767
+ public interface I{{name}}Repository
2768
+ {
2769
+ /// <summary>Get entity by ID</summary>
2770
+ Task<{{name}}?> GetByIdAsync(Guid id, CancellationToken ct = default);
2771
+
2772
+ /// <summary>Get entity by code</summary>
2773
+ Task<{{name}}?> GetByCodeAsync({{#unless isSystemEntity}}Guid tenantId, {{/unless}}string code, CancellationToken ct = default);
2774
+
2775
+ /// <summary>Get all entities{{#unless isSystemEntity}} for tenant{{/unless}}</summary>
2776
+ Task<IReadOnlyList<{{name}}>> GetAllAsync({{#unless isSystemEntity}}Guid tenantId, {{/unless}}CancellationToken ct = default);
2777
+
2778
+ /// <summary>Check if code exists{{#unless isSystemEntity}} in tenant{{/unless}}</summary>
2779
+ Task<bool> ExistsAsync({{#unless isSystemEntity}}Guid tenantId, {{/unless}}string code, CancellationToken ct = default);
2780
+
2781
+ /// <summary>Add new entity</summary>
2782
+ Task<{{name}}> AddAsync({{name}} entity, CancellationToken ct = default);
2783
+
2784
+ /// <summary>Update entity</summary>
2785
+ Task UpdateAsync({{name}} entity, CancellationToken ct = default);
2786
+
2787
+ /// <summary>Soft delete entity</summary>
2788
+ Task DeleteAsync({{name}} entity, CancellationToken ct = default);
2789
+ }
2790
+ `;
2791
+ const implementationTemplate = `using System;
2792
+ using System.Collections.Generic;
2793
+ using System.Linq;
2794
+ using System.Threading;
2795
+ using System.Threading.Tasks;
2796
+ using Microsoft.EntityFrameworkCore;
2797
+ using ${config.conventions.namespaces.domain};
2798
+ using ${config.conventions.namespaces.infrastructure}.Persistence;
2799
+
2800
+ namespace ${config.conventions.namespaces.infrastructure}.Repositories;
2801
+
2802
+ /// <summary>
2803
+ /// Repository implementation for {{name}} entity
2804
+ /// </summary>
2805
+ public class {{name}}Repository : I{{name}}Repository
2806
+ {
2807
+ private readonly ApplicationDbContext _context;
2808
+
2809
+ public {{name}}Repository(ApplicationDbContext context)
2810
+ {
2811
+ _context = context;
2812
+ }
2813
+
2814
+ /// <inheritdoc />
2815
+ public async Task<{{name}}?> GetByIdAsync(Guid id, CancellationToken ct = default)
2816
+ {
2817
+ return await _context.{{name}}s
2818
+ .FirstOrDefaultAsync(e => e.Id == id, ct);
2819
+ }
2820
+
2821
+ /// <inheritdoc />
2822
+ public async Task<{{name}}?> GetByCodeAsync({{#unless isSystemEntity}}Guid tenantId, {{/unless}}string code, CancellationToken ct = default)
2823
+ {
2824
+ return await _context.{{name}}s
2825
+ .FirstOrDefaultAsync(e => {{#unless isSystemEntity}}e.TenantId == tenantId && {{/unless}}e.Code == code.ToLowerInvariant(), ct);
2826
+ }
2827
+
2828
+ /// <inheritdoc />
2829
+ public async Task<IReadOnlyList<{{name}}>> GetAllAsync({{#unless isSystemEntity}}Guid tenantId, {{/unless}}CancellationToken ct = default)
2830
+ {
2831
+ return await _context.{{name}}s
2832
+ {{#unless isSystemEntity}}.Where(e => e.TenantId == tenantId){{/unless}}
2833
+ .OrderBy(e => e.Code)
2834
+ .ToListAsync(ct);
2835
+ }
2836
+
2837
+ /// <inheritdoc />
2838
+ public async Task<bool> ExistsAsync({{#unless isSystemEntity}}Guid tenantId, {{/unless}}string code, CancellationToken ct = default)
2839
+ {
2840
+ return await _context.{{name}}s
2841
+ .AnyAsync(e => {{#unless isSystemEntity}}e.TenantId == tenantId && {{/unless}}e.Code == code.ToLowerInvariant(), ct);
2842
+ }
2843
+
2844
+ /// <inheritdoc />
2845
+ public async Task<{{name}}> AddAsync({{name}} entity, CancellationToken ct = default)
2846
+ {
2847
+ await _context.{{name}}s.AddAsync(entity, ct);
2848
+ await _context.SaveChangesAsync(ct);
2849
+ return entity;
2850
+ }
2851
+
2852
+ /// <inheritdoc />
2853
+ public async Task UpdateAsync({{name}} entity, CancellationToken ct = default)
2854
+ {
2855
+ _context.{{name}}s.Update(entity);
2856
+ await _context.SaveChangesAsync(ct);
2857
+ }
2858
+
2859
+ /// <inheritdoc />
2860
+ public async Task DeleteAsync({{name}} entity, CancellationToken ct = default)
2861
+ {
2862
+ entity.SoftDelete();
2863
+ await UpdateAsync(entity, ct);
2864
+ }
2865
+ }
2866
+ `;
2867
+ const context = {
2868
+ name,
2869
+ isSystemEntity
2870
+ };
2871
+ const interfaceContent = Handlebars.compile(interfaceTemplate)(context);
2872
+ const implementationContent = Handlebars.compile(implementationTemplate)(context);
2873
+ const appPath = structure.application || config.smartstack.projectPath;
2874
+ const infraPath = structure.infrastructure || path7.join(config.smartstack.projectPath, "Infrastructure");
2875
+ const interfaceFilePath = path7.join(appPath, "Repositories", `I${name}Repository.cs`);
2876
+ const implementationFilePath = path7.join(infraPath, "Repositories", `${name}Repository.cs`);
2877
+ if (!dryRun) {
2878
+ await ensureDirectory(path7.join(appPath, "Repositories"));
2879
+ await ensureDirectory(path7.join(infraPath, "Repositories"));
2880
+ await writeText(interfaceFilePath, interfaceContent);
2881
+ await writeText(implementationFilePath, implementationContent);
2882
+ }
2883
+ result.files.push({ path: interfaceFilePath, content: interfaceContent, type: "created" });
2884
+ result.files.push({ path: implementationFilePath, content: implementationContent, type: "created" });
2885
+ result.instructions.push("Register repository in DI:");
2886
+ result.instructions.push(`services.AddScoped<I${name}Repository, ${name}Repository>();`);
2887
+ }
2888
+ function formatResult3(result, type, name, dryRun = false) {
2889
+ const lines = [];
2890
+ if (dryRun) {
2891
+ lines.push(`# \u{1F50D} DRY-RUN: Scaffold ${type}: ${name}`);
2892
+ lines.push("");
2893
+ lines.push("> **Preview mode**: No files were written. Review the generated code below.");
2894
+ lines.push("> To create files, run without `dryRun: true`");
2895
+ lines.push("");
2896
+ } else {
2897
+ lines.push(`# Scaffold ${type}: ${name}`);
2898
+ lines.push("");
2899
+ }
2900
+ if (result.success) {
2901
+ lines.push(dryRun ? "## \u{1F4C4} Files to Generate" : "## \u2705 Files Generated");
2902
+ lines.push("");
2903
+ for (const file of result.files) {
2904
+ lines.push(`### ${file.type === "created" ? "\u{1F4C4}" : "\u270F\uFE0F"} ${path7.basename(file.path)}`);
2905
+ lines.push(`**Path**: \`${file.path}\``);
2906
+ lines.push("");
2907
+ lines.push("```" + (file.path.endsWith(".cs") ? "csharp" : "typescript"));
2908
+ const contentLines = file.content.split("\n").slice(0, 50);
2909
+ lines.push(contentLines.join("\n"));
2910
+ if (file.content.split("\n").length > 50) {
2911
+ lines.push("// ... (truncated)");
2912
+ }
2913
+ lines.push("```");
2914
+ lines.push("");
2915
+ }
2916
+ if (result.instructions.length > 0) {
2917
+ lines.push("## \u{1F4CB} Next Steps");
2918
+ lines.push("");
2919
+ for (const instruction of result.instructions) {
2920
+ if (instruction.startsWith("services.") || instruction.startsWith("public DbSet")) {
2921
+ lines.push("```csharp");
2922
+ lines.push(instruction);
2923
+ lines.push("```");
2924
+ } else if (instruction.startsWith("dotnet ")) {
2925
+ lines.push("```bash");
1785
2926
  lines.push(instruction);
1786
2927
  lines.push("```");
1787
2928
  } else if (instruction.startsWith("import ")) {
@@ -1891,7 +3032,7 @@ async function fetchFromSwagger(apiUrl) {
1891
3032
  required: p.required || false,
1892
3033
  description: p.description
1893
3034
  })),
1894
- requestBody: operation.requestBody ? "See schema" : void 0,
3035
+ requestBody: operation.requestBody ? { type: "object", required: true } : void 0,
1895
3036
  responses: Object.entries(operation.responses || {}).map(([status, resp]) => ({
1896
3037
  status: parseInt(status, 10),
1897
3038
  description: resp.description
@@ -1942,9 +3083,14 @@ async function parseControllers(structure) {
1942
3083
  });
1943
3084
  }
1944
3085
  }
3086
+ let requestBody;
1945
3087
  if (afterAttribute.includes("[FromBody]")) {
1946
3088
  const bodyMatch = afterAttribute.match(/\[FromBody\]\s*(\w+)\s+(\w+)/);
1947
3089
  if (bodyMatch) {
3090
+ requestBody = {
3091
+ type: bodyMatch[1],
3092
+ required: true
3093
+ };
1948
3094
  parameters.push({
1949
3095
  name: bodyMatch[2],
1950
3096
  in: "body",
@@ -1953,6 +3099,14 @@ async function parseControllers(structure) {
1953
3099
  });
1954
3100
  }
1955
3101
  }
3102
+ let summary;
3103
+ const methodStartIndex = content.lastIndexOf("/// <summary>", match.index);
3104
+ if (methodStartIndex !== -1 && match.index - methodStartIndex < 500) {
3105
+ const summaryMatch = content.substring(methodStartIndex, match.index).match(/\/\/\/\s*<summary>\s*\n?\s*\/\/\/\s*(.+?)\s*\n?\s*\/\/\/\s*<\/summary>/s);
3106
+ if (summaryMatch) {
3107
+ summary = summaryMatch[1].replace(/\/\/\/\s*/g, "").trim();
3108
+ }
3109
+ }
1956
3110
  const queryMatches = afterAttribute.matchAll(/\[FromQuery\]\s*(\w+)\s+(\w+)/g);
1957
3111
  for (const qm of queryMatches) {
1958
3112
  parameters.push({
@@ -1962,20 +3116,88 @@ async function parseControllers(structure) {
1962
3116
  required: false
1963
3117
  });
1964
3118
  }
3119
+ const example = {};
3120
+ if (requestBody) {
3121
+ example.request = generateExampleRequest(requestBody.type);
3122
+ }
3123
+ if (method === "GET" || method === "POST") {
3124
+ example.response = generateExampleResponse(controllerName, method);
3125
+ }
1965
3126
  endpoints.push({
1966
3127
  method,
1967
3128
  path: fullPath.replace(/\/+/g, "/"),
1968
3129
  controller: controllerName,
1969
3130
  action: methodMatch ? methodMatch[2] : "Unknown",
3131
+ summary,
1970
3132
  parameters,
1971
- responses: [{ status: 200, description: "Success" }],
1972
- authorize: hasAuthorize
3133
+ requestBody,
3134
+ responses: generateResponses(method),
3135
+ authorize: hasAuthorize,
3136
+ example
1973
3137
  });
1974
3138
  }
1975
3139
  }
1976
3140
  }
1977
3141
  return endpoints;
1978
3142
  }
3143
+ function generateResponses(method) {
3144
+ switch (method) {
3145
+ case "GET":
3146
+ return [
3147
+ { status: 200, description: "Success" },
3148
+ { status: 404, description: "Not Found" }
3149
+ ];
3150
+ case "POST":
3151
+ return [
3152
+ { status: 201, description: "Created" },
3153
+ { status: 400, description: "Bad Request" },
3154
+ { status: 422, description: "Validation Error" }
3155
+ ];
3156
+ case "PUT":
3157
+ case "PATCH":
3158
+ return [
3159
+ { status: 204, description: "No Content" },
3160
+ { status: 400, description: "Bad Request" },
3161
+ { status: 404, description: "Not Found" }
3162
+ ];
3163
+ case "DELETE":
3164
+ return [
3165
+ { status: 204, description: "No Content" },
3166
+ { status: 404, description: "Not Found" }
3167
+ ];
3168
+ default:
3169
+ return [{ status: 200, description: "Success" }];
3170
+ }
3171
+ }
3172
+ function generateExampleRequest(typeName) {
3173
+ const name = typeName.replace("Request", "").replace("Dto", "").replace("Create", "").replace("Update", "");
3174
+ const example = {
3175
+ code: `${name.toLowerCase()}_001`
3176
+ };
3177
+ if (typeName.includes("Create")) {
3178
+ example.name = `New ${name}`;
3179
+ example.description = `Description for ${name}`;
3180
+ } else if (typeName.includes("Update")) {
3181
+ example.name = `Updated ${name}`;
3182
+ example.description = `Updated description for ${name}`;
3183
+ }
3184
+ return JSON.stringify(example, null, 2);
3185
+ }
3186
+ function generateExampleResponse(controllerName, method) {
3187
+ const example = {
3188
+ id: "3fa85f64-5717-4562-b3fc-2c963f66afa6",
3189
+ tenantId: "6fa85f64-5717-4562-b3fc-2c963f66afa6",
3190
+ code: `${controllerName.toLowerCase()}_001`,
3191
+ name: `Example ${controllerName}`,
3192
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
3193
+ updatedAt: null,
3194
+ createdBy: "user@example.com"
3195
+ };
3196
+ if (method === "GET") {
3197
+ return JSON.stringify(example, null, 2);
3198
+ }
3199
+ return JSON.stringify(example, null, 2);
3200
+ }
1979
3201
  function formatAsMarkdown(endpoints) {
1980
3202
  const lines = [];
1981
3203
  lines.push("# SmartStack API Documentation");
@@ -2009,6 +3231,18 @@ function formatAsMarkdown(endpoints) {
2009
3231
  }
2010
3232
  lines.push("");
2011
3233
  }
3234
+ if (endpoint.requestBody) {
3235
+ lines.push("**Request Body:**");
3236
+ lines.push("");
3237
+ lines.push(`Type: \`${endpoint.requestBody.type}\``);
3238
+ lines.push("");
3239
+ if (endpoint.example?.request) {
3240
+ lines.push("```json");
3241
+ lines.push(endpoint.example.request);
3242
+ lines.push("```");
3243
+ lines.push("");
3244
+ }
3245
+ }
2012
3246
  if (endpoint.responses.length > 0) {
2013
3247
  lines.push("**Responses:**");
2014
3248
  lines.push("");
@@ -2017,11 +3251,27 @@ function formatAsMarkdown(endpoints) {
2017
3251
  }
2018
3252
  lines.push("");
2019
3253
  }
3254
+ if (endpoint.example?.response && (endpoint.method === "GET" || endpoint.method === "POST")) {
3255
+ lines.push("**Example Response:**");
3256
+ lines.push("");
3257
+ lines.push("```json");
3258
+ lines.push(endpoint.example.response);
3259
+ lines.push("```");
3260
+ lines.push("");
3261
+ }
2020
3262
  }
2021
3263
  }
2022
3264
  if (endpoints.length === 0) {
2023
3265
  lines.push("No endpoints found matching the filter criteria.");
2024
3266
  }
3267
+ lines.push("---");
3268
+ lines.push("");
3269
+ lines.push("## Summary");
3270
+ lines.push("");
3271
+ lines.push(`- **Total Endpoints**: ${endpoints.length}`);
3272
+ lines.push(`- **Controllers**: ${new Set(endpoints.map((e) => e.controller)).size}`);
3273
+ lines.push(`- **Authenticated**: ${endpoints.filter((e) => e.authorize).length}`);
3274
+ lines.push(`- **Public**: ${endpoints.filter((e) => !e.authorize).length}`);
2025
3275
  return lines.join("\n");
2026
3276
  }
2027
3277
  function formatAsOpenApi(endpoints) {
@@ -2056,7 +3306,144 @@ function formatAsOpenApi(endpoints) {
2056
3306
  security: endpoint.authorize ? [{ bearerAuth: [] }] : void 0
2057
3307
  };
2058
3308
  }
2059
- return JSON.stringify(spec, null, 2);
3309
+ return JSON.stringify(spec, null, 2);
3310
+ }
3311
+
3312
+ // src/tools/suggest-migration.ts
3313
+ import { z as z2 } from "zod";
3314
+ import path9 from "path";
3315
+ var suggestMigrationTool = {
3316
+ name: "suggest_migration",
3317
+ description: "Suggest a migration name following SmartStack conventions ({context}_v{version}_{sequence}_{Description})",
3318
+ inputSchema: {
3319
+ type: "object",
3320
+ properties: {
3321
+ description: {
3322
+ type: "string",
3323
+ description: 'Description of what the migration does (e.g., "Add User Profiles", "Create Orders Table")'
3324
+ },
3325
+ context: {
3326
+ type: "string",
3327
+ enum: ["core", "extensions"],
3328
+ description: "DbContext name (default: core)"
3329
+ },
3330
+ version: {
3331
+ type: "string",
3332
+ description: 'Semver version (e.g., "1.0.0", "1.2.0"). If not provided, uses latest from existing migrations.'
3333
+ }
3334
+ },
3335
+ required: ["description"]
3336
+ }
3337
+ };
3338
+ var SuggestMigrationInputSchema = z2.object({
3339
+ description: z2.string().describe("Description of what the migration does"),
3340
+ context: z2.enum(["core", "extensions"]).optional().describe("DbContext name (default: core)"),
3341
+ version: z2.string().optional().describe('Semver version (e.g., "1.0.0")')
3342
+ });
3343
+ async function handleSuggestMigration(args, config) {
3344
+ const input = SuggestMigrationInputSchema.parse(args);
3345
+ const context = input.context || "core";
3346
+ logger.info("Suggesting migration name", { description: input.description, context });
3347
+ const structure = await findSmartStackStructure(config.smartstack.projectPath);
3348
+ const existingMigrations = await findExistingMigrations(structure, config, context);
3349
+ let version = input.version;
3350
+ let sequence = 1;
3351
+ if (existingMigrations.length > 0) {
3352
+ const latestMigration = existingMigrations[existingMigrations.length - 1];
3353
+ if (!version) {
3354
+ version = latestMigration.version;
3355
+ }
3356
+ const migrationsInVersion = existingMigrations.filter((m) => m.version === version);
3357
+ if (migrationsInVersion.length > 0) {
3358
+ const maxSequence = Math.max(...migrationsInVersion.map((m) => m.sequence));
3359
+ sequence = maxSequence + 1;
3360
+ }
3361
+ } else {
3362
+ version = version || "1.0.0";
3363
+ }
3364
+ const pascalDescription = toPascalCase(input.description);
3365
+ const sequenceStr = sequence.toString().padStart(3, "0");
3366
+ const migrationName = `${context}_v${version}_${sequenceStr}_${pascalDescription}`;
3367
+ const command = `dotnet ef migrations add ${migrationName} --context ApplicationDbContext`;
3368
+ const lines = [];
3369
+ lines.push("# Migration Name Suggestion");
3370
+ lines.push("");
3371
+ lines.push("## Suggested Name");
3372
+ lines.push("```");
3373
+ lines.push(migrationName);
3374
+ lines.push("```");
3375
+ lines.push("");
3376
+ lines.push("## Command");
3377
+ lines.push("```bash");
3378
+ lines.push(command);
3379
+ lines.push("```");
3380
+ lines.push("");
3381
+ lines.push("## Convention Details");
3382
+ lines.push("");
3383
+ lines.push(`| Part | Value | Description |`);
3384
+ lines.push(`|------|-------|-------------|`);
3385
+ lines.push(`| Context | \`${context}\` | DbContext name |`);
3386
+ lines.push(`| Version | \`v${version}\` | Semver version |`);
3387
+ lines.push(`| Sequence | \`${sequenceStr}\` | Order in version |`);
3388
+ lines.push(`| Description | \`${pascalDescription}\` | Migration description |`);
3389
+ lines.push("");
3390
+ if (existingMigrations.length > 0) {
3391
+ lines.push("## Existing Migrations (last 5)");
3392
+ lines.push("");
3393
+ const recent = existingMigrations.slice(-5);
3394
+ for (const m of recent) {
3395
+ lines.push(`- \`${m.name}\``);
3396
+ }
3397
+ }
3398
+ return lines.join("\n");
3399
+ }
3400
+ async function findExistingMigrations(structure, config, context) {
3401
+ const migrations = [];
3402
+ const infraPath = structure.infrastructure || path9.join(config.smartstack.projectPath, "Infrastructure");
3403
+ const migrationsPath = path9.join(infraPath, "Migrations");
3404
+ try {
3405
+ const migrationFiles = await findFiles("*.cs", { cwd: migrationsPath });
3406
+ const migrationPattern = /^(\w+)_v(\d+\.\d+\.\d+)_(\d+)_(\w+)\.cs$/;
3407
+ for (const file of migrationFiles) {
3408
+ const fileName = path9.basename(file);
3409
+ if (fileName.includes(".Designer.") || fileName.includes("ModelSnapshot")) {
3410
+ continue;
3411
+ }
3412
+ const match = fileName.match(migrationPattern);
3413
+ if (match) {
3414
+ const [, ctx, ver, seq, desc] = match;
3415
+ if (ctx === context || !context) {
3416
+ migrations.push({
3417
+ name: fileName.replace(".cs", ""),
3418
+ context: ctx,
3419
+ version: ver,
3420
+ sequence: parseInt(seq, 10),
3421
+ description: desc
3422
+ });
3423
+ }
3424
+ }
3425
+ }
3426
+ migrations.sort((a, b) => {
3427
+ const verCompare = compareVersions2(a.version, b.version);
3428
+ if (verCompare !== 0) return verCompare;
3429
+ return a.sequence - b.sequence;
3430
+ });
3431
+ } catch {
3432
+ logger.debug("No migrations folder found");
3433
+ }
3434
+ return migrations;
3435
+ }
3436
+ function toPascalCase(str) {
3437
+ return str.replace(/[^a-zA-Z0-9\s]/g, "").split(/\s+/).map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join("");
3438
+ }
3439
+ function compareVersions2(a, b) {
3440
+ const aParts = a.split(".").map(Number);
3441
+ const bParts = b.split(".").map(Number);
3442
+ for (let i = 0; i < 3; i++) {
3443
+ if (aParts[i] > bParts[i]) return 1;
3444
+ if (aParts[i] < bParts[i]) return -1;
3445
+ }
3446
+ return 0;
2060
3447
  }
2061
3448
 
2062
3449
  // src/resources/conventions.ts
@@ -2105,6 +3492,7 @@ Tables are organized by domain using prefixes:
2105
3492
  | \`ref_\` | References | ref_Companies, ref_Departments |
2106
3493
  | \`loc_\` | Localization | loc_Languages, loc_Translations |
2107
3494
  | \`lic_\` | Licensing | lic_Licenses |
3495
+ | \`tenant_\` | Multi-Tenancy | tenant_Tenants, tenant_TenantUsers, tenant_TenantUserRoles |
2108
3496
 
2109
3497
  ### Navigation Code Prefixes
2110
3498
 
@@ -2571,6 +3959,286 @@ public class MyEntityConfiguration : IEntityTypeConfiguration<MyEntity>
2571
3959
 
2572
3960
  ---
2573
3961
 
3962
+ ## 8. Multi-Tenant Architecture
3963
+
3964
+ ### Overview
3965
+
3966
+ SmartStack implements a comprehensive multi-tenant architecture where:
3967
+ - **Platform scope**: Users with roles via \`UserRole\` (no tenant) see ALL tenants
3968
+ - **Tenant scope**: Users with roles via \`TenantUserRole\` see ONLY their tenant
3969
+
3970
+ ### Tenant Model
3971
+
3972
+ \`\`\`
3973
+ License (1) \u2500\u2500\u2500\u2500\u2500\u2500\u25BA (N) Tenant
3974
+ \u2502
3975
+ \u251C\u2500\u2500\u25BA (1:1) Organisation (if B2B)
3976
+ \u2502
3977
+ \u2514\u2500\u2500\u25BA (N) TenantUser \u2500\u2500\u25BA (N) TenantUserRole
3978
+ \u2502
3979
+ \u2514\u2500\u2500\u25BA User (global)
3980
+ \`\`\`
3981
+
3982
+ ### Core Entities
3983
+
3984
+ #### Tenant
3985
+
3986
+ | Field | Type | Description |
3987
+ |-------|------|-------------|
3988
+ | \`Id\` | Guid | Primary key |
3989
+ | \`LicenseId\` | Guid (FK) | Link to License (features & limits) |
3990
+ | \`Slug\` | string? | Optional vanity URL (unique, lowercase, alphanum + hyphen) |
3991
+ | \`Name\` | string | Display name |
3992
+ | \`Type\` | enum | B2B (with Organisation) or B2C |
3993
+ | \`Status\` | enum | Active, Suspended, Deleted |
3994
+ | \`CreatedBy\` | Guid (FK) | User who created the tenant |
3995
+ | \`CreatedAt\` | DateTime | Creation timestamp |
3996
+
3997
+ \`\`\`csharp
3998
+ public class Tenant : SystemEntity
3999
+ {
4000
+ public Guid LicenseId { get; private set; }
4001
+ public License License { get; private set; }
4002
+
4003
+ public string? Slug { get; private set; } // Vanity URL (optional)
4004
+ public string Name { get; private set; }
4005
+ public TenantType Type { get; private set; }
4006
+ public TenantStatus Status { get; private set; }
4007
+
4008
+ public Guid CreatedBy { get; private set; }
4009
+ public User Creator { get; private set; }
4010
+
4011
+ // Navigation
4012
+ public ICollection<TenantUser> TenantUsers { get; private set; }
4013
+ public Organisation? Organisation { get; private set; } // If B2B
4014
+ }
4015
+
4016
+ public enum TenantType { B2B = 1, B2C = 2 }
4017
+ public enum TenantStatus { Active = 1, Suspended = 2, Deleted = 3 }
4018
+ \`\`\`
4019
+
4020
+ #### TenantUser
4021
+
4022
+ Links a User to a Tenant with membership metadata.
4023
+
4024
+ | Field | Type | Description |
4025
+ |-------|------|-------------|
4026
+ | \`Id\` | Guid | Primary key |
4027
+ | \`TenantId\` | Guid (FK) | **NOT NULL** - Tenant reference |
4028
+ | \`UserId\` | Guid (FK) | User reference |
4029
+ | \`IsDefault\` | bool | Default tenant for this user (unique per user) |
4030
+ | \`Status\` | enum | Pending, Active, Revoked |
4031
+ | \`JoinedAt\` | DateTime | When user joined tenant |
4032
+
4033
+ \`\`\`csharp
4034
+ public class TenantUser
4035
+ {
4036
+ public Guid Id { get; private set; }
4037
+ public Guid TenantId { get; private set; } // NOT NULL
4038
+ public Tenant Tenant { get; private set; }
4039
+
4040
+ public Guid UserId { get; private set; }
4041
+ public User User { get; private set; }
4042
+
4043
+ public bool IsDefault { get; private set; }
4044
+ public TenantUserStatus Status { get; private set; }
4045
+ public DateTime JoinedAt { get; private set; }
4046
+
4047
+ // Roles within this tenant
4048
+ public ICollection<TenantUserRole> Roles { get; private set; }
4049
+ }
4050
+
4051
+ public enum TenantUserStatus { Pending = 1, Active = 2, Revoked = 3 }
4052
+ \`\`\`
4053
+
4054
+ #### TenantUserRole
4055
+
4056
+ Assigns roles to a user within a specific tenant.
4057
+
4058
+ | Field | Type | Description |
4059
+ |-------|------|-------------|
4060
+ | \`TenantUserId\` | Guid (FK) | TenantUser reference |
4061
+ | \`RoleId\` | Guid (FK) | Role reference |
4062
+ | \`AssignedAt\` | DateTime | When role was assigned |
4063
+ | \`AssignedBy\` | string? | Who assigned the role |
4064
+
4065
+ \`\`\`csharp
4066
+ public class TenantUserRole
4067
+ {
4068
+ public Guid TenantUserId { get; private set; }
4069
+ public TenantUser TenantUser { get; private set; }
4070
+
4071
+ public Guid RoleId { get; private set; }
4072
+ public Role Role { get; private set; }
4073
+
4074
+ public DateTime AssignedAt { get; private set; }
4075
+ public string? AssignedBy { get; private set; }
4076
+ }
4077
+ \`\`\`
4078
+
4079
+ #### TenantInvitation
4080
+
4081
+ Manages user invitations to a tenant.
4082
+
4083
+ | Field | Type | Description |
4084
+ |-------|------|-------------|
4085
+ | \`Id\` | Guid | Primary key |
4086
+ | \`TenantId\` | Guid (FK) | Target tenant |
4087
+ | \`Email\` | string | Invited email address |
4088
+ | \`RoleId\` | Guid (FK) | Role to assign on acceptance |
4089
+ | \`Token\` | string | Unique invitation token |
4090
+ | \`ExpiresAt\` | DateTime | Token expiration |
4091
+ | \`Status\` | enum | Pending, Accepted, Expired, Revoked |
4092
+ | \`InvitedBy\` | Guid (FK) | User who sent invitation |
4093
+ | \`InvitedAt\` | DateTime | Invitation timestamp |
4094
+
4095
+ \`\`\`csharp
4096
+ public class TenantInvitation
4097
+ {
4098
+ public Guid Id { get; private set; }
4099
+ public Guid TenantId { get; private set; }
4100
+ public Tenant Tenant { get; private set; }
4101
+
4102
+ public string Email { get; private set; }
4103
+ public Guid RoleId { get; private set; }
4104
+ public Role Role { get; private set; }
4105
+
4106
+ public string Token { get; private set; }
4107
+ public DateTime ExpiresAt { get; private set; }
4108
+ public TenantInvitationStatus Status { get; private set; }
4109
+
4110
+ public Guid InvitedBy { get; private set; }
4111
+ public User Inviter { get; private set; }
4112
+ public DateTime InvitedAt { get; private set; }
4113
+ }
4114
+
4115
+ public enum TenantInvitationStatus { Pending = 1, Accepted = 2, Expired = 3, Revoked = 4 }
4116
+ \`\`\`
4117
+
4118
+ ### Access Control Matrix
4119
+
4120
+ | Role Source | Scope | Description |
4121
+ |-------------|-------|-------------|
4122
+ | \`UserRole\` (existing) | Platform | Roles WITHOUT tenant \u2192 sees ALL tenants |
4123
+ | \`TenantUserRole\` (new) | Tenant | Roles WITH tenant \u2192 sees ONLY that tenant |
4124
+
4125
+ **Examples:**
4126
+
4127
+ \`\`\`
4128
+ UserRole: UserId=1, RoleId=PlatformAdmin \u2192 User 1 sees ALL tenants
4129
+ UserRole: UserId=2, RoleId=PlatformReader \u2192 User 2 reads ALL tenants
4130
+
4131
+ TenantUserRole: TenantUserId=3, RoleId=Admin \u2192 User 3 is Admin of Tenant A only
4132
+ TenantUserRole: TenantUserId=4, RoleId=User \u2192 User 4 is User of Tenant A only
4133
+ \`\`\`
4134
+
4135
+ ### Organisation (B2B Extension)
4136
+
4137
+ Organisation is a 1:1 extension of Tenant for B2B scenarios:
4138
+
4139
+ \`\`\`csharp
4140
+ public class Organisation : BaseEntity
4141
+ {
4142
+ public Guid TenantId { get; private set; } // FK to Tenant
4143
+ public Tenant Tenant { get; private set; }
4144
+
4145
+ // Organisation-specific fields
4146
+ public string LegalName { get; private set; }
4147
+ public string? VatNumber { get; private set; }
4148
+ public string? Address { get; private set; }
4149
+ // ... other B2B fields
4150
+ }
4151
+ \`\`\`
4152
+
4153
+ ### License Integration
4154
+
4155
+ Features and limits are managed via License:
4156
+
4157
+ \`\`\`csharp
4158
+ // License.LimitsJson example
4159
+ {
4160
+ "max_users": 100,
4161
+ "max_tenants": 5,
4162
+ "max_storage_gb": 50
4163
+ }
4164
+
4165
+ // Check in service
4166
+ public async Task<bool> CanCreateTenantAsync(Guid licenseId)
4167
+ {
4168
+ var license = await _licenseService.GetAsync(licenseId);
4169
+ var limits = JsonSerializer.Deserialize<LicenseLimits>(license.LimitsJson);
4170
+ var currentTenants = await _tenantRepo.CountByLicenseAsync(licenseId);
4171
+ return currentTenants < limits.MaxTenants;
4172
+ }
4173
+ \`\`\`
4174
+
4175
+ ### URL Routing
4176
+
4177
+ Tenant identification in URLs:
4178
+
4179
+ | Pattern | Example | Use Case |
4180
+ |---------|---------|----------|
4181
+ | GUID (default) | \`/{tenant-guid}/api/...\` | Always works, secure |
4182
+ | Slug (optional) | \`/{slug}/api/...\` | Vanity URL for premium clients |
4183
+
4184
+ \`\`\`csharp
4185
+ // Middleware resolution priority
4186
+ 1. Extract from URL path (/{tenant-identifier}/...)
4187
+ 2. Check if GUID \u2192 resolve directly
4188
+ 3. Check if Slug \u2192 resolve by slug
4189
+ 4. Fallback to user's default tenant (IsDefault=true)
4190
+ \`\`\`
4191
+
4192
+ ### Audit Trail
4193
+
4194
+ Critical tables have history tracking:
4195
+
4196
+ - \`TenantUserHistory\` - tracks membership changes
4197
+ - \`TenantUserRoleHistory\` - tracks role assignment changes
4198
+
4199
+ \`\`\`csharp
4200
+ public class TenantUserHistory : IHistoryEntity<Guid>
4201
+ {
4202
+ public Guid Id { get; set; }
4203
+ public Guid SourceId { get; set; } // TenantUser.Id
4204
+ public ChangeType ChangeType { get; set; }
4205
+ public string? OldValues { get; set; } // JSON
4206
+ public string? NewValues { get; set; } // JSON
4207
+ public DateTime ChangedAt { get; set; }
4208
+ public string? ChangedBy { get; set; }
4209
+ }
4210
+ \`\`\`
4211
+
4212
+ ### Database Constraints
4213
+
4214
+ \`\`\`sql
4215
+ -- Tenant slug unique (if set)
4216
+ CREATE UNIQUE INDEX IX_Tenant_Slug ON tenant_Tenants(Slug) WHERE Slug IS NOT NULL;
4217
+
4218
+ -- Only one default tenant per user
4219
+ CREATE UNIQUE INDEX IX_TenantUser_IsDefault
4220
+ ON tenant_TenantUsers(UserId) WHERE IsDefault = 1;
4221
+
4222
+ -- Composite key for TenantUserRole
4223
+ ALTER TABLE tenant_TenantUserRoles
4224
+ ADD CONSTRAINT PK_TenantUserRole PRIMARY KEY (TenantUserId, RoleId);
4225
+ \`\`\`
4226
+
4227
+ ### Table Prefix
4228
+
4229
+ All tenant-related tables use the \`tenant_\` prefix:
4230
+
4231
+ | Table | Prefix |
4232
+ |-------|--------|
4233
+ | Tenants | \`tenant_Tenants\` |
4234
+ | TenantUsers | \`tenant_TenantUsers\` |
4235
+ | TenantUserRoles | \`tenant_TenantUserRoles\` |
4236
+ | TenantInvitations | \`tenant_TenantInvitations\` |
4237
+ | TenantUserHistory | \`tenant_TenantUserHistory\` |
4238
+ | TenantUserRoleHistory | \`tenant_TenantUserRoleHistory\` |
4239
+
4240
+ ---
4241
+
2574
4242
  ## Quick Reference
2575
4243
 
2576
4244
  | Category | Convention | Example |
@@ -2586,11 +4254,185 @@ public class MyEntityConfiguration : IEntityTypeConfiguration<MyEntity>
2586
4254
  | Domain namespace | \`${namespaces.domain}\` | - |
2587
4255
  | API namespace | \`${namespaces.api}\` | - |
2588
4256
  | Required entity fields | Id, TenantId, Code, Audit, SoftDelete, RowVersion | See Entity Conventions |
4257
+ | Tenant prefix | \`tenant_\` | \`tenant_Tenants\`, \`tenant_TenantUsers\` |
4258
+ | Platform roles | \`UserRole\` | No TenantId \u2192 sees ALL tenants |
4259
+ | Tenant roles | \`TenantUserRole\` | With TenantId \u2192 sees ONLY that tenant |
4260
+ | License:Tenant | 1:N | One License can have multiple Tenants |
4261
+ | Dry-run scaffold | \`dryRun: true\` | Preview generated code without writing |
4262
+ | Tenant-aware entity | \`ITenantEntity\` | Default for scaffold_extension |
4263
+ | System entity | \`isSystemEntity: true\` | No TenantId, platform-level |
4264
+ | Full-stack scaffold | \`type: "feature"\` | Entity + DTOs + Validators + Repository + Service + Controller + Component |
4265
+ | Client extension | \`clientExtension: true\` | Auto-sets schema=extensions + prefix=ext_ |
4266
+ | Unit tests | \`type: "test"\` | xUnit tests with Moq & FluentAssertions |
4267
+ | DTOs generation | \`withDtos: true\` | Create, Update, Response DTOs |
4268
+ | Validation | \`withValidation: true\` | FluentValidation validators |
4269
+ | Repository | \`withRepository: true\` | Interface + Implementation |
4270
+ | Migration naming | \`suggest_migration\` | {context}_v{version}_{seq}_{Desc} |
4271
+
4272
+ ---
4273
+
4274
+ ## 9. MCP Tools & Resources
4275
+
4276
+ ### Available Tools
4277
+
4278
+ | Tool | Description |
4279
+ |------|-------------|
4280
+ | \`scaffold_extension\` | Generate code: feature (full-stack), entity, service, controller, component, dto, validator, repository, or test |
4281
+ | \`validate_conventions\` | Validate SmartStack conventions compliance |
4282
+ | \`check_migrations\` | Analyze EF Core migrations for conflicts |
4283
+ | \`api_docs\` | Get API documentation from Swagger/Controllers with examples |
4284
+ | \`suggest_migration\` | Suggest migration name following conventions |
4285
+
4286
+ ### Available Resources
4287
+
4288
+ | URI | Description |
4289
+ |-----|-------------|
4290
+ | \`smartstack://conventions\` | This documentation |
4291
+ | \`smartstack://project\` | Project structure information |
4292
+ | \`smartstack://entities\` | Quick reference of all domain entities |
4293
+ | \`smartstack://entities/{filter}\` | Filtered entity list by name/prefix |
4294
+ | \`smartstack://schema\` | Database schema information |
4295
+ | \`smartstack://api\` | API endpoints documentation |
4296
+
4297
+ ### Scaffold Extension
4298
+
4299
+ Generate tenant-aware code by default:
4300
+
4301
+ **Type Options:**
4302
+
4303
+ | Type | Description | Generated Files |
4304
+ |------|-------------|-----------------|
4305
+ | \`feature\` | **Full-stack generation** | Entity + DTOs + Validators + Repository + Service + Controller + Component |
4306
+ | \`entity\` | Domain entity only | Entity class + EF Configuration |
4307
+ | \`dto\` | DTOs only | Create, Update, Response DTOs |
4308
+ | \`validator\` | Validators only | FluentValidation validators |
4309
+ | \`repository\` | Repository only | Interface + Implementation |
4310
+ | \`service\` | Service layer only | Interface + Implementation |
4311
+ | \`controller\` | API controller only | REST Controller |
4312
+ | \`component\` | React component only | Component + Hook + Types |
4313
+ | \`test\` | Unit tests only | xUnit tests with Moq |
4314
+
4315
+ #### Full-Stack Feature (Recommended)
4316
+
4317
+ Generate all layers in one command:
4318
+
4319
+ \`\`\`json
4320
+ // Complete feature: Entity \u2192 Service \u2192 Controller \u2192 Component
4321
+ {
4322
+ "type": "feature",
4323
+ "name": "Product",
4324
+ "options": {
4325
+ "tablePrefix": "ref_",
4326
+ "dryRun": true, // Preview first
4327
+ "withTests": true // Include unit tests
4328
+ }
4329
+ }
4330
+
4331
+ // Client extension (uses 'extensions' schema + 'ext_' prefix)
4332
+ {
4333
+ "type": "feature",
4334
+ "name": "CustomReport",
4335
+ "options": {
4336
+ "clientExtension": true, // Auto-sets schema + prefix
4337
+ "skipController": true // Skip API, React-only
4338
+ }
4339
+ }
4340
+ \`\`\`
4341
+
4342
+ #### Individual Types
4343
+
4344
+ \`\`\`json
4345
+ // Entity (default: tenant-aware with ITenantEntity)
4346
+ {
4347
+ "type": "entity",
4348
+ "name": "Product",
4349
+ "options": {
4350
+ "tablePrefix": "ref_",
4351
+ "dryRun": true // Preview without writing files
4352
+ }
4353
+ }
4354
+
4355
+ // System entity (no TenantId)
4356
+ {
4357
+ "type": "entity",
4358
+ "name": "GlobalConfig",
4359
+ "options": {
4360
+ "isSystemEntity": true,
4361
+ "tablePrefix": "cfg_"
4362
+ }
4363
+ }
4364
+
4365
+ // Unit tests for a service
4366
+ {
4367
+ "type": "test",
4368
+ "name": "Product"
4369
+ }
4370
+ \`\`\`
4371
+
4372
+ **Common Options:**
4373
+
4374
+ | Option | Type | Default | Description |
4375
+ |--------|------|---------|-------------|
4376
+ | \`dryRun\` | boolean | false | Preview generated code without writing files |
4377
+ | \`isSystemEntity\` | boolean | false | Create system entity (no TenantId) |
4378
+ | \`tablePrefix\` | string | "ref_" | Domain prefix for table name |
4379
+ | \`schema\` | string | "core" | Database schema ("core" or "extensions") |
4380
+ | \`namespace\` | string | auto | Custom namespace |
4381
+ | \`baseEntity\` | string | - | Base entity to extend |
4382
+
4383
+ **Feature-Specific Options:**
4384
+
4385
+ | Option | Type | Default | Description |
4386
+ |--------|------|---------|-------------|
4387
+ | \`clientExtension\` | boolean | false | Use 'extensions' schema + 'ext_' prefix |
4388
+ | \`skipService\` | boolean | false | Skip service layer generation |
4389
+ | \`skipController\` | boolean | false | Skip API controller generation |
4390
+ | \`skipComponent\` | boolean | false | Skip React component generation |
4391
+ | \`withTests\` | boolean | false | Generate unit tests (xUnit + Moq) |
4392
+ | \`withDtos\` | boolean | false | Generate DTOs (Create, Update, Response) |
4393
+ | \`withValidation\` | boolean | false | Generate FluentValidation validators |
4394
+ | \`withRepository\` | boolean | false | Generate repository pattern |
4395
+ | \`entityProperties\` | array | - | Define entity properties for DTOs |
4396
+
4397
+ ### Suggest Migration
4398
+
4399
+ Generate migration names following conventions:
4400
+
4401
+ \`\`\`json
4402
+ {
4403
+ "description": "Add User Profiles",
4404
+ "context": "core", // optional: core or extensions
4405
+ "version": "1.2.0" // optional: auto-detected from existing
4406
+ }
4407
+ \`\`\`
4408
+
4409
+ Output: \`core_v1.2.0_001_AddUserProfiles\`
4410
+
4411
+ ### Validate Conventions
4412
+
4413
+ Run specific or all checks:
4414
+
4415
+ \`\`\`json
4416
+ {
4417
+ "checks": ["tables", "migrations", "services", "namespaces", "entities", "tenants"]
4418
+ }
4419
+ \`\`\`
4420
+
4421
+ **Check Types:**
4422
+
4423
+ | Check | Validates |
4424
+ |-------|-----------|
4425
+ | \`tables\` | Schema specification, domain prefixes |
4426
+ | \`migrations\` | Naming convention, version ordering |
4427
+ | \`services\` | Interface implementation pattern |
4428
+ | \`namespaces\` | Layer namespace structure |
4429
+ | \`entities\` | BaseEntity/SystemEntity inheritance, factory methods |
4430
+ | \`tenants\` | ITenantEntity implementation, TenantId consistency |
2589
4431
  `;
2590
4432
  }
2591
4433
 
2592
4434
  // src/resources/project-info.ts
2593
- import path9 from "path";
4435
+ import path10 from "path";
2594
4436
  var projectInfoResourceTemplate = {
2595
4437
  uri: "smartstack://project",
2596
4438
  name: "SmartStack Project Info",
@@ -2627,16 +4469,16 @@ async function getProjectInfoResource(config) {
2627
4469
  lines.push("```");
2628
4470
  lines.push(`${projectInfo.name}/`);
2629
4471
  if (structure.domain) {
2630
- lines.push(`\u251C\u2500\u2500 ${path9.basename(structure.domain)}/ # Domain layer (entities)`);
4472
+ lines.push(`\u251C\u2500\u2500 ${path10.basename(structure.domain)}/ # Domain layer (entities)`);
2631
4473
  }
2632
4474
  if (structure.application) {
2633
- lines.push(`\u251C\u2500\u2500 ${path9.basename(structure.application)}/ # Application layer (services)`);
4475
+ lines.push(`\u251C\u2500\u2500 ${path10.basename(structure.application)}/ # Application layer (services)`);
2634
4476
  }
2635
4477
  if (structure.infrastructure) {
2636
- lines.push(`\u251C\u2500\u2500 ${path9.basename(structure.infrastructure)}/ # Infrastructure (EF Core)`);
4478
+ lines.push(`\u251C\u2500\u2500 ${path10.basename(structure.infrastructure)}/ # Infrastructure (EF Core)`);
2637
4479
  }
2638
4480
  if (structure.api) {
2639
- lines.push(`\u251C\u2500\u2500 ${path9.basename(structure.api)}/ # API layer (controllers)`);
4481
+ lines.push(`\u251C\u2500\u2500 ${path10.basename(structure.api)}/ # API layer (controllers)`);
2640
4482
  }
2641
4483
  if (structure.web) {
2642
4484
  lines.push(`\u2514\u2500\u2500 web/smartstack-web/ # React frontend`);
@@ -2649,8 +4491,8 @@ async function getProjectInfoResource(config) {
2649
4491
  lines.push("| Project | Path |");
2650
4492
  lines.push("|---------|------|");
2651
4493
  for (const csproj of projectInfo.csprojFiles) {
2652
- const name = path9.basename(csproj, ".csproj");
2653
- const relativePath = path9.relative(projectPath, csproj);
4494
+ const name = path10.basename(csproj, ".csproj");
4495
+ const relativePath = path10.relative(projectPath, csproj);
2654
4496
  lines.push(`| ${name} | \`${relativePath}\` |`);
2655
4497
  }
2656
4498
  lines.push("");
@@ -2660,10 +4502,10 @@ async function getProjectInfoResource(config) {
2660
4502
  cwd: structure.migrations,
2661
4503
  ignore: ["*.Designer.cs"]
2662
4504
  });
2663
- const migrations = migrationFiles.map((f) => path9.basename(f)).filter((f) => !f.includes("ModelSnapshot") && !f.includes(".Designer.")).sort();
4505
+ const migrations = migrationFiles.map((f) => path10.basename(f)).filter((f) => !f.includes("ModelSnapshot") && !f.includes(".Designer.")).sort();
2664
4506
  lines.push("## EF Core Migrations");
2665
4507
  lines.push("");
2666
- lines.push(`**Location**: \`${path9.relative(projectPath, structure.migrations)}\``);
4508
+ lines.push(`**Location**: \`${path10.relative(projectPath, structure.migrations)}\``);
2667
4509
  lines.push(`**Total Migrations**: ${migrations.length}`);
2668
4510
  lines.push("");
2669
4511
  if (migrations.length > 0) {
@@ -2698,11 +4540,11 @@ async function getProjectInfoResource(config) {
2698
4540
  lines.push("dotnet build");
2699
4541
  lines.push("");
2700
4542
  lines.push("# Run API");
2701
- lines.push(`cd ${structure.api ? path9.relative(projectPath, structure.api) : "SmartStack.Api"}`);
4543
+ lines.push(`cd ${structure.api ? path10.relative(projectPath, structure.api) : "SmartStack.Api"}`);
2702
4544
  lines.push("dotnet run");
2703
4545
  lines.push("");
2704
4546
  lines.push("# Run frontend");
2705
- lines.push(`cd ${structure.web ? path9.relative(projectPath, structure.web) : "web/smartstack-web"}`);
4547
+ lines.push(`cd ${structure.web ? path10.relative(projectPath, structure.web) : "web/smartstack-web"}`);
2706
4548
  lines.push("npm run dev");
2707
4549
  lines.push("");
2708
4550
  lines.push("# Create migration");
@@ -2725,7 +4567,7 @@ async function getProjectInfoResource(config) {
2725
4567
  }
2726
4568
 
2727
4569
  // src/resources/api-endpoints.ts
2728
- import path10 from "path";
4570
+ import path11 from "path";
2729
4571
  var apiEndpointsResourceTemplate = {
2730
4572
  uri: "smartstack://api/",
2731
4573
  name: "SmartStack API Endpoints",
@@ -2750,7 +4592,7 @@ async function getApiEndpointsResource(config, endpointFilter) {
2750
4592
  }
2751
4593
  async function parseController(filePath, _rootPath) {
2752
4594
  const content = await readText(filePath);
2753
- const fileName = path10.basename(filePath, ".cs");
4595
+ const fileName = path11.basename(filePath, ".cs");
2754
4596
  const controllerName = fileName.replace("Controller", "");
2755
4597
  const endpoints = [];
2756
4598
  const routeMatch = content.match(/\[Route\s*\(\s*"([^"]+)"\s*\)\]/);
@@ -2897,7 +4739,7 @@ function getMethodEmoji(method) {
2897
4739
  }
2898
4740
 
2899
4741
  // src/resources/db-schema.ts
2900
- import path11 from "path";
4742
+ import path12 from "path";
2901
4743
  var dbSchemaResourceTemplate = {
2902
4744
  uri: "smartstack://schema/",
2903
4745
  name: "SmartStack Database Schema",
@@ -2987,7 +4829,7 @@ async function parseEntity(filePath, rootPath, _config) {
2987
4829
  tableName,
2988
4830
  properties,
2989
4831
  relationships,
2990
- file: path11.relative(rootPath, filePath)
4832
+ file: path12.relative(rootPath, filePath)
2991
4833
  };
2992
4834
  }
2993
4835
  async function enrichFromConfigurations(entities, infrastructurePath, _config) {
@@ -3132,6 +4974,231 @@ function formatSchema(entities, filter, _config) {
3132
4974
  return lines.join("\n");
3133
4975
  }
3134
4976
 
4977
+ // src/resources/entities.ts
4978
+ import path13 from "path";
4979
+ var entitiesResourceTemplate = {
4980
+ uri: "smartstack://entities/",
4981
+ name: "SmartStack Entities",
4982
+ description: "Quick reference list of all domain entities with tenant-awareness and table prefixes. Use smartstack://entities/{name} for details.",
4983
+ mimeType: "text/markdown"
4984
+ };
4985
+ async function getEntitiesResource(config, entityFilter) {
4986
+ const structure = await findSmartStackStructure(config.smartstack.projectPath);
4987
+ if (!structure.domain) {
4988
+ return "# SmartStack Entities\n\nNo domain project found.";
4989
+ }
4990
+ const entities = [];
4991
+ const entityFiles = await findEntityFiles(structure.domain);
4992
+ for (const file of entityFiles) {
4993
+ const entity = await parseEntitySummary(file, structure.root, config);
4994
+ if (entity) {
4995
+ entities.push(entity);
4996
+ }
4997
+ }
4998
+ entities.sort((a, b) => {
4999
+ if (a.tablePrefix !== b.tablePrefix) {
5000
+ return a.tablePrefix.localeCompare(b.tablePrefix);
5001
+ }
5002
+ return a.name.localeCompare(b.name);
5003
+ });
5004
+ const filteredEntities = entityFilter ? entities.filter(
5005
+ (e) => e.name.toLowerCase().includes(entityFilter.toLowerCase()) || e.tableName.toLowerCase().includes(entityFilter.toLowerCase()) || e.tablePrefix.toLowerCase().includes(entityFilter.toLowerCase())
5006
+ ) : entities;
5007
+ return formatEntities(filteredEntities, entityFilter, config);
5008
+ }
5009
+ async function parseEntitySummary(filePath, rootPath, config) {
5010
+ const content = await readText(filePath);
5011
+ const classMatch = content.match(/public\s+(?:class|record)\s+(\w+)(?:\s*:\s*([^{]+))?/);
5012
+ if (!classMatch) return null;
5013
+ const entityName = classMatch[1];
5014
+ const inheritance = classMatch[2]?.trim() || "";
5015
+ if (entityName.endsWith("Dto") || entityName.endsWith("Command") || entityName.endsWith("Query") || entityName.endsWith("Handler") || entityName.endsWith("Configuration") || entityName.endsWith("Service") || entityName.endsWith("Validator") || entityName.endsWith("Exception")) {
5016
+ return null;
5017
+ }
5018
+ const baseClassMatch = inheritance.match(/^(\w+)/);
5019
+ const baseClass = baseClassMatch ? baseClassMatch[1] : "Unknown";
5020
+ const isSystemEntity = baseClass === "SystemEntity" || inheritance.includes("ISystemEntity") || content.includes("// System entity") || !content.includes("TenantId");
5021
+ const isTenantAware = content.includes("TenantId") && (content.includes("public Guid TenantId") || content.includes("public required Guid TenantId"));
5022
+ const hasCode = content.includes("public string Code") || content.includes("public required string Code") || content.includes("public string? Code");
5023
+ const hasSoftDelete = content.includes("IsDeleted") || inheritance.includes("ISoftDeletable") || baseClass === "BaseEntity";
5024
+ const hasRowVersion = content.includes("RowVersion") || inheritance.includes("IHasRowVersion") || baseClass === "BaseEntity";
5025
+ const { tableName, tablePrefix, schema } = inferTableInfo(entityName, config);
5026
+ return {
5027
+ name: entityName,
5028
+ tableName,
5029
+ tablePrefix,
5030
+ schema,
5031
+ isTenantAware,
5032
+ isSystemEntity,
5033
+ baseClass,
5034
+ hasCode,
5035
+ hasSoftDelete,
5036
+ hasRowVersion,
5037
+ file: filePath,
5038
+ relativePath: path13.relative(rootPath, filePath)
5039
+ };
5040
+ }
5041
+ function inferTableInfo(entityName, config) {
5042
+ const prefixMapping = {
5043
+ // Authentication & Authorization
5044
+ "User": "auth_",
5045
+ "Role": "auth_",
5046
+ "Permission": "auth_",
5047
+ "UserRole": "auth_",
5048
+ "RolePermission": "auth_",
5049
+ "RefreshToken": "auth_",
5050
+ // Navigation
5051
+ "NavigationItem": "nav_",
5052
+ "NavigationMenu": "nav_",
5053
+ "Page": "nav_",
5054
+ // AI
5055
+ "Prompt": "ai_",
5056
+ "Completion": "ai_",
5057
+ "Model": "ai_",
5058
+ "Conversation": "ai_",
5059
+ // Configuration
5060
+ "Setting": "cfg_",
5061
+ "Configuration": "cfg_",
5062
+ // Workflow
5063
+ "Workflow": "wkf_",
5064
+ "WorkflowStep": "wkf_",
5065
+ "WorkflowInstance": "wkf_",
5066
+ // Support
5067
+ "Ticket": "support_",
5068
+ "TicketComment": "support_",
5069
+ // Entra (Azure AD)
5070
+ "EntraUser": "entra_",
5071
+ "EntraGroup": "entra_",
5072
+ // Licensing
5073
+ "License": "lic_",
5074
+ "Subscription": "lic_",
5075
+ // Tenant
5076
+ "Tenant": "tenant_",
5077
+ "TenantUser": "tenant_",
5078
+ "TenantUserRole": "tenant_",
5079
+ "TenantInvitation": "tenant_",
5080
+ "Organisation": "tenant_",
5081
+ // Localization
5082
+ "Translation": "loc_",
5083
+ "Language": "loc_"
5084
+ };
5085
+ let tablePrefix = "ref_";
5086
+ for (const [pattern, prefix] of Object.entries(prefixMapping)) {
5087
+ if (entityName === pattern || entityName.startsWith(pattern)) {
5088
+ tablePrefix = prefix;
5089
+ break;
5090
+ }
5091
+ }
5092
+ const validPrefixes = config.conventions.tablePrefixes;
5093
+ if (!validPrefixes.includes(tablePrefix)) {
5094
+ tablePrefix = "ref_";
5095
+ }
5096
+ const tableName = `${tablePrefix}${pluralize(entityName)}`;
5097
+ const schema = config.conventions.schemas.platform;
5098
+ return { tableName, tablePrefix, schema };
5099
+ }
5100
+ function pluralize(name) {
5101
+ if (name.endsWith("y") && !/[aeiou]y$/i.test(name)) {
5102
+ return name.slice(0, -1) + "ies";
5103
+ }
5104
+ if (name.endsWith("s") || name.endsWith("x") || name.endsWith("ch") || name.endsWith("sh")) {
5105
+ return name + "es";
5106
+ }
5107
+ return name + "s";
5108
+ }
5109
+ function formatEntities(entities, filter, config) {
5110
+ const lines = [];
5111
+ lines.push("# SmartStack Entities");
5112
+ lines.push("");
5113
+ if (filter) {
5114
+ lines.push(`> Filtered by: \`${filter}\``);
5115
+ lines.push("");
5116
+ }
5117
+ if (entities.length === 0) {
5118
+ lines.push("No entities found matching the criteria.");
5119
+ return lines.join("\n");
5120
+ }
5121
+ const tenantAwareCount = entities.filter((e) => e.isTenantAware).length;
5122
+ const systemCount = entities.filter((e) => e.isSystemEntity).length;
5123
+ const prefixes = [...new Set(entities.map((e) => e.tablePrefix))];
5124
+ lines.push("## Summary");
5125
+ lines.push("");
5126
+ lines.push(`- **Total Entities**: ${entities.length}`);
5127
+ lines.push(`- **Tenant-Aware**: ${tenantAwareCount}`);
5128
+ lines.push(`- **System Entities**: ${systemCount}`);
5129
+ lines.push(`- **Table Prefixes Used**: ${prefixes.join(", ")}`);
5130
+ lines.push("");
5131
+ lines.push("## Quick Reference");
5132
+ lines.push("");
5133
+ lines.push("| Entity | Table | Tenant | Code | SoftDel | Base |");
5134
+ lines.push("|--------|-------|--------|------|---------|------|");
5135
+ for (const entity of entities) {
5136
+ const tenantIcon = entity.isTenantAware ? "\u2705" : entity.isSystemEntity ? "\u{1F512}" : "\u274C";
5137
+ const codeIcon = entity.hasCode ? "\u2705" : "\u274C";
5138
+ const softDelIcon = entity.hasSoftDelete ? "\u2705" : "\u274C";
5139
+ lines.push(
5140
+ `| ${entity.name} | \`${entity.tableName}\` | ${tenantIcon} | ${codeIcon} | ${softDelIcon} | ${entity.baseClass} |`
5141
+ );
5142
+ }
5143
+ lines.push("");
5144
+ lines.push("## By Domain");
5145
+ lines.push("");
5146
+ const byPrefix = /* @__PURE__ */ new Map();
5147
+ for (const entity of entities) {
5148
+ const list = byPrefix.get(entity.tablePrefix) || [];
5149
+ list.push(entity);
5150
+ byPrefix.set(entity.tablePrefix, list);
5151
+ }
5152
+ const prefixNames = {
5153
+ "auth_": "Authentication & Authorization",
5154
+ "nav_": "Navigation",
5155
+ "ai_": "AI & Prompts",
5156
+ "cfg_": "Configuration",
5157
+ "wkf_": "Workflows",
5158
+ "support_": "Support",
5159
+ "entra_": "Microsoft Entra",
5160
+ "lic_": "Licensing",
5161
+ "tenant_": "Multi-Tenant",
5162
+ "loc_": "Localization",
5163
+ "ref_": "Reference Data",
5164
+ "usr_": "User Data"
5165
+ };
5166
+ for (const [prefix, prefixEntities] of byPrefix) {
5167
+ const domainName = prefixNames[prefix] || prefix;
5168
+ lines.push(`### ${domainName} (\`${prefix}\`)`);
5169
+ lines.push("");
5170
+ for (const entity of prefixEntities) {
5171
+ const badges = [];
5172
+ if (entity.isTenantAware) badges.push("tenant-aware");
5173
+ if (entity.isSystemEntity) badges.push("system");
5174
+ if (entity.hasCode) badges.push("has-code");
5175
+ lines.push(`- **${entity.name}** \u2192 \`${entity.tableName}\``);
5176
+ if (badges.length > 0) {
5177
+ lines.push(` - ${badges.map((b) => `\`${b}\``).join(" ")}`);
5178
+ }
5179
+ lines.push(` - File: \`${entity.relativePath}\``);
5180
+ }
5181
+ lines.push("");
5182
+ }
5183
+ lines.push("## Conventions");
5184
+ lines.push("");
5185
+ lines.push("| Property | Convention |");
5186
+ lines.push("|----------|------------|");
5187
+ lines.push(`| Schema | \`${config.conventions.schemas.platform}\` |`);
5188
+ lines.push(`| Valid Prefixes | ${config.conventions.tablePrefixes.map((p) => `\`${p}\``).join(", ")} |`);
5189
+ lines.push("| Tenant-aware | Must have `TenantId` (GUID, NOT NULL) |");
5190
+ lines.push("| System entity | No `TenantId`, used for platform-level data |");
5191
+ lines.push("| Code field | Lowercase, unique per tenant (or globally for system) |");
5192
+ lines.push("");
5193
+ lines.push("## Legend");
5194
+ lines.push("");
5195
+ lines.push("- \u2705 = Feature present");
5196
+ lines.push("- \u274C = Feature absent");
5197
+ lines.push("- \u{1F512} = System entity (no tenant scope)");
5198
+ lines.push("");
5199
+ return lines.join("\n");
5200
+ }
5201
+
3135
5202
  // src/server.ts
3136
5203
  async function createServer() {
3137
5204
  const config = await getConfig();
@@ -3154,7 +5221,8 @@ async function createServer() {
3154
5221
  validateConventionsTool,
3155
5222
  checkMigrationsTool,
3156
5223
  scaffoldExtensionTool,
3157
- apiDocsTool
5224
+ apiDocsTool,
5225
+ suggestMigrationTool
3158
5226
  ]
3159
5227
  };
3160
5228
  });
@@ -3177,6 +5245,9 @@ async function createServer() {
3177
5245
  case "api_docs":
3178
5246
  result = await handleApiDocs(args, config);
3179
5247
  break;
5248
+ case "suggest_migration":
5249
+ result = await handleSuggestMigration(args, config);
5250
+ break;
3180
5251
  default:
3181
5252
  throw new Error(`Unknown tool: ${name}`);
3182
5253
  }
@@ -3210,7 +5281,8 @@ async function createServer() {
3210
5281
  conventionsResourceTemplate,
3211
5282
  projectInfoResourceTemplate,
3212
5283
  apiEndpointsResourceTemplate,
3213
- dbSchemaResourceTemplate
5284
+ dbSchemaResourceTemplate,
5285
+ entitiesResourceTemplate
3214
5286
  ]
3215
5287
  };
3216
5288
  });
@@ -3230,6 +5302,11 @@ async function createServer() {
3230
5302
  } else if (uri.startsWith("smartstack://schema/")) {
3231
5303
  const table = uri.replace("smartstack://schema/", "");
3232
5304
  content = await getDbSchemaResource(config, table);
5305
+ } else if (uri.startsWith("smartstack://entities/")) {
5306
+ const entityFilter = uri.replace("smartstack://entities/", "");
5307
+ content = await getEntitiesResource(config, entityFilter || void 0);
5308
+ } else if (uri === "smartstack://entities") {
5309
+ content = await getEntitiesResource(config);
3233
5310
  } else {
3234
5311
  throw new Error(`Unknown resource: ${uri}`);
3235
5312
  }