@atlashub/smartstack-cli 3.39.0 → 3.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.documentation/apex.html +644 -644
- package/.documentation/css/styles.css +2320 -2320
- package/.documentation/init.html +1377 -1377
- package/.documentation/js/app.js +780 -780
- package/.documentation/prd-json-v2.0.0.md +396 -396
- package/.documentation/testing-ba-e2e.md +462 -462
- package/config/default-config.json +95 -95
- package/config/mcp-defaults.json +62 -62
- package/config/settings.json +53 -53
- package/config/settings.local.example.json +16 -16
- package/dist/index.js +6 -3
- package/dist/index.js.map +1 -1
- package/dist/mcp-entry.mjs +6 -4
- package/dist/mcp-entry.mjs.map +1 -1
- package/package.json +115 -115
- package/scripts/extract-api-endpoints.ts +325 -325
- package/scripts/extract-business-rules.ts +440 -440
- package/scripts/generate-doc-with-mock-ui.ts +804 -804
- package/scripts/health-check.sh +168 -168
- package/scripts/postinstall.js +18 -18
- package/templates/agents/action.md +37 -37
- package/templates/agents/ba-reader.md +378 -378
- package/templates/agents/ba-writer.md +861 -861
- package/templates/agents/code-reviewer.md +163 -163
- package/templates/agents/db-reader.md +149 -149
- package/templates/agents/docs-context-reader.md +143 -143
- package/templates/agents/docs-sync-checker.md +122 -122
- package/templates/agents/efcore/conflicts.md +95 -84
- package/templates/agents/efcore/db-deploy.md +85 -74
- package/templates/agents/efcore/db-reset.md +96 -85
- package/templates/agents/efcore/db-seed.md +72 -61
- package/templates/agents/efcore/db-status.md +97 -86
- package/templates/agents/efcore/migration.md +197 -186
- package/templates/agents/efcore/rebase-snapshot.md +119 -108
- package/templates/agents/efcore/scan.md +103 -92
- package/templates/agents/efcore/squash.md +172 -161
- package/templates/agents/explore-codebase.md +66 -66
- package/templates/agents/explore-docs.md +98 -98
- package/templates/agents/fix-grammar.md +50 -50
- package/templates/agents/gitflow/abort.md +45 -45
- package/templates/agents/gitflow/cleanup.md +96 -96
- package/templates/agents/gitflow/commit.md +236 -236
- package/templates/agents/gitflow/exec.md +48 -48
- package/templates/agents/gitflow/finish.md +146 -146
- package/templates/agents/gitflow/init-clone.md +199 -199
- package/templates/agents/gitflow/init-detect.md +137 -137
- package/templates/agents/gitflow/init-validate.md +225 -225
- package/templates/agents/gitflow/init.md +340 -340
- package/templates/agents/gitflow/merge.md +145 -145
- package/templates/agents/gitflow/plan.md +42 -42
- package/templates/agents/gitflow/pr.md +191 -191
- package/templates/agents/gitflow/review.md +49 -49
- package/templates/agents/gitflow/start.md +147 -147
- package/templates/agents/gitflow/status.md +95 -95
- package/templates/agents/mcp-healthcheck.md +163 -163
- package/templates/agents/snipper.md +37 -37
- package/templates/agents/websearch.md +46 -46
- package/templates/hooks/appsettings-guard.sh +76 -76
- package/templates/hooks/docs-drift-check.md +96 -96
- package/templates/hooks/ef-migration-check.md +139 -139
- package/templates/hooks/hooks.json +58 -58
- package/templates/hooks/mcp-check.md +64 -64
- package/templates/hooks/ralph-mcp-logger.sh +46 -46
- package/templates/hooks/ralph-session-end.sh +69 -69
- package/templates/hooks/stop-hook.sh +177 -177
- package/templates/hooks/wsl-dotnet-cleanup.sh +24 -24
- package/templates/mcp-scaffolding/component.tsx.hbs +318 -318
- package/templates/mcp-scaffolding/controller.cs.hbs +192 -192
- package/templates/mcp-scaffolding/entity-extension.cs.hbs +239 -239
- package/templates/mcp-scaffolding/frontend/api-client.ts.hbs +116 -116
- package/templates/mcp-scaffolding/frontend/nav-routes.ts.hbs +133 -133
- package/templates/mcp-scaffolding/frontend/routes.tsx.hbs +126 -126
- package/templates/mcp-scaffolding/migrations/seed-roles.cs.hbs +261 -261
- package/templates/mcp-scaffolding/service-extension.cs.hbs +53 -53
- package/templates/mcp-scaffolding/tests/controller.test.cs.hbs +436 -436
- package/templates/mcp-scaffolding/tests/entity.test.cs.hbs +239 -239
- package/templates/mcp-scaffolding/tests/repository.test.cs.hbs +441 -441
- package/templates/mcp-scaffolding/tests/security.test.cs.hbs +442 -442
- package/templates/mcp-scaffolding/tests/service.test.cs.hbs +402 -402
- package/templates/mcp-scaffolding/tests/validator.test.cs.hbs +428 -428
- package/templates/project/DependencyInjection.Application.cs.template +25 -25
- package/templates/project/DependencyInjection.Infrastructure.cs.template +61 -61
- package/templates/project/DesignTimeExtensionsDbContextFactory.cs.template +70 -70
- package/templates/project/ExampleEntity.cs.template +116 -116
- package/templates/project/ExampleEntityConfiguration.cs.template +64 -64
- package/templates/project/ExampleService.cs.template +146 -146
- package/templates/project/ExtensionsDbContext.cs.template +41 -41
- package/templates/project/IExtensionsDbContext.cs.template +22 -22
- package/templates/project/Program.cs.template +47 -47
- package/templates/project/README.md +79 -79
- package/templates/project/api.ts.template +12 -12
- package/templates/project/appsettings.json.template +170 -170
- package/templates/project/claude-settings.json.template +5 -5
- package/templates/project/test-frontend/msw/handlers.ts +58 -58
- package/templates/project/test-frontend/msw/server.ts +25 -25
- package/templates/project/test-frontend/setup.ts +16 -16
- package/templates/project/test-frontend/test-utils.tsx +59 -59
- package/templates/project/test-frontend/vitest.config.ts +31 -31
- package/templates/ralph/README.md +93 -93
- package/templates/ralph/ralph.config.yaml +113 -113
- package/templates/scripts/setup-ralph-loop.sh +173 -173
- package/templates/skills/_resources/config-safety.md +61 -61
- package/templates/skills/_resources/context-digest-template.md +53 -53
- package/templates/skills/_resources/doc-context-cache.md +60 -60
- package/templates/skills/_resources/docs-manifest-schema.md +155 -155
- package/templates/skills/_resources/formatting-guide.md +124 -124
- package/templates/skills/_resources/mcp-validate-documentation-spec.md +181 -181
- package/templates/skills/_shared.md +228 -228
- package/templates/skills/admin/SKILL.md +48 -48
- package/templates/skills/ai-prompt/SKILL.md +107 -107
- package/templates/skills/ai-prompt/steps/step-00-init.md +47 -47
- package/templates/skills/ai-prompt/steps/step-01-implementation.md +122 -122
- package/templates/skills/apex/SKILL.md +168 -168
- package/templates/skills/apex/_shared.md +141 -141
- package/templates/skills/apex/references/agent-teams-protocol.md +164 -164
- package/templates/skills/apex/references/analysis-methods.md +141 -141
- package/templates/skills/apex/references/challenge-questions.md +145 -145
- package/templates/skills/apex/references/code-generation.md +412 -412
- package/templates/skills/apex/references/core-seed-data.md +1437 -1437
- package/templates/skills/apex/references/error-classification.md +144 -144
- package/templates/skills/apex/references/examine-build-validation.md +82 -82
- package/templates/skills/apex/references/execution-frontend-gates.md +177 -177
- package/templates/skills/apex/references/execution-frontend-patterns.md +105 -105
- package/templates/skills/apex/references/execution-layer1-rules.md +96 -96
- package/templates/skills/apex/references/initialization-challenge-flow.md +110 -110
- package/templates/skills/apex/references/planning-layer-mapping.md +151 -151
- package/templates/skills/apex/references/post-checks.md +1584 -1584
- package/templates/skills/apex/references/smartstack-api.md +1053 -1053
- package/templates/skills/apex/references/smartstack-frontend.md +1571 -1571
- package/templates/skills/apex/references/smartstack-layers.md +402 -402
- package/templates/skills/apex/steps/step-00-init.md +307 -307
- package/templates/skills/apex/steps/step-01-analyze.md +165 -165
- package/templates/skills/apex/steps/step-02-plan.md +144 -144
- package/templates/skills/apex/steps/step-03-execute.md +328 -328
- package/templates/skills/apex/steps/step-04-examine.md +263 -263
- package/templates/skills/apex/steps/step-05-deep-review.md +129 -129
- package/templates/skills/apex/steps/step-06-resolve.md +101 -101
- package/templates/skills/apex/steps/step-07-tests.md +238 -238
- package/templates/skills/apex/steps/step-08-run-tests.md +125 -125
- package/templates/skills/application/SKILL.md +4 -4
- package/templates/skills/application/references/application-roles-template.md +227 -227
- package/templates/skills/application/references/backend-controller-hierarchy.md +58 -58
- package/templates/skills/application/references/backend-entity-seeding.md +72 -72
- package/templates/skills/application/references/backend-seeding-and-dto-output.md +83 -83
- package/templates/skills/application/references/backend-table-prefix-mapping.md +79 -79
- package/templates/skills/application/references/backend-verification.md +88 -88
- package/templates/skills/application/references/frontend-i18n-and-output.md +67 -67
- package/templates/skills/application/references/frontend-route-naming.md +117 -117
- package/templates/skills/application/references/frontend-route-wiring-app-tsx.md +107 -107
- package/templates/skills/application/references/frontend-verification.md +156 -156
- package/templates/skills/application/references/migration-checklist-troubleshooting.md +1 -1
- package/templates/skills/application/references/provider-template.md +177 -177
- package/templates/skills/application/references/roles-client-project-handling.md +55 -55
- package/templates/skills/application/references/roles-fallback-procedure.md +149 -149
- package/templates/skills/application/references/test-coverage-requirements.md +213 -213
- package/templates/skills/application/references/test-frontend.md +73 -73
- package/templates/skills/application/references/test-prerequisites.md +72 -72
- package/templates/skills/application/steps/step-05-frontend.md +176 -176
- package/templates/skills/application/steps/step-06-migration.md +193 -193
- package/templates/skills/application/steps/step-07-tests.md +356 -356
- package/templates/skills/application/steps/step-08-documentation.md +137 -137
- package/templates/skills/application/templates-backend.md +463 -463
- package/templates/skills/application/templates-frontend.md +685 -685
- package/templates/skills/application/templates-i18n.md +520 -520
- package/templates/skills/application/templates-seed.md +1096 -1096
- package/templates/skills/business-analyse/SKILL.md +327 -327
- package/templates/skills/business-analyse/_architecture.md +123 -123
- package/templates/skills/business-analyse/_elicitation.md +206 -206
- package/templates/skills/business-analyse/_module-loop.md +115 -115
- package/templates/skills/business-analyse/_shared.md +383 -383
- package/templates/skills/business-analyse/_suggestions.md +34 -34
- package/templates/skills/business-analyse/html/ba-interactive.html +4477 -4477
- package/templates/skills/business-analyse/html/build-html.js +77 -77
- package/templates/skills/business-analyse/html/src/scripts/01-data-init.js +150 -150
- package/templates/skills/business-analyse/html/src/scripts/02-navigation.js +227 -227
- package/templates/skills/business-analyse/html/src/scripts/03-render-cadrage.js +199 -199
- package/templates/skills/business-analyse/html/src/scripts/04-render-modules.js +205 -205
- package/templates/skills/business-analyse/html/src/scripts/05-render-specs.js +647 -647
- package/templates/skills/business-analyse/html/src/scripts/06-render-consolidation.js +195 -195
- package/templates/skills/business-analyse/html/src/scripts/07-render-handoff.js +92 -92
- package/templates/skills/business-analyse/html/src/scripts/08-editing.js +135 -135
- package/templates/skills/business-analyse/html/src/scripts/09-export.js +168 -168
- package/templates/skills/business-analyse/html/src/scripts/10-comments.js +171 -171
- package/templates/skills/business-analyse/html/src/scripts/11-review-panel.js +166 -166
- package/templates/skills/business-analyse/html/src/styles/01-variables.css +38 -38
- package/templates/skills/business-analyse/html/src/styles/02-layout.css +101 -101
- package/templates/skills/business-analyse/html/src/styles/03-navigation.css +120 -120
- package/templates/skills/business-analyse/html/src/styles/04-cards.css +196 -196
- package/templates/skills/business-analyse/html/src/styles/05-modules.css +454 -454
- package/templates/skills/business-analyse/html/src/styles/06-wireframes.css +272 -272
- package/templates/skills/business-analyse/html/src/styles/07-comments.css +184 -184
- package/templates/skills/business-analyse/html/src/styles/08-review-panel.css +241 -241
- package/templates/skills/business-analyse/html/src/template.html +516 -516
- package/templates/skills/business-analyse/patterns/suggestion-catalog.md +546 -546
- package/templates/skills/business-analyse/questionnaire/00-application.md +160 -160
- package/templates/skills/business-analyse/questionnaire/00b-project.md +85 -85
- package/templates/skills/business-analyse/questionnaire/01-context.md +185 -185
- package/templates/skills/business-analyse/questionnaire/02-stakeholders.md +189 -189
- package/templates/skills/business-analyse/questionnaire/03-scope.md +164 -164
- package/templates/skills/business-analyse/questionnaire/04-data.md +88 -88
- package/templates/skills/business-analyse/questionnaire/05-integrations.md +58 -58
- package/templates/skills/business-analyse/questionnaire/06-security.md +68 -68
- package/templates/skills/business-analyse/questionnaire/07-ui.md +76 -76
- package/templates/skills/business-analyse/questionnaire/08-performance.md +42 -42
- package/templates/skills/business-analyse/questionnaire/09-constraints.md +45 -45
- package/templates/skills/business-analyse/questionnaire/10-documentation.md +43 -43
- package/templates/skills/business-analyse/questionnaire/11-data-lifecycle.md +59 -59
- package/templates/skills/business-analyse/questionnaire/12-migration.md +58 -58
- package/templates/skills/business-analyse/questionnaire/13-cross-module.md +69 -69
- package/templates/skills/business-analyse/questionnaire/14-risk-assumptions.md +135 -135
- package/templates/skills/business-analyse/questionnaire/15-success-metrics.md +136 -136
- package/templates/skills/business-analyse/questionnaire.md +337 -337
- package/templates/skills/business-analyse/react/application-viewer.md +242 -242
- package/templates/skills/business-analyse/react/components.md +551 -551
- package/templates/skills/business-analyse/react/i18n-template.md +306 -306
- package/templates/skills/business-analyse/references/acceptance-criteria.md +169 -169
- package/templates/skills/business-analyse/references/agent-module-prompt.md +362 -362
- package/templates/skills/business-analyse/references/agent-pooling-best-practices.md +557 -557
- package/templates/skills/business-analyse/references/analysis-semantic-checks.md +190 -190
- package/templates/skills/business-analyse/references/cache-warming-strategy.md +566 -566
- package/templates/skills/business-analyse/references/cadrage-challenge-patterns.md +41 -41
- package/templates/skills/business-analyse/references/cadrage-coverage-matrix.md +74 -74
- package/templates/skills/business-analyse/references/cadrage-pre-analysis.md +115 -115
- package/templates/skills/business-analyse/references/cadrage-shared-modules.md +68 -69
- package/templates/skills/business-analyse/references/cadrage-structure-cards.md +85 -85
- package/templates/skills/business-analyse/references/compilation-structure-cards.md +297 -297
- package/templates/skills/business-analyse/references/consolidation-structural-checks.md +107 -107
- package/templates/skills/business-analyse/references/deploy-data-build.md +180 -180
- package/templates/skills/business-analyse/references/deploy-modes.md +118 -118
- package/templates/skills/business-analyse/references/detection-strategies.md +424 -424
- package/templates/skills/business-analyse/references/entity-architecture-decision.md +218 -218
- package/templates/skills/business-analyse/references/handoff-file-templates.md +120 -120
- package/templates/skills/business-analyse/references/handoff-mappings.md +81 -81
- package/templates/skills/business-analyse/references/handoff-seeddata-generation.md +312 -312
- package/templates/skills/business-analyse/references/html-data-mapping.md +299 -299
- package/templates/skills/business-analyse/references/init-schema-deployment.md +65 -65
- package/templates/skills/business-analyse/references/naming-conventions.md +243 -243
- package/templates/skills/business-analyse/references/prd-generation.md +258 -258
- package/templates/skills/business-analyse/references/review-data-mapping.md +363 -363
- package/templates/skills/business-analyse/references/robustness-checks.md +542 -542
- package/templates/skills/business-analyse/references/spec-auto-inference.md +111 -111
- package/templates/skills/business-analyse/references/team-orchestration.md +1022 -1022
- package/templates/skills/business-analyse/references/ui-dashboard-spec.md +85 -85
- package/templates/skills/business-analyse/references/ui-resource-cards.md +259 -259
- package/templates/skills/business-analyse/references/validate-incremental-html.md +121 -121
- package/templates/skills/business-analyse/references/validation-checklist.md +347 -347
- package/templates/skills/business-analyse/references/wireframe-svg-style-guide.md +335 -335
- package/templates/skills/business-analyse/schemas/application-schema.json +453 -453
- package/templates/skills/business-analyse/schemas/feature-schema.json +53 -53
- package/templates/skills/business-analyse/schemas/project-schema.json +485 -485
- package/templates/skills/business-analyse/schemas/sections/analysis-schema.json +201 -201
- package/templates/skills/business-analyse/schemas/sections/discovery-schema.json +82 -82
- package/templates/skills/business-analyse/schemas/sections/handoff-schema.json +80 -80
- package/templates/skills/business-analyse/schemas/sections/metadata-schema.json +70 -70
- package/templates/skills/business-analyse/schemas/sections/specification-schema.json +547 -547
- package/templates/skills/business-analyse/schemas/sections/validation-schema.json +93 -93
- package/templates/skills/business-analyse/schemas/shared/common-defs.json +226 -226
- package/templates/skills/business-analyse/steps/step-00-init.md +575 -576
- package/templates/skills/business-analyse/steps/step-01-cadrage.md +767 -767
- package/templates/skills/business-analyse/steps/step-01b-applications.md +419 -419
- package/templates/skills/business-analyse/steps/step-02-decomposition.md +387 -387
- package/templates/skills/business-analyse/steps/step-03a-data.md +16 -16
- package/templates/skills/business-analyse/steps/step-03a1-setup.md +506 -506
- package/templates/skills/business-analyse/steps/step-03a2-analysis.md +252 -252
- package/templates/skills/business-analyse/steps/step-03b-ui.md +425 -425
- package/templates/skills/business-analyse/steps/step-03c-compile.md +611 -611
- package/templates/skills/business-analyse/steps/step-03d-validate.md +783 -783
- package/templates/skills/business-analyse/steps/step-04-consolidation.md +17 -17
- package/templates/skills/business-analyse/steps/step-04a-collect.md +415 -415
- package/templates/skills/business-analyse/steps/step-04b-analyze.md +163 -163
- package/templates/skills/business-analyse/steps/step-04c-decide.md +186 -186
- package/templates/skills/business-analyse/steps/step-05a-handoff.md +840 -840
- package/templates/skills/business-analyse/steps/step-05b-deploy.md +522 -522
- package/templates/skills/business-analyse/steps/step-05c-ralph-readiness.md +703 -703
- package/templates/skills/business-analyse/steps/step-06-review.md +278 -278
- package/templates/skills/business-analyse/templates/tpl-frd.md +168 -168
- package/templates/skills/business-analyse/templates/tpl-handoff.md +186 -186
- package/templates/skills/business-analyse/templates/tpl-launch-displays.md +59 -59
- package/templates/skills/business-analyse/templates/tpl-progress.md +172 -172
- package/templates/skills/business-analyse/templates-frd.md +476 -476
- package/templates/skills/business-analyse/templates-react.md +574 -574
- package/templates/skills/cc-agent/SKILL.md +129 -129
- package/templates/skills/cc-agent/references/agent-behavior-patterns.md +95 -95
- package/templates/skills/cc-agent/references/agent-frontmatter.md +213 -213
- package/templates/skills/cc-agent/references/permission-modes.md +102 -102
- package/templates/skills/cc-agent/references/tools-reference.md +144 -144
- package/templates/skills/cc-agent/steps/step-00-init.md +134 -134
- package/templates/skills/cc-agent/steps/step-01-design.md +186 -186
- package/templates/skills/cc-agent/steps/step-02-generate.md +131 -131
- package/templates/skills/cc-agent/steps/step-03-validate.md +130 -130
- package/templates/skills/cc-agent/templates/agent-categorized.md +67 -67
- package/templates/skills/cc-agent/templates/agent-standalone.md +56 -56
- package/templates/skills/cc-agent/templates/agent-with-skills.md +94 -94
- package/templates/skills/cc-audit/SKILL.md +108 -108
- package/templates/skills/cc-audit/references/agent-checklist.md +91 -91
- package/templates/skills/cc-audit/references/hook-checklist.md +110 -110
- package/templates/skills/cc-audit/references/skill-checklist.md +70 -70
- package/templates/skills/cc-audit/steps/step-00-init.md +98 -98
- package/templates/skills/cc-audit/steps/step-01-scan.md +142 -142
- package/templates/skills/cc-audit/steps/step-02-analyze.md +158 -158
- package/templates/skills/cc-audit/steps/step-03-report.md +142 -142
- package/templates/skills/cc-skill/SKILL.md +134 -134
- package/templates/skills/cc-skill/references/best-practices.md +167 -167
- package/templates/skills/cc-skill/references/frontmatter-reference.md +182 -182
- package/templates/skills/cc-skill/references/skill-patterns.md +199 -199
- package/templates/skills/cc-skill/steps/step-00-init.md +119 -119
- package/templates/skills/cc-skill/steps/step-01-design.md +199 -199
- package/templates/skills/cc-skill/steps/step-02-generate.md +145 -145
- package/templates/skills/cc-skill/steps/step-03-steps.md +151 -151
- package/templates/skills/cc-skill/steps/step-04-validate.md +124 -124
- package/templates/skills/cc-skill/templates/skill-forked.md +85 -85
- package/templates/skills/cc-skill/templates/skill-progressive.md +102 -102
- package/templates/skills/cc-skill/templates/skill-simple.md +75 -75
- package/templates/skills/cc-skill/templates/step-template.md +82 -82
- package/templates/skills/check-version/SKILL.md +196 -196
- package/templates/skills/controller/SKILL.md +162 -162
- package/templates/skills/controller/postman-templates.md +614 -614
- package/templates/skills/controller/references/controller-code-templates.md +159 -159
- package/templates/skills/controller/references/mcp-scaffold-workflow.md +209 -209
- package/templates/skills/controller/references/permission-sync-templates.md +149 -149
- package/templates/skills/controller/steps/step-00-init.md +193 -191
- package/templates/skills/controller/steps/step-01-analyze.md +146 -146
- package/templates/skills/controller/steps/step-02-plan.md +176 -176
- package/templates/skills/controller/steps/step-03-generate.md +189 -189
- package/templates/skills/controller/steps/step-04-perms.md +80 -80
- package/templates/skills/controller/steps/step-05-validate.md +107 -107
- package/templates/skills/controller/templates.md +1555 -1555
- package/templates/skills/debug/SKILL.md +70 -70
- package/templates/skills/debug/references/team-protocol.md +232 -232
- package/templates/skills/debug/steps/step-00-init.md +57 -57
- package/templates/skills/debug/steps/step-01-analyze.md +219 -219
- package/templates/skills/debug/steps/step-02-resolve.md +85 -85
- package/templates/skills/documentation/SKILL.md +132 -132
- package/templates/skills/documentation/data-schema.md +227 -227
- package/templates/skills/documentation/steps/step-00-init.md +70 -70
- package/templates/skills/documentation/steps/step-01-scan.md +113 -113
- package/templates/skills/documentation/steps/step-02-generate.md +231 -231
- package/templates/skills/documentation/steps/step-03-validate.md +251 -238
- package/templates/skills/documentation/templates.md +662 -663
- package/templates/skills/efcore/SKILL.md +168 -167
- package/templates/skills/efcore/references/both-contexts.md +32 -32
- package/templates/skills/efcore/references/database-operations.md +67 -67
- package/templates/skills/efcore/references/destructive-operations.md +38 -38
- package/templates/skills/efcore/references/reset-operations.md +81 -81
- package/templates/skills/efcore/references/seed-methods.md +86 -86
- package/templates/skills/efcore/references/shared-init-functions.md +250 -250
- package/templates/skills/efcore/references/sql-objects-injection.md +61 -61
- package/templates/skills/efcore/references/troubleshooting.md +81 -81
- package/templates/skills/efcore/references/zero-downtime-patterns.md +227 -227
- package/templates/skills/efcore/steps/db/step-deploy.md +217 -217
- package/templates/skills/efcore/steps/db/step-reset.md +186 -186
- package/templates/skills/efcore/steps/db/step-seed.md +166 -166
- package/templates/skills/efcore/steps/db/step-status.md +173 -173
- package/templates/skills/efcore/steps/migration/step-00-init.md +102 -102
- package/templates/skills/efcore/steps/migration/step-01-check.md +164 -164
- package/templates/skills/efcore/steps/migration/step-02-create.md +160 -160
- package/templates/skills/efcore/steps/migration/step-03-validate.md +168 -168
- package/templates/skills/efcore/steps/rebase-snapshot/step-00-init.md +173 -173
- package/templates/skills/efcore/steps/rebase-snapshot/step-01-backup.md +100 -100
- package/templates/skills/efcore/steps/rebase-snapshot/step-02-fetch.md +115 -115
- package/templates/skills/efcore/steps/rebase-snapshot/step-03-create.md +112 -112
- package/templates/skills/efcore/steps/rebase-snapshot/step-04-validate.md +157 -157
- package/templates/skills/efcore/steps/shared/step-00-init.md +131 -131
- package/templates/skills/efcore/steps/squash/step-00-init.md +141 -141
- package/templates/skills/efcore/steps/squash/step-01-backup.md +120 -120
- package/templates/skills/efcore/steps/squash/step-02-fetch.md +168 -168
- package/templates/skills/efcore/steps/squash/step-03-create.md +184 -184
- package/templates/skills/efcore/steps/squash/step-04-validate.md +174 -174
- package/templates/skills/explore/SKILL.md +98 -98
- package/templates/skills/feature-full/SKILL.md +111 -111
- package/templates/skills/feature-full/steps/step-00-init.md +57 -57
- package/templates/skills/feature-full/steps/step-01-implementation.md +120 -120
- package/templates/skills/gitflow/SKILL.md +377 -377
- package/templates/skills/gitflow/_shared.md +620 -620
- package/templates/skills/gitflow/phases/abort.md +189 -189
- package/templates/skills/gitflow/phases/cleanup.md +234 -234
- package/templates/skills/gitflow/phases/status.md +192 -192
- package/templates/skills/gitflow/references/commit-message-generation.md +58 -58
- package/templates/skills/gitflow/references/commit-migration-validation.md +49 -49
- package/templates/skills/gitflow/references/finish-cleanup.md +55 -55
- package/templates/skills/gitflow/references/finish-version-bumping.md +45 -45
- package/templates/skills/gitflow/references/init-config-template.md +135 -135
- package/templates/skills/gitflow/references/init-environment-detection.md +41 -41
- package/templates/skills/gitflow/references/init-name-normalization.md +103 -103
- package/templates/skills/gitflow/references/init-questions.md +185 -185
- package/templates/skills/gitflow/references/init-structure-creation.md +75 -75
- package/templates/skills/gitflow/references/init-version-detection.md +21 -21
- package/templates/skills/gitflow/references/init-workspace-detection.md +43 -43
- package/templates/skills/gitflow/references/merge-ci-status.md +36 -36
- package/templates/skills/gitflow/references/merge-execution.md +62 -62
- package/templates/skills/gitflow/references/merge-pr-context.md +76 -76
- package/templates/skills/gitflow/references/plan-template.md +69 -69
- package/templates/skills/gitflow/references/pr-build-checks.md +60 -60
- package/templates/skills/gitflow/references/pr-generation.md +58 -58
- package/templates/skills/gitflow/references/start-branch-normalization.md +28 -28
- package/templates/skills/gitflow/references/start-efcore-preflight.md +70 -70
- package/templates/skills/gitflow/references/start-local-config.md +113 -113
- package/templates/skills/gitflow/references/start-worktree-creation.md +50 -50
- package/templates/skills/gitflow/references/sync-push-verify.md +44 -44
- package/templates/skills/gitflow/references/sync-rebase-conflicts.md +38 -38
- package/templates/skills/gitflow/steps/step-commit.md +199 -199
- package/templates/skills/gitflow/steps/step-finish.md +147 -147
- package/templates/skills/gitflow/steps/step-init.md +190 -190
- package/templates/skills/gitflow/steps/step-merge.md +85 -85
- package/templates/skills/gitflow/steps/step-plan.md +151 -151
- package/templates/skills/gitflow/steps/step-pr.md +199 -199
- package/templates/skills/gitflow/steps/step-start.md +195 -195
- package/templates/skills/gitflow/steps/step-sync.md +161 -161
- package/templates/skills/gitflow/templates/config.json +72 -72
- package/templates/skills/mcp/SKILL.md +62 -62
- package/templates/skills/mcp/steps/step-01-healthcheck.md +108 -108
- package/templates/skills/mcp/steps/step-02-tools.md +73 -73
- package/templates/skills/notification/SKILL.md +173 -173
- package/templates/skills/quick-search/SKILL.md +99 -99
- package/templates/skills/ralph-loop/SKILL.md +234 -234
- package/templates/skills/ralph-loop/references/category-completeness.md +185 -185
- package/templates/skills/ralph-loop/references/category-rules.md +96 -96
- package/templates/skills/ralph-loop/references/compact-loop.md +300 -300
- package/templates/skills/ralph-loop/references/init-resume-recovery.md +127 -127
- package/templates/skills/ralph-loop/references/module-transition.md +151 -151
- package/templates/skills/ralph-loop/references/multi-module-queue.md +171 -171
- package/templates/skills/ralph-loop/references/parallel-execution.md +246 -246
- package/templates/skills/ralph-loop/references/section-splitting.md +439 -439
- package/templates/skills/ralph-loop/references/task-transform-legacy.md +256 -256
- package/templates/skills/ralph-loop/references/team-orchestration.md +547 -547
- package/templates/skills/ralph-loop/steps/step-00-init.md +150 -150
- package/templates/skills/ralph-loop/steps/step-01-task.md +174 -174
- package/templates/skills/ralph-loop/steps/step-02-execute.md +177 -177
- package/templates/skills/ralph-loop/steps/step-03-commit.md +92 -92
- package/templates/skills/ralph-loop/steps/step-04-check.md +207 -207
- package/templates/skills/ralph-loop/steps/step-05-report.md +175 -175
- package/templates/skills/refactor/SKILL.md +56 -56
- package/templates/skills/refactor/steps/step-01-discover.md +60 -60
- package/templates/skills/refactor/steps/step-02-execute.md +67 -67
- package/templates/skills/review-code/SKILL.md +95 -94
- package/templates/skills/review-code/references/clean-code-principles.md +292 -292
- package/templates/skills/review-code/references/code-quality-metrics.md +174 -174
- package/templates/skills/review-code/references/feedback-patterns.md +149 -149
- package/templates/skills/review-code/references/owasp-api-top10.md +243 -243
- package/templates/skills/review-code/references/security-checklist.md +212 -212
- package/templates/skills/review-code/steps/step-01-smartstack.md +96 -96
- package/templates/skills/review-code/steps/step-02-detailed-review.md +80 -80
- package/templates/skills/review-code/steps/step-03-react.md +44 -44
- package/templates/skills/ui-components/SKILL.md +137 -137
- package/templates/skills/ui-components/accessibility.md +170 -170
- package/templates/skills/ui-components/patterns/dashboard-chart.md +327 -327
- package/templates/skills/ui-components/patterns/data-table.md +39 -39
- package/templates/skills/ui-components/patterns/entity-card.md +77 -77
- package/templates/skills/ui-components/patterns/grid-layout.md +91 -91
- package/templates/skills/ui-components/patterns/kanban.md +43 -43
- package/templates/skills/ui-components/responsive-guidelines.md +278 -278
- package/templates/skills/ui-components/style-guide.md +113 -113
- package/templates/skills/utils/SKILL.md +44 -44
- package/templates/skills/utils/subcommands/test-web-config.md +152 -152
- package/templates/skills/utils/subcommands/test-web.md +123 -123
- package/templates/skills/validate/SKILL.md +181 -181
- package/templates/skills/validate-feature/SKILL.md +101 -101
- package/templates/skills/validate-feature/references/api-smoke-tests.md +140 -140
- package/templates/skills/validate-feature/references/db-validation-checks.md +180 -180
- package/templates/skills/validate-feature/steps/step-00-dependencies.md +121 -121
- package/templates/skills/validate-feature/steps/step-01-compile.md +39 -39
- package/templates/skills/validate-feature/steps/step-02-unit-tests.md +45 -45
- package/templates/skills/validate-feature/steps/step-03-integration-tests.md +53 -53
- package/templates/skills/validate-feature/steps/step-04-api-smoke.md +94 -94
- package/templates/skills/validate-feature/steps/step-05-db-validation.md +149 -149
- package/templates/skills/workflow/SKILL.md +127 -127
- package/templates/skills/workflow/steps/step-00-init.md +57 -57
- package/templates/skills/workflow/steps/step-01-implementation.md +84 -84
- package/templates/test-web/api-health.json +38 -38
- package/templates/test-web/minimal.json +19 -19
- package/templates/test-web/npm-package.json +46 -46
- package/templates/test-web/seo-check.json +54 -54
|
@@ -1,1053 +1,1053 @@
|
|
|
1
|
-
# SmartStack Domain API Reference
|
|
2
|
-
|
|
3
|
-
> **Source of truth:** `SmartStack.app/src/SmartStack.Domain/Common/`
|
|
4
|
-
> **Loaded by:** step-01 (analyze), step-03 (execute)
|
|
5
|
-
|
|
6
|
-
---
|
|
7
|
-
|
|
8
|
-
## BaseEntity
|
|
9
|
-
|
|
10
|
-
```csharp
|
|
11
|
-
namespace SmartStack.Domain.Common;
|
|
12
|
-
|
|
13
|
-
public abstract class BaseEntity
|
|
14
|
-
{
|
|
15
|
-
public Guid Id { get; set; }
|
|
16
|
-
public DateTime CreatedAt { get; set; }
|
|
17
|
-
public DateTime? UpdatedAt { get; set; }
|
|
18
|
-
}
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
**ONLY 3 properties.** No Code, no IsDeleted, no RowVersion, no SoftDelete, no CreatedBy/UpdatedBy.
|
|
22
|
-
|
|
23
|
-
---
|
|
24
|
-
|
|
25
|
-
## Interfaces
|
|
26
|
-
|
|
27
|
-
### ITenantEntity (mandatory tenant isolation)
|
|
28
|
-
|
|
29
|
-
```csharp
|
|
30
|
-
public interface ITenantEntity
|
|
31
|
-
{
|
|
32
|
-
Guid TenantId { get; }
|
|
33
|
-
}
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
### IAuditableEntity (audit trail)
|
|
37
|
-
|
|
38
|
-
```csharp
|
|
39
|
-
public interface IAuditableEntity
|
|
40
|
-
{
|
|
41
|
-
string? CreatedBy { get; set; }
|
|
42
|
-
string? UpdatedBy { get; set; }
|
|
43
|
-
}
|
|
44
|
-
```
|
|
45
|
-
|
|
46
|
-
### IOptionalTenantEntity (nullable tenant)
|
|
47
|
-
|
|
48
|
-
```csharp
|
|
49
|
-
public interface IOptionalTenantEntity
|
|
50
|
-
{
|
|
51
|
-
Guid? TenantId { get; }
|
|
52
|
-
}
|
|
53
|
-
```
|
|
54
|
-
|
|
55
|
-
### IScopedTenantEntity (tenant + scope visibility)
|
|
56
|
-
|
|
57
|
-
```csharp
|
|
58
|
-
public interface IScopedTenantEntity : IOptionalTenantEntity
|
|
59
|
-
{
|
|
60
|
-
EntityScope Scope { get; }
|
|
61
|
-
}
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
### EntityScope enum
|
|
65
|
-
|
|
66
|
-
```csharp
|
|
67
|
-
public enum EntityScope
|
|
68
|
-
{
|
|
69
|
-
Tenant = 0, // Visible only to specific tenant (TenantId required)
|
|
70
|
-
Shared = 1, // Visible to all tenants (TenantId null)
|
|
71
|
-
Platform = 2 // Visible only to platform admins (HasGlobalAccess)
|
|
72
|
-
}
|
|
73
|
-
```
|
|
74
|
-
|
|
75
|
-
---
|
|
76
|
-
|
|
77
|
-
## Entity Pattern (tenant-scoped, most common)
|
|
78
|
-
|
|
79
|
-
```csharp
|
|
80
|
-
using SmartStack.Domain.Common;
|
|
81
|
-
|
|
82
|
-
namespace {ProjectName}.Domain.Entities.{App}.{Module};
|
|
83
|
-
|
|
84
|
-
public class {Name} : BaseEntity, ITenantEntity, IAuditableEntity
|
|
85
|
-
{
|
|
86
|
-
// ITenantEntity
|
|
87
|
-
public Guid TenantId { get; private set; }
|
|
88
|
-
|
|
89
|
-
// IAuditableEntity
|
|
90
|
-
public string? CreatedBy { get; set; }
|
|
91
|
-
public string? UpdatedBy { get; set; }
|
|
92
|
-
|
|
93
|
-
// Business properties (add your own)
|
|
94
|
-
public string Code { get; private set; } = null!;
|
|
95
|
-
public string Name { get; private set; } = null!;
|
|
96
|
-
public string? Description { get; private set; }
|
|
97
|
-
public bool IsActive { get; private set; } = true;
|
|
98
|
-
|
|
99
|
-
private {Name}() { }
|
|
100
|
-
|
|
101
|
-
public static {Name} Create(Guid tenantId, string code, string name)
|
|
102
|
-
{
|
|
103
|
-
if (tenantId == Guid.Empty)
|
|
104
|
-
throw new ArgumentException("TenantId is required", nameof(tenantId));
|
|
105
|
-
|
|
106
|
-
return new {Name}
|
|
107
|
-
{
|
|
108
|
-
Id = Guid.NewGuid(),
|
|
109
|
-
TenantId = tenantId,
|
|
110
|
-
Code = code.ToLowerInvariant(),
|
|
111
|
-
Name = name,
|
|
112
|
-
CreatedAt = DateTime.UtcNow
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
public void Update(string name, string? description)
|
|
117
|
-
{
|
|
118
|
-
Name = name;
|
|
119
|
-
Description = description;
|
|
120
|
-
UpdatedAt = DateTime.UtcNow;
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
```
|
|
124
|
-
|
|
125
|
-
---
|
|
126
|
-
|
|
127
|
-
## Entity Pattern (platform-level, no tenant)
|
|
128
|
-
|
|
129
|
-
```csharp
|
|
130
|
-
public class {Name} : BaseEntity, IAuditableEntity
|
|
131
|
-
{
|
|
132
|
-
public string? CreatedBy { get; set; }
|
|
133
|
-
public string? UpdatedBy { get; set; }
|
|
134
|
-
|
|
135
|
-
// Business properties
|
|
136
|
-
public string Code { get; private set; } = null!;
|
|
137
|
-
public string Name { get; private set; } = null!;
|
|
138
|
-
|
|
139
|
-
private {Name}() { }
|
|
140
|
-
|
|
141
|
-
public static {Name} Create(string code, string name)
|
|
142
|
-
{
|
|
143
|
-
return new {Name}
|
|
144
|
-
{
|
|
145
|
-
Id = Guid.NewGuid(),
|
|
146
|
-
Code = code.ToLowerInvariant(),
|
|
147
|
-
Name = name,
|
|
148
|
-
CreatedAt = DateTime.UtcNow
|
|
149
|
-
};
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
```
|
|
153
|
-
|
|
154
|
-
### Entity Pattern — Cross-Tenant (IOptionalTenantEntity)
|
|
155
|
-
|
|
156
|
-
For entities that can be shared across tenants (e.g., Department, Currency). TenantId is nullable — null means shared, Guid means tenant-specific. The user decides the scope at creation time.
|
|
157
|
-
|
|
158
|
-
```csharp
|
|
159
|
-
public class {Name} : BaseEntity, IOptionalTenantEntity, IAuditableEntity
|
|
160
|
-
{
|
|
161
|
-
// TenantId nullable — null = shared across all tenants
|
|
162
|
-
public Guid? TenantId { get; private set; }
|
|
163
|
-
|
|
164
|
-
public string? CreatedBy { get; set; }
|
|
165
|
-
public string? UpdatedBy { get; set; }
|
|
166
|
-
|
|
167
|
-
// Business properties
|
|
168
|
-
public string Code { get; private set; } = string.Empty;
|
|
169
|
-
public string Name { get; private set; } = string.Empty;
|
|
170
|
-
|
|
171
|
-
private {Name}() { }
|
|
172
|
-
|
|
173
|
-
/// <param name="tenantId">null = shared (cross-tenant), Guid = tenant-specific</param>
|
|
174
|
-
public static {Name} Create(Guid? tenantId = null, string code, string name)
|
|
175
|
-
{
|
|
176
|
-
return new {Name}
|
|
177
|
-
{
|
|
178
|
-
Id = Guid.NewGuid(),
|
|
179
|
-
TenantId = tenantId,
|
|
180
|
-
Code = code.ToLowerInvariant(),
|
|
181
|
-
Name = name,
|
|
182
|
-
CreatedAt = DateTime.UtcNow
|
|
183
|
-
};
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
```
|
|
187
|
-
|
|
188
|
-
**EF Core global query filter (already in SmartStack.app CoreDbContext):**
|
|
189
|
-
```csharp
|
|
190
|
-
builder.HasQueryFilter(e => !ShouldFilterByTenant || e.TenantId == null || e.TenantId == CurrentTenantId);
|
|
191
|
-
```
|
|
192
|
-
This automatically includes shared (null) + current tenant data in all queries.
|
|
193
|
-
|
|
194
|
-
**Service pattern for optional tenant:**
|
|
195
|
-
```csharp
|
|
196
|
-
// No guard clause — tenantId is nullable
|
|
197
|
-
var tenantId = _currentTenant.TenantId; // null = creating shared data
|
|
198
|
-
var entity = Department.Create(tenantId, dto.Code, dto.Name);
|
|
199
|
-
```
|
|
200
|
-
|
|
201
|
-
### Entity Pattern — Scoped (IScopedTenantEntity)
|
|
202
|
-
|
|
203
|
-
For entities with explicit visibility control via EntityScope enum (Tenant, Shared, Platform).
|
|
204
|
-
|
|
205
|
-
```csharp
|
|
206
|
-
public class {Name} : BaseEntity, IScopedTenantEntity, IAuditableEntity
|
|
207
|
-
{
|
|
208
|
-
public Guid? TenantId { get; private set; }
|
|
209
|
-
public EntityScope Scope { get; private set; }
|
|
210
|
-
|
|
211
|
-
public string? CreatedBy { get; set; }
|
|
212
|
-
public string? UpdatedBy { get; set; }
|
|
213
|
-
|
|
214
|
-
private {Name}() { }
|
|
215
|
-
|
|
216
|
-
public static {Name} Create(Guid? tenantId = null, EntityScope scope = EntityScope.Tenant)
|
|
217
|
-
{
|
|
218
|
-
if (scope == EntityScope.Tenant && tenantId == null)
|
|
219
|
-
throw new ArgumentException("TenantId is required when scope is Tenant");
|
|
220
|
-
|
|
221
|
-
return new {Name}
|
|
222
|
-
{
|
|
223
|
-
Id = Guid.NewGuid(),
|
|
224
|
-
TenantId = tenantId,
|
|
225
|
-
Scope = scope,
|
|
226
|
-
CreatedAt = DateTime.UtcNow
|
|
227
|
-
};
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
```
|
|
231
|
-
|
|
232
|
-
### MCP tenantMode Parameter
|
|
233
|
-
|
|
234
|
-
When calling `scaffold_extension`, use the `tenantMode` parameter:
|
|
235
|
-
- `strict` (default) — ITenantEntity, Guid TenantId (required)
|
|
236
|
-
- `optional` — IOptionalTenantEntity, Guid? TenantId (cross-tenant)
|
|
237
|
-
- `scoped` — IScopedTenantEntity, Guid? TenantId + EntityScope
|
|
238
|
-
- `none` — No tenant interface (platform-level entities)
|
|
239
|
-
|
|
240
|
-
The old `isSystemEntity: true` still works and maps to `tenantMode: 'none'`.
|
|
241
|
-
|
|
242
|
-
---
|
|
243
|
-
|
|
244
|
-
## EF Configuration Pattern
|
|
245
|
-
|
|
246
|
-
```csharp
|
|
247
|
-
using Microsoft.EntityFrameworkCore;
|
|
248
|
-
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
|
249
|
-
|
|
250
|
-
public class {Name}Configuration : IEntityTypeConfiguration<{Name}>
|
|
251
|
-
{
|
|
252
|
-
public void Configure(EntityTypeBuilder<{Name}> builder)
|
|
253
|
-
{
|
|
254
|
-
builder.ToTable("{prefix}{Name}s", "{schema}");
|
|
255
|
-
|
|
256
|
-
builder.HasKey(x => x.Id);
|
|
257
|
-
|
|
258
|
-
// Tenant (if ITenantEntity)
|
|
259
|
-
builder.Property(x => x.TenantId).IsRequired();
|
|
260
|
-
builder.HasIndex(x => x.TenantId)
|
|
261
|
-
.HasDatabaseName("IX_{prefix}{Name}s_TenantId");
|
|
262
|
-
|
|
263
|
-
// Business properties
|
|
264
|
-
builder.Property(x => x.Code).HasMaxLength(50).IsRequired();
|
|
265
|
-
builder.Property(x => x.Name).HasMaxLength(100).IsRequired();
|
|
266
|
-
builder.Property(x => x.Description).HasMaxLength(500);
|
|
267
|
-
|
|
268
|
-
// Audit (from IAuditableEntity)
|
|
269
|
-
builder.Property(x => x.CreatedBy).HasMaxLength(256);
|
|
270
|
-
builder.Property(x => x.UpdatedBy).HasMaxLength(256);
|
|
271
|
-
|
|
272
|
-
// Unique indexes
|
|
273
|
-
builder.HasIndex(x => new { x.TenantId, x.Code })
|
|
274
|
-
.IsUnique()
|
|
275
|
-
.HasDatabaseName("IX_{prefix}{Name}s_Tenant_Code");
|
|
276
|
-
|
|
277
|
-
// Relationships
|
|
278
|
-
// builder.HasMany(x => x.Children)
|
|
279
|
-
// .WithOne(x => x.Parent)
|
|
280
|
-
// .HasForeignKey(x => x.ParentId)
|
|
281
|
-
// .OnDelete(DeleteBehavior.Restrict);
|
|
282
|
-
|
|
283
|
-
// Seed data (if applicable)
|
|
284
|
-
// builder.HasData({Name}SeedData.GetSeedData());
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
```
|
|
288
|
-
|
|
289
|
-
---
|
|
290
|
-
|
|
291
|
-
## Service Pattern (tenant-scoped, MANDATORY)
|
|
292
|
-
|
|
293
|
-
> **CRITICAL:** ALL services MUST inject `ICurrentUserService` + `ICurrentTenantService` and filter by `TenantId`. Missing TenantId = OWASP A01 vulnerability.
|
|
294
|
-
|
|
295
|
-
```csharp
|
|
296
|
-
using Microsoft.EntityFrameworkCore;
|
|
297
|
-
using Microsoft.Extensions.Logging;
|
|
298
|
-
using SmartStack.Application.Common.Interfaces.Identity;
|
|
299
|
-
using SmartStack.Application.Common.Interfaces.Tenants;
|
|
300
|
-
using SmartStack.Application.Common.Interfaces.Persistence;
|
|
301
|
-
|
|
302
|
-
namespace {ProjectName}.Infrastructure.Services.{App}.{Module};
|
|
303
|
-
|
|
304
|
-
public class {Name}Service : I{Name}Service
|
|
305
|
-
{
|
|
306
|
-
private readonly IExtensionsDbContext _db;
|
|
307
|
-
private readonly ICurrentUserService _currentUser;
|
|
308
|
-
private readonly ICurrentTenantService _currentTenant;
|
|
309
|
-
private readonly ILogger<{Name}Service> _logger;
|
|
310
|
-
|
|
311
|
-
public {Name}Service(
|
|
312
|
-
IExtensionsDbContext db,
|
|
313
|
-
ICurrentUserService currentUser,
|
|
314
|
-
ICurrentTenantService currentTenant,
|
|
315
|
-
ILogger<{Name}Service> logger)
|
|
316
|
-
{
|
|
317
|
-
_db = db;
|
|
318
|
-
_currentUser = currentUser;
|
|
319
|
-
_currentTenant = currentTenant;
|
|
320
|
-
_logger = logger;
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
public async Task<PaginatedResult<{Name}ResponseDto>> GetAllAsync(
|
|
324
|
-
string? search = null,
|
|
325
|
-
int page = 1,
|
|
326
|
-
int pageSize = 20,
|
|
327
|
-
CancellationToken ct = default)
|
|
328
|
-
{
|
|
329
|
-
// MANDATORY guard — throws 400 if no tenant context (e.g., missing X-Tenant-Slug header)
|
|
330
|
-
var tenantId = _currentTenant.TenantId
|
|
331
|
-
?? throw new TenantContextRequiredException();
|
|
332
|
-
|
|
333
|
-
var query = _db.{Name}s
|
|
334
|
-
.Where(x => x.TenantId == tenantId) // MANDATORY tenant filter
|
|
335
|
-
.AsNoTracking();
|
|
336
|
-
|
|
337
|
-
// Search filter — enables EntityLookup on frontend
|
|
338
|
-
if (!string.IsNullOrWhiteSpace(search))
|
|
339
|
-
{
|
|
340
|
-
query = query.Where(x =>
|
|
341
|
-
x.Name.Contains(search) ||
|
|
342
|
-
x.Code.Contains(search));
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
var totalCount = await query.CountAsync(ct);
|
|
346
|
-
var items = await query
|
|
347
|
-
.OrderBy(x => x.Name)
|
|
348
|
-
.Skip((page - 1) * pageSize)
|
|
349
|
-
.Take(pageSize)
|
|
350
|
-
.Select(x => new {Name}ResponseDto(x.Id, x.Code, x.Name, x.CreatedAt))
|
|
351
|
-
.ToListAsync(ct);
|
|
352
|
-
|
|
353
|
-
return new PaginatedResult<{Name}ResponseDto>(items, totalCount, page, pageSize);
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
public async Task<{Name}ResponseDto?> GetByIdAsync(Guid id, CancellationToken ct)
|
|
357
|
-
{
|
|
358
|
-
var tenantId = _currentTenant.TenantId
|
|
359
|
-
?? throw new TenantContextRequiredException();
|
|
360
|
-
|
|
361
|
-
return await _db.{Name}s
|
|
362
|
-
.Where(x => x.Id == id && x.TenantId == tenantId) // MANDATORY
|
|
363
|
-
.AsNoTracking()
|
|
364
|
-
.Select(x => new {Name}ResponseDto(x.Id, x.Code, x.Name, x.CreatedAt))
|
|
365
|
-
.FirstOrDefaultAsync(ct);
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
public async Task<{Name}ResponseDto> CreateAsync(Create{Name}Dto dto, CancellationToken ct)
|
|
369
|
-
{
|
|
370
|
-
var tenantId = _currentTenant.TenantId
|
|
371
|
-
?? throw new TenantContextRequiredException();
|
|
372
|
-
|
|
373
|
-
var entity = {Name}.Create(
|
|
374
|
-
tenantId: tenantId, // MANDATORY — never Guid.Empty
|
|
375
|
-
code: dto.Code,
|
|
376
|
-
name: dto.Name);
|
|
377
|
-
|
|
378
|
-
entity.CreatedBy = _currentUser.UserId?.ToString();
|
|
379
|
-
|
|
380
|
-
_db.{Name}s.Add(entity);
|
|
381
|
-
await _db.SaveChangesAsync(ct);
|
|
382
|
-
|
|
383
|
-
_logger.LogInformation("Created {Entity} {Id} for tenant {TenantId}",
|
|
384
|
-
nameof({Name}), entity.Id, tenantId);
|
|
385
|
-
|
|
386
|
-
return new {Name}ResponseDto(entity.Id, entity.Code, entity.Name, entity.CreatedAt);
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
public async Task DeleteAsync(Guid id, CancellationToken ct)
|
|
390
|
-
{
|
|
391
|
-
var tenantId = _currentTenant.TenantId
|
|
392
|
-
?? throw new TenantContextRequiredException();
|
|
393
|
-
|
|
394
|
-
var entity = await _db.{Name}s
|
|
395
|
-
.FirstOrDefaultAsync(x => x.Id == id && x.TenantId == tenantId, ct)
|
|
396
|
-
?? throw new KeyNotFoundException($"{Name} {id} not found");
|
|
397
|
-
|
|
398
|
-
_db.{Name}s.Remove(entity);
|
|
399
|
-
await _db.SaveChangesAsync(ct);
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
```
|
|
403
|
-
|
|
404
|
-
**Key interfaces (from SmartStack NuGet package):**
|
|
405
|
-
- `ICurrentUserService` (from `SmartStack.Application.Common.Interfaces.Identity`): provides `UserId` (Guid?), `Email` (string?), `IsAuthenticated` (bool)
|
|
406
|
-
- `ICurrentTenantService` (from `SmartStack.Application.Common.Interfaces.Tenants`): provides `TenantId` (Guid?), `HasTenant` (bool), `TenantSlug` (string?)
|
|
407
|
-
- `IExtensionsDbContext` (for client extensions) or `ICoreDbContext` (for platform)
|
|
408
|
-
|
|
409
|
-
**MANDATORY guard clause (first line of every method):**
|
|
410
|
-
```csharp
|
|
411
|
-
var tenantId = _currentTenant.TenantId
|
|
412
|
-
?? throw new TenantContextRequiredException();
|
|
413
|
-
```
|
|
414
|
-
This converts a null TenantId into a clean 400 Bad Request response via `GlobalExceptionHandlerMiddleware`.
|
|
415
|
-
**IMPORTANT:** Uses `TenantContextRequiredException` (400), NOT `UnauthorizedAccessException` (401). A missing tenant is a bad request, not an auth failure — the JWT is valid, `[Authorize]` passed.
|
|
416
|
-
|
|
417
|
-
**FORBIDDEN in services:**
|
|
418
|
-
- `_currentTenant.TenantId!.Value` — throws `InvalidOperationException` (500) instead of clean 400
|
|
419
|
-
- `UnauthorizedAccessException("Tenant context is required")` — throws 401, triggers frontend token clearing
|
|
420
|
-
- `tenantId: Guid.Empty` — always use validated tenantId from guard clause
|
|
421
|
-
- Queries WITHOUT `.Where(x => x.TenantId == tenantId)` — data leak
|
|
422
|
-
- Missing `ILogger<T>` — undiagnosable in production
|
|
423
|
-
- Using `ICurrentUser` (does NOT exist) — use `ICurrentUserService` + `ICurrentTenantService`
|
|
424
|
-
|
|
425
|
-
---
|
|
426
|
-
|
|
427
|
-
## Controller Pattern (NavRoute)
|
|
428
|
-
|
|
429
|
-
```csharp
|
|
430
|
-
using Microsoft.AspNetCore.Authorization;
|
|
431
|
-
using Microsoft.AspNetCore.Mvc;
|
|
432
|
-
using SmartStack.Api.Routing;
|
|
433
|
-
using SmartStack.Api.Authorization;
|
|
434
|
-
|
|
435
|
-
namespace {ProjectName}.Api.Controllers.{App};
|
|
436
|
-
|
|
437
|
-
[ApiController]
|
|
438
|
-
[NavRoute("{app}.{module}")]
|
|
439
|
-
[Authorize]
|
|
440
|
-
public class {Name}Controller : ControllerBase
|
|
441
|
-
{
|
|
442
|
-
private readonly I{Name}Service _service;
|
|
443
|
-
private readonly ILogger<{Name}Controller> _logger;
|
|
444
|
-
|
|
445
|
-
public {Name}Controller(I{Name}Service service, ILogger<{Name}Controller> logger)
|
|
446
|
-
{
|
|
447
|
-
_service = service;
|
|
448
|
-
_logger = logger;
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
[HttpGet]
|
|
452
|
-
[RequirePermission(Permissions.{Module}.Read)]
|
|
453
|
-
public async Task<ActionResult<PaginatedResult<{Name}ResponseDto>>> GetAll(
|
|
454
|
-
[FromQuery] string? search = null,
|
|
455
|
-
[FromQuery] int page = 1,
|
|
456
|
-
[FromQuery] int pageSize = 20,
|
|
457
|
-
CancellationToken ct = default)
|
|
458
|
-
=> Ok(await _service.GetAllAsync(search, page, pageSize, ct));
|
|
459
|
-
|
|
460
|
-
[HttpGet("{id:guid}")]
|
|
461
|
-
[RequirePermission(Permissions.{Module}.Read)]
|
|
462
|
-
public async Task<ActionResult<{Name}ResponseDto>> GetById(Guid id, CancellationToken ct)
|
|
463
|
-
{
|
|
464
|
-
var result = await _service.GetByIdAsync(id, ct);
|
|
465
|
-
return result is null ? NotFound() : Ok(result);
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
[HttpPost]
|
|
469
|
-
[RequirePermission(Permissions.{Module}.Create)]
|
|
470
|
-
public async Task<ActionResult<{Name}ResponseDto>> Create([FromBody] Create{Name}Dto dto, CancellationToken ct)
|
|
471
|
-
{
|
|
472
|
-
var result = await _service.CreateAsync(dto, ct);
|
|
473
|
-
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
[HttpPut("{id:guid}")]
|
|
477
|
-
[RequirePermission(Permissions.{Module}.Update)]
|
|
478
|
-
public async Task<ActionResult<{Name}ResponseDto>> Update(Guid id, [FromBody] Update{Name}Dto dto, CancellationToken ct)
|
|
479
|
-
{
|
|
480
|
-
var result = await _service.UpdateAsync(id, dto, ct);
|
|
481
|
-
return result is null ? NotFound() : Ok(result);
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
[HttpDelete("{id:guid}")]
|
|
485
|
-
[RequirePermission(Permissions.{Module}.Delete)]
|
|
486
|
-
public async Task<ActionResult> Delete(Guid id, CancellationToken ct)
|
|
487
|
-
{
|
|
488
|
-
await _service.DeleteAsync(id, ct);
|
|
489
|
-
return NoContent();
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
```
|
|
493
|
-
|
|
494
|
-
**CRITICAL — Route attribute rules:**
|
|
495
|
-
- `[NavRoute]` is the ONLY route attribute needed — it resolves routes dynamically from Navigation entities at startup
|
|
496
|
-
- **FORBIDDEN:** `[Route("api/...")]` alongside `[NavRoute]` — causes route conflicts and 404s at runtime
|
|
497
|
-
- **FORBIDDEN:** `[Route("api/[controller]")]` — this is standard ASP.NET Core, NOT SmartStack
|
|
498
|
-
- If a controller has `[NavRoute]`, there must be NO `[Route]` attribute on the class
|
|
499
|
-
|
|
500
|
-
**CRITICAL:** Use `[RequirePermission(Permissions.{Module}.{Action})]` on EVERY endpoint — NEVER `[Authorize]` alone (no RBAC enforcement).
|
|
501
|
-
|
|
502
|
-
**CRITICAL — Permission paths use IDENTICAL segments to NavRoute codes (kebab-case):**
|
|
503
|
-
- NavRoute: `human-resources.employees` → Permission: `human-resources.employees.read`
|
|
504
|
-
- NavRoute: `human-resources.employees.leaves` → Permission: `human-resources.employees.leaves.read`
|
|
505
|
-
- FORBIDDEN: `humanresources.employees.read` (no kebab-case — mismatches NavRoute)
|
|
506
|
-
- SmartStack.app convention: `support-client.my-tickets.read` (always kebab-case)
|
|
507
|
-
|
|
508
|
-
### Section-Level Controller (NavRoute with 4 segments)
|
|
509
|
-
|
|
510
|
-
When a module has sections, each section gets its own controller with a 4-segment navRoute:
|
|
511
|
-
|
|
512
|
-
```csharp
|
|
513
|
-
// Section-level controller: navRoute has 4 segments
|
|
514
|
-
[ApiController]
|
|
515
|
-
[NavRoute("{app}.{module}.{section}")]
|
|
516
|
-
[Authorize]
|
|
517
|
-
public class {Section}Controller : ControllerBase
|
|
518
|
-
{
|
|
519
|
-
// Example: human-resources.employees.departments
|
|
520
|
-
[HttpGet]
|
|
521
|
-
[RequirePermission(Permissions.{Section}.Read)]
|
|
522
|
-
public async Task<ActionResult<PaginatedResult<{Section}ResponseDto>>> GetAll(
|
|
523
|
-
[FromQuery] string? search = null,
|
|
524
|
-
[FromQuery] int page = 1,
|
|
525
|
-
[FromQuery] int pageSize = 20,
|
|
526
|
-
CancellationToken ct = default)
|
|
527
|
-
=> Ok(await _service.GetAllAsync(search, page, pageSize, ct));
|
|
528
|
-
}
|
|
529
|
-
```
|
|
530
|
-
|
|
531
|
-
**NavRoute segment rules:**
|
|
532
|
-
| Level | NavRoute format | Example |
|
|
533
|
-
|-------|----------------|---------|
|
|
534
|
-
| Module | `{app}.{module}` (2 segments) | `human-resources.employees` |
|
|
535
|
-
| Section | `{app}.{module}.{section}` (3 segments) | `human-resources.employees.departments` |
|
|
536
|
-
|
|
537
|
-
**Namespace:** `SmartStack.Api.Routing` (NOT `SmartStack.Api.Core.Routing`)
|
|
538
|
-
|
|
539
|
-
**NavRoute resolves at startup from DB:** `administration.users` → `api/administration/users`
|
|
540
|
-
|
|
541
|
-
### Sub-Resource Pattern (NavRoute Suffix)
|
|
542
|
-
|
|
543
|
-
When an entity is a child of another entity (e.g., LeaveTypes under Leaves), use `[NavRoute(..., Suffix = "types")]`:
|
|
544
|
-
|
|
545
|
-
```csharp
|
|
546
|
-
// Sub-resource controller: types are nested under leaves
|
|
547
|
-
[ApiController]
|
|
548
|
-
[NavRoute("human-resources.employees.leaves", Suffix = "types")]
|
|
549
|
-
[Authorize]
|
|
550
|
-
public class LeaveTypesController : ControllerBase
|
|
551
|
-
{
|
|
552
|
-
[HttpGet]
|
|
553
|
-
[RequirePermission(Permissions.Leaves.Read)] // inherits parent section permission
|
|
554
|
-
public async Task<ActionResult<PaginatedResult<LeaveTypeResponseDto>>> GetAll(...)
|
|
555
|
-
=> Ok(await _service.GetAllAsync(search, page, pageSize, ct));
|
|
556
|
-
}
|
|
557
|
-
```
|
|
558
|
-
|
|
559
|
-
**Alternative pattern** (sub-resource endpoints within parent controller):
|
|
560
|
-
```csharp
|
|
561
|
-
// LeaveTypes as endpoints within LeavesController
|
|
562
|
-
[HttpGet("types")]
|
|
563
|
-
[RequirePermission(Permissions.Leaves.Read)]
|
|
564
|
-
public async Task<ActionResult<PaginatedResult<LeaveTypeResponseDto>>> GetAllLeaveTypes(...)
|
|
565
|
-
```
|
|
566
|
-
|
|
567
|
-
> **CRITICAL — Sub-resource frontend completeness:**
|
|
568
|
-
> If a parent page has a button (e.g., "Manage Leave Types") that `navigate()`s to a sub-resource route,
|
|
569
|
-
> the frontend MUST include a page component for that route. Otherwise → dead link → white screen.
|
|
570
|
-
> - Either create a dedicated sub-resource ListPage (e.g., `LeaveTypesPage.tsx`)
|
|
571
|
-
> - Or DON'T include the navigate() button if pages won't be created
|
|
572
|
-
> - **Prefer separate controllers** (with Suffix) over sub-endpoints in parent controller — easier to route
|
|
573
|
-
|
|
574
|
-
---
|
|
575
|
-
|
|
576
|
-
## Navigation Seed Data Pattern (CRITICAL — routes must be full paths)
|
|
577
|
-
|
|
578
|
-
> **The navigation seed data defines menu routes stored in DB. These routes MUST be full paths starting with `/`.**
|
|
579
|
-
> Short routes (e.g., `humanresources`) cause 400 Bad Request on application-tracking.
|
|
580
|
-
|
|
581
|
-
### Route Convention
|
|
582
|
-
|
|
583
|
-
| Level | Route Format | Example |
|
|
584
|
-
|-------|-------------|---------|
|
|
585
|
-
| Application | `/{app-kebab}` | `/human-resources` |
|
|
586
|
-
| Module | `/{app-kebab}/{module-kebab}` | `/human-resources/employees` |
|
|
587
|
-
| Section | `/{app-kebab}/{module-kebab}/{section-kebab}` | `/human-resources/employees/departments` |
|
|
588
|
-
| Resource | `/{app-kebab}/{module-kebab}/{section-kebab}/{resource-kebab}` | `/human-resources/employees/departments/export` |
|
|
589
|
-
|
|
590
|
-
**ROUTE SPECIAL CASES (list and detail sections):**
|
|
591
|
-
> The `list` and `detail` sections are NOT functional sub-areas — they are view modes of the module itself.
|
|
592
|
-
> Their navigation routes MUST NOT add extra segments:
|
|
593
|
-
> - `list` section route = module route (e.g., `/human-resources/employees`)
|
|
594
|
-
> - `detail` section route = module route + `/:id` (e.g., `/human-resources/employees/:id`)
|
|
595
|
-
> - FORBIDDEN: `/employees/list`, `/employees/detail/:id`
|
|
596
|
-
> - Other sections (dashboard, approve, import, etc.) = module route + `/{section-kebab}` (normal behavior)
|
|
597
|
-
|
|
598
|
-
**Rules:**
|
|
599
|
-
- Routes ALWAYS start with `/`
|
|
600
|
-
- Routes ALWAYS include the full hierarchy from application to current level
|
|
601
|
-
- Routes ALWAYS use kebab-case (NOT PascalCase, NOT camelCase)
|
|
602
|
-
- Code identifiers stay PascalCase in C# (`HumanResources`) but routes are kebab-case (`human-resources`)
|
|
603
|
-
|
|
604
|
-
### ToKebabCase Helper (include in SeedConstants or SeedDataProvider)
|
|
605
|
-
|
|
606
|
-
```csharp
|
|
607
|
-
private static string ToKebabCase(string value)
|
|
608
|
-
=> System.Text.RegularExpressions.Regex
|
|
609
|
-
.Replace(value, "([a-z])([A-Z])", "$1-$2")
|
|
610
|
-
.ToLowerInvariant();
|
|
611
|
-
```
|
|
612
|
-
|
|
613
|
-
### SeedConstants Pattern
|
|
614
|
-
|
|
615
|
-
```csharp
|
|
616
|
-
public static class SeedConstants
|
|
617
|
-
{
|
|
618
|
-
// Deterministic GUIDs (SHA256-based, reproducible across environments)
|
|
619
|
-
// NOTE: Application/Module/Section/Resource IDs are deterministic.
|
|
620
|
-
public static readonly Guid ApplicationId = DeterministicGuid("nav:human-resources");
|
|
621
|
-
public static readonly Guid ModuleId = DeterministicGuid("nav:human-resources.employees");
|
|
622
|
-
public static readonly Guid SectionId = DeterministicGuid("nav:human-resources.employees.departments");
|
|
623
|
-
|
|
624
|
-
private static Guid DeterministicGuid(string input)
|
|
625
|
-
{
|
|
626
|
-
var hash = System.Security.Cryptography.SHA256.HashData(
|
|
627
|
-
System.Text.Encoding.UTF8.GetBytes(input));
|
|
628
|
-
var bytes = new byte[16];
|
|
629
|
-
Array.Copy(hash, bytes, 16);
|
|
630
|
-
bytes[6] = (byte)((bytes[6] & 0x0F) | 0x50); // version 5
|
|
631
|
-
bytes[8] = (byte)((bytes[8] & 0x3F) | 0x80); // variant
|
|
632
|
-
return new Guid(bytes);
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
```
|
|
636
|
-
|
|
637
|
-
### Navigation Seed Data Example
|
|
638
|
-
|
|
639
|
-
```csharp
|
|
640
|
-
// Application: /human-resources
|
|
641
|
-
var app = NavigationApplication.Create(
|
|
642
|
-
"human-resources", "Human Resources", "HR Management",
|
|
643
|
-
"Users", IconType.Lucide,
|
|
644
|
-
"/human-resources", // FULL PATH — starts with /, kebab-case
|
|
645
|
-
10);
|
|
646
|
-
|
|
647
|
-
// Module: /human-resources/employees
|
|
648
|
-
var module = NavigationModule.Create(
|
|
649
|
-
app.Id, "employees", "Employees", "Employee management",
|
|
650
|
-
"UserCheck", IconType.Lucide,
|
|
651
|
-
"/human-resources/employees", // FULL PATH — includes parent
|
|
652
|
-
10);
|
|
653
|
-
|
|
654
|
-
// Section: /human-resources/employees/departments
|
|
655
|
-
var section = NavigationSection.Create(
|
|
656
|
-
module.Id, "departments", "Departments", "Manage departments",
|
|
657
|
-
"Building2", IconType.Lucide,
|
|
658
|
-
"/human-resources/employees/departments", // FULL PATH
|
|
659
|
-
10);
|
|
660
|
-
```
|
|
661
|
-
|
|
662
|
-
### FORBIDDEN in Seed Data
|
|
663
|
-
|
|
664
|
-
| Mistake | Reality |
|
|
665
|
-
|---------|---------|
|
|
666
|
-
| `"humanresources"` as route | Must be `"/human-resources"` (full path, kebab-case) |
|
|
667
|
-
| `"employees"` as route | Must be `"/human-resources/employees"` (includes parent) |
|
|
668
|
-
| `Guid.NewGuid()` in seed data | Must use deterministic GUIDs (SHA256) |
|
|
669
|
-
| Missing translations | Must have 4 languages: fr, en, it, de |
|
|
670
|
-
| Missing NavigationApplicationSeedData | Menu invisible without Application level |
|
|
671
|
-
|
|
672
|
-
---
|
|
673
|
-
|
|
674
|
-
## DbContext Pattern (extensions)
|
|
675
|
-
|
|
676
|
-
```csharp
|
|
677
|
-
// In IExtensionsDbContext.cs:
|
|
678
|
-
public DbSet<{Name}> {Name}s => Set<{Name}>();
|
|
679
|
-
|
|
680
|
-
// In ExtensionsDbContext.cs (same line):
|
|
681
|
-
public DbSet<{Name}> {Name}s => Set<{Name}>();
|
|
682
|
-
```
|
|
683
|
-
|
|
684
|
-
---
|
|
685
|
-
|
|
686
|
-
## DI Registration Pattern
|
|
687
|
-
|
|
688
|
-
```csharp
|
|
689
|
-
// In DependencyInjection.cs or ServiceCollectionExtensions.cs:
|
|
690
|
-
services.AddScoped<I{Name}Service, {Name}Service>();
|
|
691
|
-
services.AddValidatorsFromAssemblyContaining<Create{Name}DtoValidator>();
|
|
692
|
-
```
|
|
693
|
-
|
|
694
|
-
---
|
|
695
|
-
|
|
696
|
-
## DTO Type Mapping (CRITICAL)
|
|
697
|
-
|
|
698
|
-
> **Use the correct .NET type for each property.** Incorrect types cause runtime parsing errors.
|
|
699
|
-
|
|
700
|
-
| Property Pattern | .NET Type | JSON Format | Example |
|
|
701
|
-
|-----------------|-----------|-------------|---------|
|
|
702
|
-
| `*Date`, `StartDate`, `EndDate`, `BirthDate` | `DateOnly` | `"2025-03-15"` | `public DateOnly Date { get; set; }` |
|
|
703
|
-
| `CreatedAt`, `UpdatedAt` | `DateTime` | `"2025-03-15T10:30:00Z"` | `public DateTime CreatedAt { get; set; }` |
|
|
704
|
-
| `*Time`, `StartTime` | `TimeOnly` | `"14:30:00"` | `public TimeOnly StartTime { get; set; }` |
|
|
705
|
-
| Duration, hours | `decimal` | `8.5` | `public decimal HoursWorked { get; set; }` |
|
|
706
|
-
| FK reference | `Guid` | `"uuid-string"` | `public Guid EmployeeId { get; set; }` |
|
|
707
|
-
|
|
708
|
-
**FORBIDDEN in DTOs:**
|
|
709
|
-
- `string Date` / `string StartDate` — use `DateOnly`
|
|
710
|
-
- `string Time` — use `TimeOnly`
|
|
711
|
-
- `DateTime BirthDate` — use `DateOnly` (no time component needed)
|
|
712
|
-
- `int` for hours/duration — use `decimal` for fractional values
|
|
713
|
-
|
|
714
|
-
---
|
|
715
|
-
|
|
716
|
-
## Common Mistakes to Avoid
|
|
717
|
-
|
|
718
|
-
| Mistake | Reality |
|
|
719
|
-
|---------|---------|
|
|
720
|
-
| `entity.SoftDelete()` | Does NOT exist — no soft delete in BaseEntity |
|
|
721
|
-
| `entity.Code` inherited | Code is a business property — add it yourself |
|
|
722
|
-
| `e.RowVersion` in config | Does NOT exist in BaseEntity |
|
|
723
|
-
| `e.IsDeleted` filter | Does NOT exist — no soft delete |
|
|
724
|
-
| `SmartStack.Api.Core.Routing` | Wrong — use `SmartStack.Api.Routing` |
|
|
725
|
-
| `SystemEntity` base class | Does NOT exist — use `BaseEntity` for all |
|
|
726
|
-
| `[Route("api/...")] + [NavRoute]` | **FORBIDDEN** — causes 404s. Only `[NavRoute]` needed (resolves route from DB at startup). Remove ALL `[Route]` attributes when `[NavRoute]` is present. |
|
|
727
|
-
| `SmartStack.Domain.Common.Interfaces` | Wrong — interfaces are in `SmartStack.Domain.Common` directly |
|
|
728
|
-
| `[Authorize]` without `[RequirePermission]` | No RBAC enforcement — always use `[RequirePermission]` |
|
|
729
|
-
| `tenantId: Guid.Empty` in services | OWASP A01 — always use validated `_currentTenant.TenantId` |
|
|
730
|
-
| Service without `ICurrentTenantService` | All tenant data leaks — inject `ICurrentTenantService` |
|
|
731
|
-
| `ICurrentUser` in service code | Does NOT exist — use `ICurrentUserService` + `ICurrentTenantService` |
|
|
732
|
-
| `_currentTenant.TenantId!.Value` | Crashes with 500 — use `?? throw new TenantContextRequiredException()` |
|
|
733
|
-
| `UnauthorizedAccessException("Tenant context is required")` | Returns 401 → clears frontend token. Use `TenantContextRequiredException()` (400) |
|
|
734
|
-
| Route `"humanresources"` in seed data | Must be full path `"/human-resources"` |
|
|
735
|
-
| Route without leading `/` | All routes must start with `/` |
|
|
736
|
-
| `humanresources.employees.read` in permissions | Permission segments MUST match NavRoute kebab-case: `human-resources.employees.read` |
|
|
737
|
-
| `Permission.Create()` | Does NOT exist — use `CreateForModule()`, `CreateForSection()`, etc. |
|
|
738
|
-
| `GetAllAsync()` without search param | ALL GetAll endpoints MUST support `?search=` for EntityLookup |
|
|
739
|
-
| `string Date` in DTO | Date-only fields MUST use `DateOnly`, NEVER `string` |
|
|
740
|
-
| `DateTime` for date-only | Use `DateOnly` when no time component needed |
|
|
741
|
-
| FK field as plain text input | Frontend MUST use `EntityLookup` component for Guid FK fields |
|
|
742
|
-
| `PagedResult<T>` / `PaginatedResultDto<T>` | FORBIDDEN — use `PaginatedResult<T>` only |
|
|
743
|
-
|
|
744
|
-
---
|
|
745
|
-
|
|
746
|
-
## PaginatedResult Pattern
|
|
747
|
-
|
|
748
|
-
> **Canonical type for ALL paginated responses.** One name, one contract, everywhere.
|
|
749
|
-
|
|
750
|
-
### Definition (Backend — `SmartStack.Application.Common.Models`)
|
|
751
|
-
|
|
752
|
-
```csharp
|
|
753
|
-
namespace SmartStack.Application.Common.Models;
|
|
754
|
-
|
|
755
|
-
public record PaginatedResult<T>(
|
|
756
|
-
List<T> Items,
|
|
757
|
-
int TotalCount,
|
|
758
|
-
int Page,
|
|
759
|
-
int PageSize)
|
|
760
|
-
{
|
|
761
|
-
public int TotalPages => PageSize > 0
|
|
762
|
-
? (int)Math.Ceiling((double)TotalCount / PageSize) : 0;
|
|
763
|
-
public bool HasPreviousPage => Page > 1;
|
|
764
|
-
public bool HasNextPage => Page < TotalPages;
|
|
765
|
-
|
|
766
|
-
public static PaginatedResult<T> Empty(int page = 1, int pageSize = 20)
|
|
767
|
-
=> new([], 0, page, pageSize);
|
|
768
|
-
}
|
|
769
|
-
```
|
|
770
|
-
|
|
771
|
-
### Extension Method
|
|
772
|
-
|
|
773
|
-
```csharp
|
|
774
|
-
namespace SmartStack.Application.Common.Extensions;
|
|
775
|
-
|
|
776
|
-
public static class QueryableExtensions
|
|
777
|
-
{
|
|
778
|
-
public const int MaxPageSize = 100;
|
|
779
|
-
|
|
780
|
-
public static async Task<PaginatedResult<T>> ToPaginatedResultAsync<T>(
|
|
781
|
-
this IQueryable<T> query,
|
|
782
|
-
int page = 1,
|
|
783
|
-
int pageSize = 20,
|
|
784
|
-
CancellationToken ct = default)
|
|
785
|
-
{
|
|
786
|
-
page = Math.Max(1, page);
|
|
787
|
-
pageSize = Math.Clamp(pageSize, 1, MaxPageSize);
|
|
788
|
-
|
|
789
|
-
var totalCount = await query.CountAsync(ct);
|
|
790
|
-
var items = await query
|
|
791
|
-
.Skip((page - 1) * pageSize)
|
|
792
|
-
.Take(pageSize)
|
|
793
|
-
.ToListAsync(ct);
|
|
794
|
-
|
|
795
|
-
return new PaginatedResult<T>(items, totalCount, page, pageSize);
|
|
796
|
-
}
|
|
797
|
-
}
|
|
798
|
-
```
|
|
799
|
-
|
|
800
|
-
### Usage in Service (with search + extension method)
|
|
801
|
-
|
|
802
|
-
```csharp
|
|
803
|
-
public async Task<PaginatedResult<{Name}ResponseDto>> GetAllAsync(
|
|
804
|
-
string? search = null, int page = 1, int pageSize = 20, CancellationToken ct = default)
|
|
805
|
-
{
|
|
806
|
-
var tenantId = _currentTenant.TenantId
|
|
807
|
-
?? throw new TenantContextRequiredException();
|
|
808
|
-
|
|
809
|
-
var query = _db.{Name}s
|
|
810
|
-
.Where(x => x.TenantId == tenantId)
|
|
811
|
-
.AsNoTracking();
|
|
812
|
-
|
|
813
|
-
if (!string.IsNullOrWhiteSpace(search))
|
|
814
|
-
query = query.Where(x => x.Name.Contains(search) || x.Code.Contains(search));
|
|
815
|
-
|
|
816
|
-
return await query
|
|
817
|
-
.OrderBy(x => x.Name)
|
|
818
|
-
.Select(x => new {Name}ResponseDto(x.Id, x.Code, x.Name, x.CreatedAt))
|
|
819
|
-
.ToPaginatedResultAsync(page, pageSize, ct);
|
|
820
|
-
}
|
|
821
|
-
```
|
|
822
|
-
|
|
823
|
-
### Frontend Types (`@/types/pagination.ts`)
|
|
824
|
-
|
|
825
|
-
```typescript
|
|
826
|
-
export interface PaginatedResult<T> {
|
|
827
|
-
items: T[];
|
|
828
|
-
totalCount: number;
|
|
829
|
-
page: number;
|
|
830
|
-
pageSize: number;
|
|
831
|
-
totalPages: number;
|
|
832
|
-
hasPreviousPage: boolean;
|
|
833
|
-
hasNextPage: boolean;
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
export interface PaginationParams {
|
|
837
|
-
page?: number;
|
|
838
|
-
pageSize?: number;
|
|
839
|
-
search?: string;
|
|
840
|
-
sortBy?: string;
|
|
841
|
-
sortDirection?: 'asc' | 'desc';
|
|
842
|
-
}
|
|
843
|
-
```
|
|
844
|
-
|
|
845
|
-
### FORBIDDEN Type Names
|
|
846
|
-
|
|
847
|
-
| Forbidden | Canonical Replacement |
|
|
848
|
-
|-----------|----------------------|
|
|
849
|
-
| `PagedResult<T>` | `PaginatedResult<T>` |
|
|
850
|
-
| `PaginatedResultDto<T>` | `PaginatedResult<T>` |
|
|
851
|
-
| `PaginatedResponse<T>` | `PaginatedResult<T>` |
|
|
852
|
-
| `PageResultDto<T>` | `PaginatedResult<T>` |
|
|
853
|
-
| `PaginatedRequest` | `PaginationParams` |
|
|
854
|
-
| `QueryParameters` | `PaginationParams` |
|
|
855
|
-
| `currentPage` (property) | `page` |
|
|
856
|
-
| `HasPrevious` (property) | `HasPreviousPage` |
|
|
857
|
-
| `HasNext` (property) | `HasNextPage` |
|
|
858
|
-
|
|
859
|
-
### Rules
|
|
860
|
-
|
|
861
|
-
- **Max pageSize = 100** — enforced via `Math.Clamp(pageSize, 1, 100)` or extension method
|
|
862
|
-
- **Default page = 1, pageSize = 20** — all GetAll endpoints
|
|
863
|
-
- **Search param mandatory** — enables `EntityLookup` on frontend
|
|
864
|
-
- **POST-CHECK 16** blocks `List<T>` returns on GetAll
|
|
865
|
-
- **POST-CHECK 31** blocks non-canonical pagination type names
|
|
866
|
-
|
|
867
|
-
---
|
|
868
|
-
|
|
869
|
-
## Critical Anti-Patterns (with code examples)
|
|
870
|
-
|
|
871
|
-
> **These are the most common and dangerous mistakes.** Each one has been observed in production code generation.
|
|
872
|
-
|
|
873
|
-
### Anti-Pattern 1: HasQueryFilter with `!= Guid.Empty` (SECURITY — OWASP A01)
|
|
874
|
-
|
|
875
|
-
The `HasQueryFilter` in EF Core should use **runtime tenant resolution**, NOT a static comparison against `Guid.Empty`.
|
|
876
|
-
|
|
877
|
-
**INCORRECT — Does NOT isolate tenants:**
|
|
878
|
-
```csharp
|
|
879
|
-
// WRONG: This only excludes empty GUIDs — ALL tenant data is still visible to everyone!
|
|
880
|
-
public void Configure(EntityTypeBuilder<MyEntity> builder)
|
|
881
|
-
{
|
|
882
|
-
builder.HasQueryFilter(e => e.TenantId != Guid.Empty);
|
|
883
|
-
}
|
|
884
|
-
```
|
|
885
|
-
|
|
886
|
-
**CORRECT — Tenant isolation via service:**
|
|
887
|
-
```csharp
|
|
888
|
-
// CORRECT: In SmartStack, tenant filtering is done in the SERVICE layer, not via HasQueryFilter.
|
|
889
|
-
public async Task<PaginatedResult<MyEntityDto>> GetAllAsync(...)
|
|
890
|
-
{
|
|
891
|
-
var query = _db.MyEntities
|
|
892
|
-
.Where(x => x.TenantId == _currentUser.TenantId) // MANDATORY runtime filter
|
|
893
|
-
.AsNoTracking();
|
|
894
|
-
// ...
|
|
895
|
-
}
|
|
896
|
-
```
|
|
897
|
-
|
|
898
|
-
**Why it's wrong:** `HasQueryFilter(e => e.TenantId != Guid.Empty)` is a **static filter** — it only removes records with empty GUIDs. It does NOT restrict data to the current tenant. This is an **OWASP A01 Broken Access Control** vulnerability.
|
|
899
|
-
|
|
900
|
-
---
|
|
901
|
-
|
|
902
|
-
### Anti-Pattern 2: `List<T>` instead of `PaginatedResult<T>` for GetAll
|
|
903
|
-
|
|
904
|
-
**INCORRECT — No pagination:**
|
|
905
|
-
```csharp
|
|
906
|
-
// WRONG: Returns all records at once — no pagination, no totalCount
|
|
907
|
-
public async Task<List<MyEntityDto>> GetAllAsync(CancellationToken ct)
|
|
908
|
-
{
|
|
909
|
-
return await _db.MyEntities
|
|
910
|
-
.Where(x => x.TenantId == _currentUser.TenantId)
|
|
911
|
-
.Select(x => new MyEntityDto(x.Id, x.Code, x.Name))
|
|
912
|
-
.ToListAsync(ct);
|
|
913
|
-
}
|
|
914
|
-
```
|
|
915
|
-
|
|
916
|
-
**CORRECT — Paginated with search:**
|
|
917
|
-
```csharp
|
|
918
|
-
// CORRECT: Returns PaginatedResult<T> with search, page, pageSize
|
|
919
|
-
public async Task<PaginatedResult<MyEntityDto>> GetAllAsync(
|
|
920
|
-
string? search = null, int page = 1, int pageSize = 20, CancellationToken ct = default)
|
|
921
|
-
{
|
|
922
|
-
var query = _db.MyEntities.Where(x => x.TenantId == _currentUser.TenantId).AsNoTracking();
|
|
923
|
-
if (!string.IsNullOrWhiteSpace(search))
|
|
924
|
-
query = query.Where(x => x.Name.Contains(search) || x.Code.Contains(search));
|
|
925
|
-
var totalCount = await query.CountAsync(ct);
|
|
926
|
-
var items = await query.OrderBy(x => x.Name).Skip((page - 1) * pageSize).Take(pageSize)
|
|
927
|
-
.Select(x => new MyEntityDto(x.Id, x.Code, x.Name, x.CreatedAt)).ToListAsync(ct);
|
|
928
|
-
return new PaginatedResult<MyEntityDto>(items, totalCount, page, pageSize);
|
|
929
|
-
}
|
|
930
|
-
```
|
|
931
|
-
|
|
932
|
-
**Why it's wrong:** `List<T>` loads ALL records into memory. It also breaks `EntityLookup` which requires `{ items, totalCount }` response format.
|
|
933
|
-
|
|
934
|
-
---
|
|
935
|
-
|
|
936
|
-
### Anti-Pattern 3: Missing `IAuditableEntity` on tenant entities
|
|
937
|
-
|
|
938
|
-
**INCORRECT — No audit trail:**
|
|
939
|
-
```csharp
|
|
940
|
-
// WRONG: Tenant entity without IAuditableEntity
|
|
941
|
-
public class MyEntity : BaseEntity, ITenantEntity
|
|
942
|
-
{
|
|
943
|
-
public Guid TenantId { get; private set; }
|
|
944
|
-
public string Code { get; private set; } = null!;
|
|
945
|
-
}
|
|
946
|
-
```
|
|
947
|
-
|
|
948
|
-
**CORRECT — Always pair ITenantEntity with IAuditableEntity:**
|
|
949
|
-
```csharp
|
|
950
|
-
public class MyEntity : BaseEntity, ITenantEntity, IAuditableEntity
|
|
951
|
-
{
|
|
952
|
-
public Guid TenantId { get; private set; }
|
|
953
|
-
public string? CreatedBy { get; set; }
|
|
954
|
-
public string? UpdatedBy { get; set; }
|
|
955
|
-
public string Code { get; private set; } = null!;
|
|
956
|
-
}
|
|
957
|
-
```
|
|
958
|
-
|
|
959
|
-
**Why it's wrong:** Without `IAuditableEntity`, there is no record of who created or modified data. Mandatory for compliance in multi-tenant environments.
|
|
960
|
-
|
|
961
|
-
---
|
|
962
|
-
|
|
963
|
-
### Anti-Pattern 4: Code auto-generation with `Count() + 1`
|
|
964
|
-
|
|
965
|
-
**INCORRECT — Race condition:**
|
|
966
|
-
```csharp
|
|
967
|
-
// WRONG: Two concurrent requests get the same count
|
|
968
|
-
var count = await _db.MyEntities.Where(x => x.TenantId == tenantId).CountAsync(ct);
|
|
969
|
-
return $"emp-{(count + 1):D5}";
|
|
970
|
-
```
|
|
971
|
-
|
|
972
|
-
**CORRECT — Use `ICodeGenerator<T>.NextCodeAsync()` (atomic with retry):**
|
|
973
|
-
```csharp
|
|
974
|
-
private readonly ICodeGenerator<MyEntity> _codeGenerator;
|
|
975
|
-
|
|
976
|
-
// In CreateAsync:
|
|
977
|
-
var code = await _codeGenerator.NextCodeAsync(ct);
|
|
978
|
-
var entity = MyEntity.Create(tenantId, code, dto.Name, createdBy: null);
|
|
979
|
-
```
|
|
980
|
-
|
|
981
|
-
**Why it's wrong:** `Count() + 1` causes **race conditions** — concurrent requests generate duplicate codes. `ICodeGenerator<T>` uses `OrderByDescending` on existing codes + retry on unique constraint violation for safe concurrency.
|
|
982
|
-
|
|
983
|
-
**Key rules when using auto-generated codes:**
|
|
984
|
-
- **REMOVE** `Code` from `CreateDto` (auto-generated, not user-provided)
|
|
985
|
-
- **KEEP** `Code` in `ResponseDto` (returned to frontend)
|
|
986
|
-
- Register `ICodeGenerator<T>` in DI with `CodePatternConfig`
|
|
987
|
-
- Code regex in validators: `^[a-z0-9_-]+$` (supports hyphens)
|
|
988
|
-
|
|
989
|
-
**Full reference:** See `references/code-generation.md` for strategies (sequential, timestamp, yearly, UUID), volume-to-digits calculation, and complete implementation patterns.
|
|
990
|
-
|
|
991
|
-
---
|
|
992
|
-
|
|
993
|
-
### Anti-Pattern 5: Missing Update validator
|
|
994
|
-
|
|
995
|
-
**INCORRECT — Only CreateValidator:**
|
|
996
|
-
```csharp
|
|
997
|
-
public class CreateMyEntityDtoValidator : AbstractValidator<CreateMyEntityDto>
|
|
998
|
-
{
|
|
999
|
-
public CreateMyEntityDtoValidator()
|
|
1000
|
-
{
|
|
1001
|
-
RuleFor(x => x.Code).NotEmpty().MaximumLength(100);
|
|
1002
|
-
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
|
|
1003
|
-
}
|
|
1004
|
-
}
|
|
1005
|
-
// No UpdateMyEntityDtoValidator exists!
|
|
1006
|
-
```
|
|
1007
|
-
|
|
1008
|
-
**CORRECT — Always create validators in pairs:**
|
|
1009
|
-
```csharp
|
|
1010
|
-
public class CreateMyEntityDtoValidator : AbstractValidator<CreateMyEntityDto> { /* ... */ }
|
|
1011
|
-
public class UpdateMyEntityDtoValidator : AbstractValidator<UpdateMyEntityDto>
|
|
1012
|
-
{
|
|
1013
|
-
public UpdateMyEntityDtoValidator()
|
|
1014
|
-
{
|
|
1015
|
-
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
|
|
1016
|
-
}
|
|
1017
|
-
}
|
|
1018
|
-
```
|
|
1019
|
-
|
|
1020
|
-
**Why it's wrong:** Without an `UpdateValidator`, the Update endpoint accepts **any data without validation**.
|
|
1021
|
-
|
|
1022
|
-
---
|
|
1023
|
-
|
|
1024
|
-
### Anti-Pattern 6: `TenantId!.Value` null-forgiving operator (RUNTIME CRASH)
|
|
1025
|
-
|
|
1026
|
-
The `!` (null-forgiving) operator followed by `.Value` on a `Guid?` suppresses compiler warnings but **throws `InvalidOperationException` at runtime** when TenantId is null.
|
|
1027
|
-
|
|
1028
|
-
**INCORRECT — Crashes with 500 Internal Server Error:**
|
|
1029
|
-
```csharp
|
|
1030
|
-
// WRONG: Throws InvalidOperationException("Nullable object must have a value") → 500 error
|
|
1031
|
-
public async Task<PaginatedResult<MyEntityDto>> GetAllAsync(...)
|
|
1032
|
-
{
|
|
1033
|
-
var tenantId = _currentTenant.TenantId!.Value; // CRASH if no tenant context
|
|
1034
|
-
var query = _db.MyEntities.Where(x => x.TenantId == tenantId);
|
|
1035
|
-
// ...
|
|
1036
|
-
}
|
|
1037
|
-
```
|
|
1038
|
-
|
|
1039
|
-
**CORRECT — Clean 400 via GlobalExceptionHandlerMiddleware:**
|
|
1040
|
-
```csharp
|
|
1041
|
-
// CORRECT: Throws TenantContextRequiredException → middleware converts to 400 Bad Request
|
|
1042
|
-
public async Task<PaginatedResult<MyEntityDto>> GetAllAsync(...)
|
|
1043
|
-
{
|
|
1044
|
-
var tenantId = _currentTenant.TenantId
|
|
1045
|
-
?? throw new TenantContextRequiredException();
|
|
1046
|
-
var query = _db.MyEntities.Where(x => x.TenantId == tenantId);
|
|
1047
|
-
// ...
|
|
1048
|
-
}
|
|
1049
|
-
```
|
|
1050
|
-
|
|
1051
|
-
**Why `!.Value` is wrong:** When a user hits an API via Swagger with a valid JWT but no tenant context (missing `X-Tenant-Slug` header), `TenantId` is null. The `!.Value` pattern produces an opaque `500 Internal Server Error` instead of a clear `400 Bad Request` with an actionable message.
|
|
1052
|
-
|
|
1053
|
-
**Why `UnauthorizedAccessException` is wrong:** A missing tenant is NOT an auth failure — the JWT is valid, `[Authorize]` passed. Using `UnauthorizedAccessException` returns 401, which triggers the frontend interceptor to clear the token and redirect to login. Use `TenantContextRequiredException` instead (returns 400, does not clear the token).
|
|
1
|
+
# SmartStack Domain API Reference
|
|
2
|
+
|
|
3
|
+
> **Source of truth:** `SmartStack.app/src/SmartStack.Domain/Common/`
|
|
4
|
+
> **Loaded by:** step-01 (analyze), step-03 (execute)
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## BaseEntity
|
|
9
|
+
|
|
10
|
+
```csharp
|
|
11
|
+
namespace SmartStack.Domain.Common;
|
|
12
|
+
|
|
13
|
+
public abstract class BaseEntity
|
|
14
|
+
{
|
|
15
|
+
public Guid Id { get; set; }
|
|
16
|
+
public DateTime CreatedAt { get; set; }
|
|
17
|
+
public DateTime? UpdatedAt { get; set; }
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
**ONLY 3 properties.** No Code, no IsDeleted, no RowVersion, no SoftDelete, no CreatedBy/UpdatedBy.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Interfaces
|
|
26
|
+
|
|
27
|
+
### ITenantEntity (mandatory tenant isolation)
|
|
28
|
+
|
|
29
|
+
```csharp
|
|
30
|
+
public interface ITenantEntity
|
|
31
|
+
{
|
|
32
|
+
Guid TenantId { get; }
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### IAuditableEntity (audit trail)
|
|
37
|
+
|
|
38
|
+
```csharp
|
|
39
|
+
public interface IAuditableEntity
|
|
40
|
+
{
|
|
41
|
+
string? CreatedBy { get; set; }
|
|
42
|
+
string? UpdatedBy { get; set; }
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### IOptionalTenantEntity (nullable tenant)
|
|
47
|
+
|
|
48
|
+
```csharp
|
|
49
|
+
public interface IOptionalTenantEntity
|
|
50
|
+
{
|
|
51
|
+
Guid? TenantId { get; }
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### IScopedTenantEntity (tenant + scope visibility)
|
|
56
|
+
|
|
57
|
+
```csharp
|
|
58
|
+
public interface IScopedTenantEntity : IOptionalTenantEntity
|
|
59
|
+
{
|
|
60
|
+
EntityScope Scope { get; }
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### EntityScope enum
|
|
65
|
+
|
|
66
|
+
```csharp
|
|
67
|
+
public enum EntityScope
|
|
68
|
+
{
|
|
69
|
+
Tenant = 0, // Visible only to specific tenant (TenantId required)
|
|
70
|
+
Shared = 1, // Visible to all tenants (TenantId null)
|
|
71
|
+
Platform = 2 // Visible only to platform admins (HasGlobalAccess)
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Entity Pattern (tenant-scoped, most common)
|
|
78
|
+
|
|
79
|
+
```csharp
|
|
80
|
+
using SmartStack.Domain.Common;
|
|
81
|
+
|
|
82
|
+
namespace {ProjectName}.Domain.Entities.{App}.{Module};
|
|
83
|
+
|
|
84
|
+
public class {Name} : BaseEntity, ITenantEntity, IAuditableEntity
|
|
85
|
+
{
|
|
86
|
+
// ITenantEntity
|
|
87
|
+
public Guid TenantId { get; private set; }
|
|
88
|
+
|
|
89
|
+
// IAuditableEntity
|
|
90
|
+
public string? CreatedBy { get; set; }
|
|
91
|
+
public string? UpdatedBy { get; set; }
|
|
92
|
+
|
|
93
|
+
// Business properties (add your own)
|
|
94
|
+
public string Code { get; private set; } = null!;
|
|
95
|
+
public string Name { get; private set; } = null!;
|
|
96
|
+
public string? Description { get; private set; }
|
|
97
|
+
public bool IsActive { get; private set; } = true;
|
|
98
|
+
|
|
99
|
+
private {Name}() { }
|
|
100
|
+
|
|
101
|
+
public static {Name} Create(Guid tenantId, string code, string name)
|
|
102
|
+
{
|
|
103
|
+
if (tenantId == Guid.Empty)
|
|
104
|
+
throw new ArgumentException("TenantId is required", nameof(tenantId));
|
|
105
|
+
|
|
106
|
+
return new {Name}
|
|
107
|
+
{
|
|
108
|
+
Id = Guid.NewGuid(),
|
|
109
|
+
TenantId = tenantId,
|
|
110
|
+
Code = code.ToLowerInvariant(),
|
|
111
|
+
Name = name,
|
|
112
|
+
CreatedAt = DateTime.UtcNow
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
public void Update(string name, string? description)
|
|
117
|
+
{
|
|
118
|
+
Name = name;
|
|
119
|
+
Description = description;
|
|
120
|
+
UpdatedAt = DateTime.UtcNow;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## Entity Pattern (platform-level, no tenant)
|
|
128
|
+
|
|
129
|
+
```csharp
|
|
130
|
+
public class {Name} : BaseEntity, IAuditableEntity
|
|
131
|
+
{
|
|
132
|
+
public string? CreatedBy { get; set; }
|
|
133
|
+
public string? UpdatedBy { get; set; }
|
|
134
|
+
|
|
135
|
+
// Business properties
|
|
136
|
+
public string Code { get; private set; } = null!;
|
|
137
|
+
public string Name { get; private set; } = null!;
|
|
138
|
+
|
|
139
|
+
private {Name}() { }
|
|
140
|
+
|
|
141
|
+
public static {Name} Create(string code, string name)
|
|
142
|
+
{
|
|
143
|
+
return new {Name}
|
|
144
|
+
{
|
|
145
|
+
Id = Guid.NewGuid(),
|
|
146
|
+
Code = code.ToLowerInvariant(),
|
|
147
|
+
Name = name,
|
|
148
|
+
CreatedAt = DateTime.UtcNow
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### Entity Pattern — Cross-Tenant (IOptionalTenantEntity)
|
|
155
|
+
|
|
156
|
+
For entities that can be shared across tenants (e.g., Department, Currency). TenantId is nullable — null means shared, Guid means tenant-specific. The user decides the scope at creation time.
|
|
157
|
+
|
|
158
|
+
```csharp
|
|
159
|
+
public class {Name} : BaseEntity, IOptionalTenantEntity, IAuditableEntity
|
|
160
|
+
{
|
|
161
|
+
// TenantId nullable — null = shared across all tenants
|
|
162
|
+
public Guid? TenantId { get; private set; }
|
|
163
|
+
|
|
164
|
+
public string? CreatedBy { get; set; }
|
|
165
|
+
public string? UpdatedBy { get; set; }
|
|
166
|
+
|
|
167
|
+
// Business properties
|
|
168
|
+
public string Code { get; private set; } = string.Empty;
|
|
169
|
+
public string Name { get; private set; } = string.Empty;
|
|
170
|
+
|
|
171
|
+
private {Name}() { }
|
|
172
|
+
|
|
173
|
+
/// <param name="tenantId">null = shared (cross-tenant), Guid = tenant-specific</param>
|
|
174
|
+
public static {Name} Create(Guid? tenantId = null, string code, string name)
|
|
175
|
+
{
|
|
176
|
+
return new {Name}
|
|
177
|
+
{
|
|
178
|
+
Id = Guid.NewGuid(),
|
|
179
|
+
TenantId = tenantId,
|
|
180
|
+
Code = code.ToLowerInvariant(),
|
|
181
|
+
Name = name,
|
|
182
|
+
CreatedAt = DateTime.UtcNow
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
**EF Core global query filter (already in SmartStack.app CoreDbContext):**
|
|
189
|
+
```csharp
|
|
190
|
+
builder.HasQueryFilter(e => !ShouldFilterByTenant || e.TenantId == null || e.TenantId == CurrentTenantId);
|
|
191
|
+
```
|
|
192
|
+
This automatically includes shared (null) + current tenant data in all queries.
|
|
193
|
+
|
|
194
|
+
**Service pattern for optional tenant:**
|
|
195
|
+
```csharp
|
|
196
|
+
// No guard clause — tenantId is nullable
|
|
197
|
+
var tenantId = _currentTenant.TenantId; // null = creating shared data
|
|
198
|
+
var entity = Department.Create(tenantId, dto.Code, dto.Name);
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### Entity Pattern — Scoped (IScopedTenantEntity)
|
|
202
|
+
|
|
203
|
+
For entities with explicit visibility control via EntityScope enum (Tenant, Shared, Platform).
|
|
204
|
+
|
|
205
|
+
```csharp
|
|
206
|
+
public class {Name} : BaseEntity, IScopedTenantEntity, IAuditableEntity
|
|
207
|
+
{
|
|
208
|
+
public Guid? TenantId { get; private set; }
|
|
209
|
+
public EntityScope Scope { get; private set; }
|
|
210
|
+
|
|
211
|
+
public string? CreatedBy { get; set; }
|
|
212
|
+
public string? UpdatedBy { get; set; }
|
|
213
|
+
|
|
214
|
+
private {Name}() { }
|
|
215
|
+
|
|
216
|
+
public static {Name} Create(Guid? tenantId = null, EntityScope scope = EntityScope.Tenant)
|
|
217
|
+
{
|
|
218
|
+
if (scope == EntityScope.Tenant && tenantId == null)
|
|
219
|
+
throw new ArgumentException("TenantId is required when scope is Tenant");
|
|
220
|
+
|
|
221
|
+
return new {Name}
|
|
222
|
+
{
|
|
223
|
+
Id = Guid.NewGuid(),
|
|
224
|
+
TenantId = tenantId,
|
|
225
|
+
Scope = scope,
|
|
226
|
+
CreatedAt = DateTime.UtcNow
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
### MCP tenantMode Parameter
|
|
233
|
+
|
|
234
|
+
When calling `scaffold_extension`, use the `tenantMode` parameter:
|
|
235
|
+
- `strict` (default) — ITenantEntity, Guid TenantId (required)
|
|
236
|
+
- `optional` — IOptionalTenantEntity, Guid? TenantId (cross-tenant)
|
|
237
|
+
- `scoped` — IScopedTenantEntity, Guid? TenantId + EntityScope
|
|
238
|
+
- `none` — No tenant interface (platform-level entities)
|
|
239
|
+
|
|
240
|
+
The old `isSystemEntity: true` still works and maps to `tenantMode: 'none'`.
|
|
241
|
+
|
|
242
|
+
---
|
|
243
|
+
|
|
244
|
+
## EF Configuration Pattern
|
|
245
|
+
|
|
246
|
+
```csharp
|
|
247
|
+
using Microsoft.EntityFrameworkCore;
|
|
248
|
+
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
|
249
|
+
|
|
250
|
+
public class {Name}Configuration : IEntityTypeConfiguration<{Name}>
|
|
251
|
+
{
|
|
252
|
+
public void Configure(EntityTypeBuilder<{Name}> builder)
|
|
253
|
+
{
|
|
254
|
+
builder.ToTable("{prefix}{Name}s", "{schema}");
|
|
255
|
+
|
|
256
|
+
builder.HasKey(x => x.Id);
|
|
257
|
+
|
|
258
|
+
// Tenant (if ITenantEntity)
|
|
259
|
+
builder.Property(x => x.TenantId).IsRequired();
|
|
260
|
+
builder.HasIndex(x => x.TenantId)
|
|
261
|
+
.HasDatabaseName("IX_{prefix}{Name}s_TenantId");
|
|
262
|
+
|
|
263
|
+
// Business properties
|
|
264
|
+
builder.Property(x => x.Code).HasMaxLength(50).IsRequired();
|
|
265
|
+
builder.Property(x => x.Name).HasMaxLength(100).IsRequired();
|
|
266
|
+
builder.Property(x => x.Description).HasMaxLength(500);
|
|
267
|
+
|
|
268
|
+
// Audit (from IAuditableEntity)
|
|
269
|
+
builder.Property(x => x.CreatedBy).HasMaxLength(256);
|
|
270
|
+
builder.Property(x => x.UpdatedBy).HasMaxLength(256);
|
|
271
|
+
|
|
272
|
+
// Unique indexes
|
|
273
|
+
builder.HasIndex(x => new { x.TenantId, x.Code })
|
|
274
|
+
.IsUnique()
|
|
275
|
+
.HasDatabaseName("IX_{prefix}{Name}s_Tenant_Code");
|
|
276
|
+
|
|
277
|
+
// Relationships
|
|
278
|
+
// builder.HasMany(x => x.Children)
|
|
279
|
+
// .WithOne(x => x.Parent)
|
|
280
|
+
// .HasForeignKey(x => x.ParentId)
|
|
281
|
+
// .OnDelete(DeleteBehavior.Restrict);
|
|
282
|
+
|
|
283
|
+
// Seed data (if applicable)
|
|
284
|
+
// builder.HasData({Name}SeedData.GetSeedData());
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
---
|
|
290
|
+
|
|
291
|
+
## Service Pattern (tenant-scoped, MANDATORY)
|
|
292
|
+
|
|
293
|
+
> **CRITICAL:** ALL services MUST inject `ICurrentUserService` + `ICurrentTenantService` and filter by `TenantId`. Missing TenantId = OWASP A01 vulnerability.
|
|
294
|
+
|
|
295
|
+
```csharp
|
|
296
|
+
using Microsoft.EntityFrameworkCore;
|
|
297
|
+
using Microsoft.Extensions.Logging;
|
|
298
|
+
using SmartStack.Application.Common.Interfaces.Identity;
|
|
299
|
+
using SmartStack.Application.Common.Interfaces.Tenants;
|
|
300
|
+
using SmartStack.Application.Common.Interfaces.Persistence;
|
|
301
|
+
|
|
302
|
+
namespace {ProjectName}.Infrastructure.Services.{App}.{Module};
|
|
303
|
+
|
|
304
|
+
public class {Name}Service : I{Name}Service
|
|
305
|
+
{
|
|
306
|
+
private readonly IExtensionsDbContext _db;
|
|
307
|
+
private readonly ICurrentUserService _currentUser;
|
|
308
|
+
private readonly ICurrentTenantService _currentTenant;
|
|
309
|
+
private readonly ILogger<{Name}Service> _logger;
|
|
310
|
+
|
|
311
|
+
public {Name}Service(
|
|
312
|
+
IExtensionsDbContext db,
|
|
313
|
+
ICurrentUserService currentUser,
|
|
314
|
+
ICurrentTenantService currentTenant,
|
|
315
|
+
ILogger<{Name}Service> logger)
|
|
316
|
+
{
|
|
317
|
+
_db = db;
|
|
318
|
+
_currentUser = currentUser;
|
|
319
|
+
_currentTenant = currentTenant;
|
|
320
|
+
_logger = logger;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
public async Task<PaginatedResult<{Name}ResponseDto>> GetAllAsync(
|
|
324
|
+
string? search = null,
|
|
325
|
+
int page = 1,
|
|
326
|
+
int pageSize = 20,
|
|
327
|
+
CancellationToken ct = default)
|
|
328
|
+
{
|
|
329
|
+
// MANDATORY guard — throws 400 if no tenant context (e.g., missing X-Tenant-Slug header)
|
|
330
|
+
var tenantId = _currentTenant.TenantId
|
|
331
|
+
?? throw new TenantContextRequiredException();
|
|
332
|
+
|
|
333
|
+
var query = _db.{Name}s
|
|
334
|
+
.Where(x => x.TenantId == tenantId) // MANDATORY tenant filter
|
|
335
|
+
.AsNoTracking();
|
|
336
|
+
|
|
337
|
+
// Search filter — enables EntityLookup on frontend
|
|
338
|
+
if (!string.IsNullOrWhiteSpace(search))
|
|
339
|
+
{
|
|
340
|
+
query = query.Where(x =>
|
|
341
|
+
x.Name.Contains(search) ||
|
|
342
|
+
x.Code.Contains(search));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
var totalCount = await query.CountAsync(ct);
|
|
346
|
+
var items = await query
|
|
347
|
+
.OrderBy(x => x.Name)
|
|
348
|
+
.Skip((page - 1) * pageSize)
|
|
349
|
+
.Take(pageSize)
|
|
350
|
+
.Select(x => new {Name}ResponseDto(x.Id, x.Code, x.Name, x.CreatedAt))
|
|
351
|
+
.ToListAsync(ct);
|
|
352
|
+
|
|
353
|
+
return new PaginatedResult<{Name}ResponseDto>(items, totalCount, page, pageSize);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
public async Task<{Name}ResponseDto?> GetByIdAsync(Guid id, CancellationToken ct)
|
|
357
|
+
{
|
|
358
|
+
var tenantId = _currentTenant.TenantId
|
|
359
|
+
?? throw new TenantContextRequiredException();
|
|
360
|
+
|
|
361
|
+
return await _db.{Name}s
|
|
362
|
+
.Where(x => x.Id == id && x.TenantId == tenantId) // MANDATORY
|
|
363
|
+
.AsNoTracking()
|
|
364
|
+
.Select(x => new {Name}ResponseDto(x.Id, x.Code, x.Name, x.CreatedAt))
|
|
365
|
+
.FirstOrDefaultAsync(ct);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
public async Task<{Name}ResponseDto> CreateAsync(Create{Name}Dto dto, CancellationToken ct)
|
|
369
|
+
{
|
|
370
|
+
var tenantId = _currentTenant.TenantId
|
|
371
|
+
?? throw new TenantContextRequiredException();
|
|
372
|
+
|
|
373
|
+
var entity = {Name}.Create(
|
|
374
|
+
tenantId: tenantId, // MANDATORY — never Guid.Empty
|
|
375
|
+
code: dto.Code,
|
|
376
|
+
name: dto.Name);
|
|
377
|
+
|
|
378
|
+
entity.CreatedBy = _currentUser.UserId?.ToString();
|
|
379
|
+
|
|
380
|
+
_db.{Name}s.Add(entity);
|
|
381
|
+
await _db.SaveChangesAsync(ct);
|
|
382
|
+
|
|
383
|
+
_logger.LogInformation("Created {Entity} {Id} for tenant {TenantId}",
|
|
384
|
+
nameof({Name}), entity.Id, tenantId);
|
|
385
|
+
|
|
386
|
+
return new {Name}ResponseDto(entity.Id, entity.Code, entity.Name, entity.CreatedAt);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
public async Task DeleteAsync(Guid id, CancellationToken ct)
|
|
390
|
+
{
|
|
391
|
+
var tenantId = _currentTenant.TenantId
|
|
392
|
+
?? throw new TenantContextRequiredException();
|
|
393
|
+
|
|
394
|
+
var entity = await _db.{Name}s
|
|
395
|
+
.FirstOrDefaultAsync(x => x.Id == id && x.TenantId == tenantId, ct)
|
|
396
|
+
?? throw new KeyNotFoundException($"{Name} {id} not found");
|
|
397
|
+
|
|
398
|
+
_db.{Name}s.Remove(entity);
|
|
399
|
+
await _db.SaveChangesAsync(ct);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
**Key interfaces (from SmartStack NuGet package):**
|
|
405
|
+
- `ICurrentUserService` (from `SmartStack.Application.Common.Interfaces.Identity`): provides `UserId` (Guid?), `Email` (string?), `IsAuthenticated` (bool)
|
|
406
|
+
- `ICurrentTenantService` (from `SmartStack.Application.Common.Interfaces.Tenants`): provides `TenantId` (Guid?), `HasTenant` (bool), `TenantSlug` (string?)
|
|
407
|
+
- `IExtensionsDbContext` (for client extensions) or `ICoreDbContext` (for platform)
|
|
408
|
+
|
|
409
|
+
**MANDATORY guard clause (first line of every method):**
|
|
410
|
+
```csharp
|
|
411
|
+
var tenantId = _currentTenant.TenantId
|
|
412
|
+
?? throw new TenantContextRequiredException();
|
|
413
|
+
```
|
|
414
|
+
This converts a null TenantId into a clean 400 Bad Request response via `GlobalExceptionHandlerMiddleware`.
|
|
415
|
+
**IMPORTANT:** Uses `TenantContextRequiredException` (400), NOT `UnauthorizedAccessException` (401). A missing tenant is a bad request, not an auth failure — the JWT is valid, `[Authorize]` passed.
|
|
416
|
+
|
|
417
|
+
**FORBIDDEN in services:**
|
|
418
|
+
- `_currentTenant.TenantId!.Value` — throws `InvalidOperationException` (500) instead of clean 400
|
|
419
|
+
- `UnauthorizedAccessException("Tenant context is required")` — throws 401, triggers frontend token clearing
|
|
420
|
+
- `tenantId: Guid.Empty` — always use validated tenantId from guard clause
|
|
421
|
+
- Queries WITHOUT `.Where(x => x.TenantId == tenantId)` — data leak
|
|
422
|
+
- Missing `ILogger<T>` — undiagnosable in production
|
|
423
|
+
- Using `ICurrentUser` (does NOT exist) — use `ICurrentUserService` + `ICurrentTenantService`
|
|
424
|
+
|
|
425
|
+
---
|
|
426
|
+
|
|
427
|
+
## Controller Pattern (NavRoute)
|
|
428
|
+
|
|
429
|
+
```csharp
|
|
430
|
+
using Microsoft.AspNetCore.Authorization;
|
|
431
|
+
using Microsoft.AspNetCore.Mvc;
|
|
432
|
+
using SmartStack.Api.Routing;
|
|
433
|
+
using SmartStack.Api.Authorization;
|
|
434
|
+
|
|
435
|
+
namespace {ProjectName}.Api.Controllers.{App};
|
|
436
|
+
|
|
437
|
+
[ApiController]
|
|
438
|
+
[NavRoute("{app}.{module}")]
|
|
439
|
+
[Authorize]
|
|
440
|
+
public class {Name}Controller : ControllerBase
|
|
441
|
+
{
|
|
442
|
+
private readonly I{Name}Service _service;
|
|
443
|
+
private readonly ILogger<{Name}Controller> _logger;
|
|
444
|
+
|
|
445
|
+
public {Name}Controller(I{Name}Service service, ILogger<{Name}Controller> logger)
|
|
446
|
+
{
|
|
447
|
+
_service = service;
|
|
448
|
+
_logger = logger;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
[HttpGet]
|
|
452
|
+
[RequirePermission(Permissions.{Module}.Read)]
|
|
453
|
+
public async Task<ActionResult<PaginatedResult<{Name}ResponseDto>>> GetAll(
|
|
454
|
+
[FromQuery] string? search = null,
|
|
455
|
+
[FromQuery] int page = 1,
|
|
456
|
+
[FromQuery] int pageSize = 20,
|
|
457
|
+
CancellationToken ct = default)
|
|
458
|
+
=> Ok(await _service.GetAllAsync(search, page, pageSize, ct));
|
|
459
|
+
|
|
460
|
+
[HttpGet("{id:guid}")]
|
|
461
|
+
[RequirePermission(Permissions.{Module}.Read)]
|
|
462
|
+
public async Task<ActionResult<{Name}ResponseDto>> GetById(Guid id, CancellationToken ct)
|
|
463
|
+
{
|
|
464
|
+
var result = await _service.GetByIdAsync(id, ct);
|
|
465
|
+
return result is null ? NotFound() : Ok(result);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
[HttpPost]
|
|
469
|
+
[RequirePermission(Permissions.{Module}.Create)]
|
|
470
|
+
public async Task<ActionResult<{Name}ResponseDto>> Create([FromBody] Create{Name}Dto dto, CancellationToken ct)
|
|
471
|
+
{
|
|
472
|
+
var result = await _service.CreateAsync(dto, ct);
|
|
473
|
+
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
[HttpPut("{id:guid}")]
|
|
477
|
+
[RequirePermission(Permissions.{Module}.Update)]
|
|
478
|
+
public async Task<ActionResult<{Name}ResponseDto>> Update(Guid id, [FromBody] Update{Name}Dto dto, CancellationToken ct)
|
|
479
|
+
{
|
|
480
|
+
var result = await _service.UpdateAsync(id, dto, ct);
|
|
481
|
+
return result is null ? NotFound() : Ok(result);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
[HttpDelete("{id:guid}")]
|
|
485
|
+
[RequirePermission(Permissions.{Module}.Delete)]
|
|
486
|
+
public async Task<ActionResult> Delete(Guid id, CancellationToken ct)
|
|
487
|
+
{
|
|
488
|
+
await _service.DeleteAsync(id, ct);
|
|
489
|
+
return NoContent();
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
**CRITICAL — Route attribute rules:**
|
|
495
|
+
- `[NavRoute]` is the ONLY route attribute needed — it resolves routes dynamically from Navigation entities at startup
|
|
496
|
+
- **FORBIDDEN:** `[Route("api/...")]` alongside `[NavRoute]` — causes route conflicts and 404s at runtime
|
|
497
|
+
- **FORBIDDEN:** `[Route("api/[controller]")]` — this is standard ASP.NET Core, NOT SmartStack
|
|
498
|
+
- If a controller has `[NavRoute]`, there must be NO `[Route]` attribute on the class
|
|
499
|
+
|
|
500
|
+
**CRITICAL:** Use `[RequirePermission(Permissions.{Module}.{Action})]` on EVERY endpoint — NEVER `[Authorize]` alone (no RBAC enforcement).
|
|
501
|
+
|
|
502
|
+
**CRITICAL — Permission paths use IDENTICAL segments to NavRoute codes (kebab-case):**
|
|
503
|
+
- NavRoute: `human-resources.employees` → Permission: `human-resources.employees.read`
|
|
504
|
+
- NavRoute: `human-resources.employees.leaves` → Permission: `human-resources.employees.leaves.read`
|
|
505
|
+
- FORBIDDEN: `humanresources.employees.read` (no kebab-case — mismatches NavRoute)
|
|
506
|
+
- SmartStack.app convention: `support-client.my-tickets.read` (always kebab-case)
|
|
507
|
+
|
|
508
|
+
### Section-Level Controller (NavRoute with 4 segments)
|
|
509
|
+
|
|
510
|
+
When a module has sections, each section gets its own controller with a 4-segment navRoute:
|
|
511
|
+
|
|
512
|
+
```csharp
|
|
513
|
+
// Section-level controller: navRoute has 4 segments
|
|
514
|
+
[ApiController]
|
|
515
|
+
[NavRoute("{app}.{module}.{section}")]
|
|
516
|
+
[Authorize]
|
|
517
|
+
public class {Section}Controller : ControllerBase
|
|
518
|
+
{
|
|
519
|
+
// Example: human-resources.employees.departments
|
|
520
|
+
[HttpGet]
|
|
521
|
+
[RequirePermission(Permissions.{Section}.Read)]
|
|
522
|
+
public async Task<ActionResult<PaginatedResult<{Section}ResponseDto>>> GetAll(
|
|
523
|
+
[FromQuery] string? search = null,
|
|
524
|
+
[FromQuery] int page = 1,
|
|
525
|
+
[FromQuery] int pageSize = 20,
|
|
526
|
+
CancellationToken ct = default)
|
|
527
|
+
=> Ok(await _service.GetAllAsync(search, page, pageSize, ct));
|
|
528
|
+
}
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
**NavRoute segment rules:**
|
|
532
|
+
| Level | NavRoute format | Example |
|
|
533
|
+
|-------|----------------|---------|
|
|
534
|
+
| Module | `{app}.{module}` (2 segments) | `human-resources.employees` |
|
|
535
|
+
| Section | `{app}.{module}.{section}` (3 segments) | `human-resources.employees.departments` |
|
|
536
|
+
|
|
537
|
+
**Namespace:** `SmartStack.Api.Routing` (NOT `SmartStack.Api.Core.Routing`)
|
|
538
|
+
|
|
539
|
+
**NavRoute resolves at startup from DB:** `administration.users` → `api/administration/users`
|
|
540
|
+
|
|
541
|
+
### Sub-Resource Pattern (NavRoute Suffix)
|
|
542
|
+
|
|
543
|
+
When an entity is a child of another entity (e.g., LeaveTypes under Leaves), use `[NavRoute(..., Suffix = "types")]`:
|
|
544
|
+
|
|
545
|
+
```csharp
|
|
546
|
+
// Sub-resource controller: types are nested under leaves
|
|
547
|
+
[ApiController]
|
|
548
|
+
[NavRoute("human-resources.employees.leaves", Suffix = "types")]
|
|
549
|
+
[Authorize]
|
|
550
|
+
public class LeaveTypesController : ControllerBase
|
|
551
|
+
{
|
|
552
|
+
[HttpGet]
|
|
553
|
+
[RequirePermission(Permissions.Leaves.Read)] // inherits parent section permission
|
|
554
|
+
public async Task<ActionResult<PaginatedResult<LeaveTypeResponseDto>>> GetAll(...)
|
|
555
|
+
=> Ok(await _service.GetAllAsync(search, page, pageSize, ct));
|
|
556
|
+
}
|
|
557
|
+
```
|
|
558
|
+
|
|
559
|
+
**Alternative pattern** (sub-resource endpoints within parent controller):
|
|
560
|
+
```csharp
|
|
561
|
+
// LeaveTypes as endpoints within LeavesController
|
|
562
|
+
[HttpGet("types")]
|
|
563
|
+
[RequirePermission(Permissions.Leaves.Read)]
|
|
564
|
+
public async Task<ActionResult<PaginatedResult<LeaveTypeResponseDto>>> GetAllLeaveTypes(...)
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
> **CRITICAL — Sub-resource frontend completeness:**
|
|
568
|
+
> If a parent page has a button (e.g., "Manage Leave Types") that `navigate()`s to a sub-resource route,
|
|
569
|
+
> the frontend MUST include a page component for that route. Otherwise → dead link → white screen.
|
|
570
|
+
> - Either create a dedicated sub-resource ListPage (e.g., `LeaveTypesPage.tsx`)
|
|
571
|
+
> - Or DON'T include the navigate() button if pages won't be created
|
|
572
|
+
> - **Prefer separate controllers** (with Suffix) over sub-endpoints in parent controller — easier to route
|
|
573
|
+
|
|
574
|
+
---
|
|
575
|
+
|
|
576
|
+
## Navigation Seed Data Pattern (CRITICAL — routes must be full paths)
|
|
577
|
+
|
|
578
|
+
> **The navigation seed data defines menu routes stored in DB. These routes MUST be full paths starting with `/`.**
|
|
579
|
+
> Short routes (e.g., `humanresources`) cause 400 Bad Request on application-tracking.
|
|
580
|
+
|
|
581
|
+
### Route Convention
|
|
582
|
+
|
|
583
|
+
| Level | Route Format | Example |
|
|
584
|
+
|-------|-------------|---------|
|
|
585
|
+
| Application | `/{app-kebab}` | `/human-resources` |
|
|
586
|
+
| Module | `/{app-kebab}/{module-kebab}` | `/human-resources/employees` |
|
|
587
|
+
| Section | `/{app-kebab}/{module-kebab}/{section-kebab}` | `/human-resources/employees/departments` |
|
|
588
|
+
| Resource | `/{app-kebab}/{module-kebab}/{section-kebab}/{resource-kebab}` | `/human-resources/employees/departments/export` |
|
|
589
|
+
|
|
590
|
+
**ROUTE SPECIAL CASES (list and detail sections):**
|
|
591
|
+
> The `list` and `detail` sections are NOT functional sub-areas — they are view modes of the module itself.
|
|
592
|
+
> Their navigation routes MUST NOT add extra segments:
|
|
593
|
+
> - `list` section route = module route (e.g., `/human-resources/employees`)
|
|
594
|
+
> - `detail` section route = module route + `/:id` (e.g., `/human-resources/employees/:id`)
|
|
595
|
+
> - FORBIDDEN: `/employees/list`, `/employees/detail/:id`
|
|
596
|
+
> - Other sections (dashboard, approve, import, etc.) = module route + `/{section-kebab}` (normal behavior)
|
|
597
|
+
|
|
598
|
+
**Rules:**
|
|
599
|
+
- Routes ALWAYS start with `/`
|
|
600
|
+
- Routes ALWAYS include the full hierarchy from application to current level
|
|
601
|
+
- Routes ALWAYS use kebab-case (NOT PascalCase, NOT camelCase)
|
|
602
|
+
- Code identifiers stay PascalCase in C# (`HumanResources`) but routes are kebab-case (`human-resources`)
|
|
603
|
+
|
|
604
|
+
### ToKebabCase Helper (include in SeedConstants or SeedDataProvider)
|
|
605
|
+
|
|
606
|
+
```csharp
|
|
607
|
+
private static string ToKebabCase(string value)
|
|
608
|
+
=> System.Text.RegularExpressions.Regex
|
|
609
|
+
.Replace(value, "([a-z])([A-Z])", "$1-$2")
|
|
610
|
+
.ToLowerInvariant();
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
### SeedConstants Pattern
|
|
614
|
+
|
|
615
|
+
```csharp
|
|
616
|
+
public static class SeedConstants
|
|
617
|
+
{
|
|
618
|
+
// Deterministic GUIDs (SHA256-based, reproducible across environments)
|
|
619
|
+
// NOTE: Application/Module/Section/Resource IDs are deterministic.
|
|
620
|
+
public static readonly Guid ApplicationId = DeterministicGuid("nav:human-resources");
|
|
621
|
+
public static readonly Guid ModuleId = DeterministicGuid("nav:human-resources.employees");
|
|
622
|
+
public static readonly Guid SectionId = DeterministicGuid("nav:human-resources.employees.departments");
|
|
623
|
+
|
|
624
|
+
private static Guid DeterministicGuid(string input)
|
|
625
|
+
{
|
|
626
|
+
var hash = System.Security.Cryptography.SHA256.HashData(
|
|
627
|
+
System.Text.Encoding.UTF8.GetBytes(input));
|
|
628
|
+
var bytes = new byte[16];
|
|
629
|
+
Array.Copy(hash, bytes, 16);
|
|
630
|
+
bytes[6] = (byte)((bytes[6] & 0x0F) | 0x50); // version 5
|
|
631
|
+
bytes[8] = (byte)((bytes[8] & 0x3F) | 0x80); // variant
|
|
632
|
+
return new Guid(bytes);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
```
|
|
636
|
+
|
|
637
|
+
### Navigation Seed Data Example
|
|
638
|
+
|
|
639
|
+
```csharp
|
|
640
|
+
// Application: /human-resources
|
|
641
|
+
var app = NavigationApplication.Create(
|
|
642
|
+
"human-resources", "Human Resources", "HR Management",
|
|
643
|
+
"Users", IconType.Lucide,
|
|
644
|
+
"/human-resources", // FULL PATH — starts with /, kebab-case
|
|
645
|
+
10);
|
|
646
|
+
|
|
647
|
+
// Module: /human-resources/employees
|
|
648
|
+
var module = NavigationModule.Create(
|
|
649
|
+
app.Id, "employees", "Employees", "Employee management",
|
|
650
|
+
"UserCheck", IconType.Lucide,
|
|
651
|
+
"/human-resources/employees", // FULL PATH — includes parent
|
|
652
|
+
10);
|
|
653
|
+
|
|
654
|
+
// Section: /human-resources/employees/departments
|
|
655
|
+
var section = NavigationSection.Create(
|
|
656
|
+
module.Id, "departments", "Departments", "Manage departments",
|
|
657
|
+
"Building2", IconType.Lucide,
|
|
658
|
+
"/human-resources/employees/departments", // FULL PATH
|
|
659
|
+
10);
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
### FORBIDDEN in Seed Data
|
|
663
|
+
|
|
664
|
+
| Mistake | Reality |
|
|
665
|
+
|---------|---------|
|
|
666
|
+
| `"humanresources"` as route | Must be `"/human-resources"` (full path, kebab-case) |
|
|
667
|
+
| `"employees"` as route | Must be `"/human-resources/employees"` (includes parent) |
|
|
668
|
+
| `Guid.NewGuid()` in seed data | Must use deterministic GUIDs (SHA256) |
|
|
669
|
+
| Missing translations | Must have 4 languages: fr, en, it, de |
|
|
670
|
+
| Missing NavigationApplicationSeedData | Menu invisible without Application level |
|
|
671
|
+
|
|
672
|
+
---
|
|
673
|
+
|
|
674
|
+
## DbContext Pattern (extensions)
|
|
675
|
+
|
|
676
|
+
```csharp
|
|
677
|
+
// In IExtensionsDbContext.cs:
|
|
678
|
+
public DbSet<{Name}> {Name}s => Set<{Name}>();
|
|
679
|
+
|
|
680
|
+
// In ExtensionsDbContext.cs (same line):
|
|
681
|
+
public DbSet<{Name}> {Name}s => Set<{Name}>();
|
|
682
|
+
```
|
|
683
|
+
|
|
684
|
+
---
|
|
685
|
+
|
|
686
|
+
## DI Registration Pattern
|
|
687
|
+
|
|
688
|
+
```csharp
|
|
689
|
+
// In DependencyInjection.cs or ServiceCollectionExtensions.cs:
|
|
690
|
+
services.AddScoped<I{Name}Service, {Name}Service>();
|
|
691
|
+
services.AddValidatorsFromAssemblyContaining<Create{Name}DtoValidator>();
|
|
692
|
+
```
|
|
693
|
+
|
|
694
|
+
---
|
|
695
|
+
|
|
696
|
+
## DTO Type Mapping (CRITICAL)
|
|
697
|
+
|
|
698
|
+
> **Use the correct .NET type for each property.** Incorrect types cause runtime parsing errors.
|
|
699
|
+
|
|
700
|
+
| Property Pattern | .NET Type | JSON Format | Example |
|
|
701
|
+
|-----------------|-----------|-------------|---------|
|
|
702
|
+
| `*Date`, `StartDate`, `EndDate`, `BirthDate` | `DateOnly` | `"2025-03-15"` | `public DateOnly Date { get; set; }` |
|
|
703
|
+
| `CreatedAt`, `UpdatedAt` | `DateTime` | `"2025-03-15T10:30:00Z"` | `public DateTime CreatedAt { get; set; }` |
|
|
704
|
+
| `*Time`, `StartTime` | `TimeOnly` | `"14:30:00"` | `public TimeOnly StartTime { get; set; }` |
|
|
705
|
+
| Duration, hours | `decimal` | `8.5` | `public decimal HoursWorked { get; set; }` |
|
|
706
|
+
| FK reference | `Guid` | `"uuid-string"` | `public Guid EmployeeId { get; set; }` |
|
|
707
|
+
|
|
708
|
+
**FORBIDDEN in DTOs:**
|
|
709
|
+
- `string Date` / `string StartDate` — use `DateOnly`
|
|
710
|
+
- `string Time` — use `TimeOnly`
|
|
711
|
+
- `DateTime BirthDate` — use `DateOnly` (no time component needed)
|
|
712
|
+
- `int` for hours/duration — use `decimal` for fractional values
|
|
713
|
+
|
|
714
|
+
---
|
|
715
|
+
|
|
716
|
+
## Common Mistakes to Avoid
|
|
717
|
+
|
|
718
|
+
| Mistake | Reality |
|
|
719
|
+
|---------|---------|
|
|
720
|
+
| `entity.SoftDelete()` | Does NOT exist — no soft delete in BaseEntity |
|
|
721
|
+
| `entity.Code` inherited | Code is a business property — add it yourself |
|
|
722
|
+
| `e.RowVersion` in config | Does NOT exist in BaseEntity |
|
|
723
|
+
| `e.IsDeleted` filter | Does NOT exist — no soft delete |
|
|
724
|
+
| `SmartStack.Api.Core.Routing` | Wrong — use `SmartStack.Api.Routing` |
|
|
725
|
+
| `SystemEntity` base class | Does NOT exist — use `BaseEntity` for all |
|
|
726
|
+
| `[Route("api/...")] + [NavRoute]` | **FORBIDDEN** — causes 404s. Only `[NavRoute]` needed (resolves route from DB at startup). Remove ALL `[Route]` attributes when `[NavRoute]` is present. |
|
|
727
|
+
| `SmartStack.Domain.Common.Interfaces` | Wrong — interfaces are in `SmartStack.Domain.Common` directly |
|
|
728
|
+
| `[Authorize]` without `[RequirePermission]` | No RBAC enforcement — always use `[RequirePermission]` |
|
|
729
|
+
| `tenantId: Guid.Empty` in services | OWASP A01 — always use validated `_currentTenant.TenantId` |
|
|
730
|
+
| Service without `ICurrentTenantService` | All tenant data leaks — inject `ICurrentTenantService` |
|
|
731
|
+
| `ICurrentUser` in service code | Does NOT exist — use `ICurrentUserService` + `ICurrentTenantService` |
|
|
732
|
+
| `_currentTenant.TenantId!.Value` | Crashes with 500 — use `?? throw new TenantContextRequiredException()` |
|
|
733
|
+
| `UnauthorizedAccessException("Tenant context is required")` | Returns 401 → clears frontend token. Use `TenantContextRequiredException()` (400) |
|
|
734
|
+
| Route `"humanresources"` in seed data | Must be full path `"/human-resources"` |
|
|
735
|
+
| Route without leading `/` | All routes must start with `/` |
|
|
736
|
+
| `humanresources.employees.read` in permissions | Permission segments MUST match NavRoute kebab-case: `human-resources.employees.read` |
|
|
737
|
+
| `Permission.Create()` | Does NOT exist — use `CreateForModule()`, `CreateForSection()`, etc. |
|
|
738
|
+
| `GetAllAsync()` without search param | ALL GetAll endpoints MUST support `?search=` for EntityLookup |
|
|
739
|
+
| `string Date` in DTO | Date-only fields MUST use `DateOnly`, NEVER `string` |
|
|
740
|
+
| `DateTime` for date-only | Use `DateOnly` when no time component needed |
|
|
741
|
+
| FK field as plain text input | Frontend MUST use `EntityLookup` component for Guid FK fields |
|
|
742
|
+
| `PagedResult<T>` / `PaginatedResultDto<T>` | FORBIDDEN — use `PaginatedResult<T>` only |
|
|
743
|
+
|
|
744
|
+
---
|
|
745
|
+
|
|
746
|
+
## PaginatedResult Pattern
|
|
747
|
+
|
|
748
|
+
> **Canonical type for ALL paginated responses.** One name, one contract, everywhere.
|
|
749
|
+
|
|
750
|
+
### Definition (Backend — `SmartStack.Application.Common.Models`)
|
|
751
|
+
|
|
752
|
+
```csharp
|
|
753
|
+
namespace SmartStack.Application.Common.Models;
|
|
754
|
+
|
|
755
|
+
public record PaginatedResult<T>(
|
|
756
|
+
List<T> Items,
|
|
757
|
+
int TotalCount,
|
|
758
|
+
int Page,
|
|
759
|
+
int PageSize)
|
|
760
|
+
{
|
|
761
|
+
public int TotalPages => PageSize > 0
|
|
762
|
+
? (int)Math.Ceiling((double)TotalCount / PageSize) : 0;
|
|
763
|
+
public bool HasPreviousPage => Page > 1;
|
|
764
|
+
public bool HasNextPage => Page < TotalPages;
|
|
765
|
+
|
|
766
|
+
public static PaginatedResult<T> Empty(int page = 1, int pageSize = 20)
|
|
767
|
+
=> new([], 0, page, pageSize);
|
|
768
|
+
}
|
|
769
|
+
```
|
|
770
|
+
|
|
771
|
+
### Extension Method
|
|
772
|
+
|
|
773
|
+
```csharp
|
|
774
|
+
namespace SmartStack.Application.Common.Extensions;
|
|
775
|
+
|
|
776
|
+
public static class QueryableExtensions
|
|
777
|
+
{
|
|
778
|
+
public const int MaxPageSize = 100;
|
|
779
|
+
|
|
780
|
+
public static async Task<PaginatedResult<T>> ToPaginatedResultAsync<T>(
|
|
781
|
+
this IQueryable<T> query,
|
|
782
|
+
int page = 1,
|
|
783
|
+
int pageSize = 20,
|
|
784
|
+
CancellationToken ct = default)
|
|
785
|
+
{
|
|
786
|
+
page = Math.Max(1, page);
|
|
787
|
+
pageSize = Math.Clamp(pageSize, 1, MaxPageSize);
|
|
788
|
+
|
|
789
|
+
var totalCount = await query.CountAsync(ct);
|
|
790
|
+
var items = await query
|
|
791
|
+
.Skip((page - 1) * pageSize)
|
|
792
|
+
.Take(pageSize)
|
|
793
|
+
.ToListAsync(ct);
|
|
794
|
+
|
|
795
|
+
return new PaginatedResult<T>(items, totalCount, page, pageSize);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
```
|
|
799
|
+
|
|
800
|
+
### Usage in Service (with search + extension method)
|
|
801
|
+
|
|
802
|
+
```csharp
|
|
803
|
+
public async Task<PaginatedResult<{Name}ResponseDto>> GetAllAsync(
|
|
804
|
+
string? search = null, int page = 1, int pageSize = 20, CancellationToken ct = default)
|
|
805
|
+
{
|
|
806
|
+
var tenantId = _currentTenant.TenantId
|
|
807
|
+
?? throw new TenantContextRequiredException();
|
|
808
|
+
|
|
809
|
+
var query = _db.{Name}s
|
|
810
|
+
.Where(x => x.TenantId == tenantId)
|
|
811
|
+
.AsNoTracking();
|
|
812
|
+
|
|
813
|
+
if (!string.IsNullOrWhiteSpace(search))
|
|
814
|
+
query = query.Where(x => x.Name.Contains(search) || x.Code.Contains(search));
|
|
815
|
+
|
|
816
|
+
return await query
|
|
817
|
+
.OrderBy(x => x.Name)
|
|
818
|
+
.Select(x => new {Name}ResponseDto(x.Id, x.Code, x.Name, x.CreatedAt))
|
|
819
|
+
.ToPaginatedResultAsync(page, pageSize, ct);
|
|
820
|
+
}
|
|
821
|
+
```
|
|
822
|
+
|
|
823
|
+
### Frontend Types (`@/types/pagination.ts`)
|
|
824
|
+
|
|
825
|
+
```typescript
|
|
826
|
+
export interface PaginatedResult<T> {
|
|
827
|
+
items: T[];
|
|
828
|
+
totalCount: number;
|
|
829
|
+
page: number;
|
|
830
|
+
pageSize: number;
|
|
831
|
+
totalPages: number;
|
|
832
|
+
hasPreviousPage: boolean;
|
|
833
|
+
hasNextPage: boolean;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
export interface PaginationParams {
|
|
837
|
+
page?: number;
|
|
838
|
+
pageSize?: number;
|
|
839
|
+
search?: string;
|
|
840
|
+
sortBy?: string;
|
|
841
|
+
sortDirection?: 'asc' | 'desc';
|
|
842
|
+
}
|
|
843
|
+
```
|
|
844
|
+
|
|
845
|
+
### FORBIDDEN Type Names
|
|
846
|
+
|
|
847
|
+
| Forbidden | Canonical Replacement |
|
|
848
|
+
|-----------|----------------------|
|
|
849
|
+
| `PagedResult<T>` | `PaginatedResult<T>` |
|
|
850
|
+
| `PaginatedResultDto<T>` | `PaginatedResult<T>` |
|
|
851
|
+
| `PaginatedResponse<T>` | `PaginatedResult<T>` |
|
|
852
|
+
| `PageResultDto<T>` | `PaginatedResult<T>` |
|
|
853
|
+
| `PaginatedRequest` | `PaginationParams` |
|
|
854
|
+
| `QueryParameters` | `PaginationParams` |
|
|
855
|
+
| `currentPage` (property) | `page` |
|
|
856
|
+
| `HasPrevious` (property) | `HasPreviousPage` |
|
|
857
|
+
| `HasNext` (property) | `HasNextPage` |
|
|
858
|
+
|
|
859
|
+
### Rules
|
|
860
|
+
|
|
861
|
+
- **Max pageSize = 100** — enforced via `Math.Clamp(pageSize, 1, 100)` or extension method
|
|
862
|
+
- **Default page = 1, pageSize = 20** — all GetAll endpoints
|
|
863
|
+
- **Search param mandatory** — enables `EntityLookup` on frontend
|
|
864
|
+
- **POST-CHECK 16** blocks `List<T>` returns on GetAll
|
|
865
|
+
- **POST-CHECK 31** blocks non-canonical pagination type names
|
|
866
|
+
|
|
867
|
+
---
|
|
868
|
+
|
|
869
|
+
## Critical Anti-Patterns (with code examples)
|
|
870
|
+
|
|
871
|
+
> **These are the most common and dangerous mistakes.** Each one has been observed in production code generation.
|
|
872
|
+
|
|
873
|
+
### Anti-Pattern 1: HasQueryFilter with `!= Guid.Empty` (SECURITY — OWASP A01)
|
|
874
|
+
|
|
875
|
+
The `HasQueryFilter` in EF Core should use **runtime tenant resolution**, NOT a static comparison against `Guid.Empty`.
|
|
876
|
+
|
|
877
|
+
**INCORRECT — Does NOT isolate tenants:**
|
|
878
|
+
```csharp
|
|
879
|
+
// WRONG: This only excludes empty GUIDs — ALL tenant data is still visible to everyone!
|
|
880
|
+
public void Configure(EntityTypeBuilder<MyEntity> builder)
|
|
881
|
+
{
|
|
882
|
+
builder.HasQueryFilter(e => e.TenantId != Guid.Empty);
|
|
883
|
+
}
|
|
884
|
+
```
|
|
885
|
+
|
|
886
|
+
**CORRECT — Tenant isolation via service:**
|
|
887
|
+
```csharp
|
|
888
|
+
// CORRECT: In SmartStack, tenant filtering is done in the SERVICE layer, not via HasQueryFilter.
|
|
889
|
+
public async Task<PaginatedResult<MyEntityDto>> GetAllAsync(...)
|
|
890
|
+
{
|
|
891
|
+
var query = _db.MyEntities
|
|
892
|
+
.Where(x => x.TenantId == _currentUser.TenantId) // MANDATORY runtime filter
|
|
893
|
+
.AsNoTracking();
|
|
894
|
+
// ...
|
|
895
|
+
}
|
|
896
|
+
```
|
|
897
|
+
|
|
898
|
+
**Why it's wrong:** `HasQueryFilter(e => e.TenantId != Guid.Empty)` is a **static filter** — it only removes records with empty GUIDs. It does NOT restrict data to the current tenant. This is an **OWASP A01 Broken Access Control** vulnerability.
|
|
899
|
+
|
|
900
|
+
---
|
|
901
|
+
|
|
902
|
+
### Anti-Pattern 2: `List<T>` instead of `PaginatedResult<T>` for GetAll
|
|
903
|
+
|
|
904
|
+
**INCORRECT — No pagination:**
|
|
905
|
+
```csharp
|
|
906
|
+
// WRONG: Returns all records at once — no pagination, no totalCount
|
|
907
|
+
public async Task<List<MyEntityDto>> GetAllAsync(CancellationToken ct)
|
|
908
|
+
{
|
|
909
|
+
return await _db.MyEntities
|
|
910
|
+
.Where(x => x.TenantId == _currentUser.TenantId)
|
|
911
|
+
.Select(x => new MyEntityDto(x.Id, x.Code, x.Name))
|
|
912
|
+
.ToListAsync(ct);
|
|
913
|
+
}
|
|
914
|
+
```
|
|
915
|
+
|
|
916
|
+
**CORRECT — Paginated with search:**
|
|
917
|
+
```csharp
|
|
918
|
+
// CORRECT: Returns PaginatedResult<T> with search, page, pageSize
|
|
919
|
+
public async Task<PaginatedResult<MyEntityDto>> GetAllAsync(
|
|
920
|
+
string? search = null, int page = 1, int pageSize = 20, CancellationToken ct = default)
|
|
921
|
+
{
|
|
922
|
+
var query = _db.MyEntities.Where(x => x.TenantId == _currentUser.TenantId).AsNoTracking();
|
|
923
|
+
if (!string.IsNullOrWhiteSpace(search))
|
|
924
|
+
query = query.Where(x => x.Name.Contains(search) || x.Code.Contains(search));
|
|
925
|
+
var totalCount = await query.CountAsync(ct);
|
|
926
|
+
var items = await query.OrderBy(x => x.Name).Skip((page - 1) * pageSize).Take(pageSize)
|
|
927
|
+
.Select(x => new MyEntityDto(x.Id, x.Code, x.Name, x.CreatedAt)).ToListAsync(ct);
|
|
928
|
+
return new PaginatedResult<MyEntityDto>(items, totalCount, page, pageSize);
|
|
929
|
+
}
|
|
930
|
+
```
|
|
931
|
+
|
|
932
|
+
**Why it's wrong:** `List<T>` loads ALL records into memory. It also breaks `EntityLookup` which requires `{ items, totalCount }` response format.
|
|
933
|
+
|
|
934
|
+
---
|
|
935
|
+
|
|
936
|
+
### Anti-Pattern 3: Missing `IAuditableEntity` on tenant entities
|
|
937
|
+
|
|
938
|
+
**INCORRECT — No audit trail:**
|
|
939
|
+
```csharp
|
|
940
|
+
// WRONG: Tenant entity without IAuditableEntity
|
|
941
|
+
public class MyEntity : BaseEntity, ITenantEntity
|
|
942
|
+
{
|
|
943
|
+
public Guid TenantId { get; private set; }
|
|
944
|
+
public string Code { get; private set; } = null!;
|
|
945
|
+
}
|
|
946
|
+
```
|
|
947
|
+
|
|
948
|
+
**CORRECT — Always pair ITenantEntity with IAuditableEntity:**
|
|
949
|
+
```csharp
|
|
950
|
+
public class MyEntity : BaseEntity, ITenantEntity, IAuditableEntity
|
|
951
|
+
{
|
|
952
|
+
public Guid TenantId { get; private set; }
|
|
953
|
+
public string? CreatedBy { get; set; }
|
|
954
|
+
public string? UpdatedBy { get; set; }
|
|
955
|
+
public string Code { get; private set; } = null!;
|
|
956
|
+
}
|
|
957
|
+
```
|
|
958
|
+
|
|
959
|
+
**Why it's wrong:** Without `IAuditableEntity`, there is no record of who created or modified data. Mandatory for compliance in multi-tenant environments.
|
|
960
|
+
|
|
961
|
+
---
|
|
962
|
+
|
|
963
|
+
### Anti-Pattern 4: Code auto-generation with `Count() + 1`
|
|
964
|
+
|
|
965
|
+
**INCORRECT — Race condition:**
|
|
966
|
+
```csharp
|
|
967
|
+
// WRONG: Two concurrent requests get the same count
|
|
968
|
+
var count = await _db.MyEntities.Where(x => x.TenantId == tenantId).CountAsync(ct);
|
|
969
|
+
return $"emp-{(count + 1):D5}";
|
|
970
|
+
```
|
|
971
|
+
|
|
972
|
+
**CORRECT — Use `ICodeGenerator<T>.NextCodeAsync()` (atomic with retry):**
|
|
973
|
+
```csharp
|
|
974
|
+
private readonly ICodeGenerator<MyEntity> _codeGenerator;
|
|
975
|
+
|
|
976
|
+
// In CreateAsync:
|
|
977
|
+
var code = await _codeGenerator.NextCodeAsync(ct);
|
|
978
|
+
var entity = MyEntity.Create(tenantId, code, dto.Name, createdBy: null);
|
|
979
|
+
```
|
|
980
|
+
|
|
981
|
+
**Why it's wrong:** `Count() + 1` causes **race conditions** — concurrent requests generate duplicate codes. `ICodeGenerator<T>` uses `OrderByDescending` on existing codes + retry on unique constraint violation for safe concurrency.
|
|
982
|
+
|
|
983
|
+
**Key rules when using auto-generated codes:**
|
|
984
|
+
- **REMOVE** `Code` from `CreateDto` (auto-generated, not user-provided)
|
|
985
|
+
- **KEEP** `Code` in `ResponseDto` (returned to frontend)
|
|
986
|
+
- Register `ICodeGenerator<T>` in DI with `CodePatternConfig`
|
|
987
|
+
- Code regex in validators: `^[a-z0-9_-]+$` (supports hyphens)
|
|
988
|
+
|
|
989
|
+
**Full reference:** See `references/code-generation.md` for strategies (sequential, timestamp, yearly, UUID), volume-to-digits calculation, and complete implementation patterns.
|
|
990
|
+
|
|
991
|
+
---
|
|
992
|
+
|
|
993
|
+
### Anti-Pattern 5: Missing Update validator
|
|
994
|
+
|
|
995
|
+
**INCORRECT — Only CreateValidator:**
|
|
996
|
+
```csharp
|
|
997
|
+
public class CreateMyEntityDtoValidator : AbstractValidator<CreateMyEntityDto>
|
|
998
|
+
{
|
|
999
|
+
public CreateMyEntityDtoValidator()
|
|
1000
|
+
{
|
|
1001
|
+
RuleFor(x => x.Code).NotEmpty().MaximumLength(100);
|
|
1002
|
+
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
// No UpdateMyEntityDtoValidator exists!
|
|
1006
|
+
```
|
|
1007
|
+
|
|
1008
|
+
**CORRECT — Always create validators in pairs:**
|
|
1009
|
+
```csharp
|
|
1010
|
+
public class CreateMyEntityDtoValidator : AbstractValidator<CreateMyEntityDto> { /* ... */ }
|
|
1011
|
+
public class UpdateMyEntityDtoValidator : AbstractValidator<UpdateMyEntityDto>
|
|
1012
|
+
{
|
|
1013
|
+
public UpdateMyEntityDtoValidator()
|
|
1014
|
+
{
|
|
1015
|
+
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
```
|
|
1019
|
+
|
|
1020
|
+
**Why it's wrong:** Without an `UpdateValidator`, the Update endpoint accepts **any data without validation**.
|
|
1021
|
+
|
|
1022
|
+
---
|
|
1023
|
+
|
|
1024
|
+
### Anti-Pattern 6: `TenantId!.Value` null-forgiving operator (RUNTIME CRASH)
|
|
1025
|
+
|
|
1026
|
+
The `!` (null-forgiving) operator followed by `.Value` on a `Guid?` suppresses compiler warnings but **throws `InvalidOperationException` at runtime** when TenantId is null.
|
|
1027
|
+
|
|
1028
|
+
**INCORRECT — Crashes with 500 Internal Server Error:**
|
|
1029
|
+
```csharp
|
|
1030
|
+
// WRONG: Throws InvalidOperationException("Nullable object must have a value") → 500 error
|
|
1031
|
+
public async Task<PaginatedResult<MyEntityDto>> GetAllAsync(...)
|
|
1032
|
+
{
|
|
1033
|
+
var tenantId = _currentTenant.TenantId!.Value; // CRASH if no tenant context
|
|
1034
|
+
var query = _db.MyEntities.Where(x => x.TenantId == tenantId);
|
|
1035
|
+
// ...
|
|
1036
|
+
}
|
|
1037
|
+
```
|
|
1038
|
+
|
|
1039
|
+
**CORRECT — Clean 400 via GlobalExceptionHandlerMiddleware:**
|
|
1040
|
+
```csharp
|
|
1041
|
+
// CORRECT: Throws TenantContextRequiredException → middleware converts to 400 Bad Request
|
|
1042
|
+
public async Task<PaginatedResult<MyEntityDto>> GetAllAsync(...)
|
|
1043
|
+
{
|
|
1044
|
+
var tenantId = _currentTenant.TenantId
|
|
1045
|
+
?? throw new TenantContextRequiredException();
|
|
1046
|
+
var query = _db.MyEntities.Where(x => x.TenantId == tenantId);
|
|
1047
|
+
// ...
|
|
1048
|
+
}
|
|
1049
|
+
```
|
|
1050
|
+
|
|
1051
|
+
**Why `!.Value` is wrong:** When a user hits an API via Swagger with a valid JWT but no tenant context (missing `X-Tenant-Slug` header), `TenantId` is null. The `!.Value` pattern produces an opaque `500 Internal Server Error` instead of a clear `400 Bad Request` with an actionable message.
|
|
1052
|
+
|
|
1053
|
+
**Why `UnauthorizedAccessException` is wrong:** A missing tenant is NOT an auth failure — the JWT is valid, `[Authorize]` passed. Using `UnauthorizedAccessException` returns 401, which triggers the frontend interceptor to clear the token and redirect to login. Use `TenantContextRequiredException` instead (returns 400, does not clear the token).
|