@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,1555 +1,1555 @@
|
|
|
1
|
-
# SmartStack Controller Templates
|
|
2
|
-
|
|
3
|
-
> **⚠️ OBSOLETE - DO NOT USE THESE TEMPLATES MANUALLY**
|
|
4
|
-
>
|
|
5
|
-
> **The `/controller` skill now uses the MCP `scaffold_extension` tool to generate controllers.**
|
|
6
|
-
> These templates are kept for reference only. All controller generation MUST go through the MCP to ensure:
|
|
7
|
-
> - ✅ `[NavRoute]` attribute is included for frontend/backend sync
|
|
8
|
-
> - ✅ Permissions are correctly generated
|
|
9
|
-
> - ✅ Consistency with SmartStack conventions
|
|
10
|
-
>
|
|
11
|
-
> **To generate a controller, use:** `/controller` skill which calls MCP `scaffold_extension` automatically.
|
|
12
|
-
>
|
|
13
|
-
> **If you modify these templates, they will NOT be used.** The MCP templates in `templates/mcp-scaffolding/controller.cs.hbs` are the source of truth.
|
|
14
|
-
|
|
15
|
-
---
|
|
16
|
-
|
|
17
|
-
## Template CRUD Controller (Standard)
|
|
18
|
-
|
|
19
|
-
```csharp
|
|
20
|
-
// src/SmartStack.Api/Controllers/{Area}/{Module}Controller.cs
|
|
21
|
-
|
|
22
|
-
using Microsoft.AspNetCore.Authorization;
|
|
23
|
-
using Microsoft.AspNetCore.Mvc;
|
|
24
|
-
using Microsoft.EntityFrameworkCore;
|
|
25
|
-
using SmartStack.Application.Common.Authorization;
|
|
26
|
-
using SmartStack.Application.Common.Interfaces;
|
|
27
|
-
using SmartStack.Api.Authorization;
|
|
28
|
-
using SmartStack.Domain.{DomainNamespace};
|
|
29
|
-
|
|
30
|
-
namespace SmartStack.Api.Controllers.{Area};
|
|
31
|
-
|
|
32
|
-
[ApiController]
|
|
33
|
-
[Route("api/{area-kebab}/{module-kebab}")]
|
|
34
|
-
[Authorize]
|
|
35
|
-
[Tags("{Module}")]
|
|
36
|
-
public class {Module}Controller : ControllerBase
|
|
37
|
-
{
|
|
38
|
-
private readonly IApplicationDbContext _context;
|
|
39
|
-
private readonly ICurrentUserService _currentUser;
|
|
40
|
-
private readonly ILogger<{Module}Controller> _logger;
|
|
41
|
-
|
|
42
|
-
public {Module}Controller(
|
|
43
|
-
IApplicationDbContext context,
|
|
44
|
-
ICurrentUserService currentUser,
|
|
45
|
-
ILogger<{Module}Controller> logger)
|
|
46
|
-
{
|
|
47
|
-
_context = context;
|
|
48
|
-
_currentUser = currentUser;
|
|
49
|
-
_logger = logger;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
#region GET - List with Pagination
|
|
53
|
-
|
|
54
|
-
[HttpGet]
|
|
55
|
-
[RequirePermission(Permissions.{PermissionClass}.View)]
|
|
56
|
-
[ProducesResponseType(typeof(PaginatedResult<{Entity}ListDto>), StatusCodes.Status200OK)]
|
|
57
|
-
public async Task<ActionResult<PaginatedResult<{Entity}ListDto>>> Get{Module}(
|
|
58
|
-
[FromQuery] int page = 1,
|
|
59
|
-
[FromQuery] int pageSize = 20,
|
|
60
|
-
[FromQuery] string? search = null,
|
|
61
|
-
CancellationToken cancellationToken = default)
|
|
62
|
-
{
|
|
63
|
-
var query = _context.{DbSet}.AsQueryable();
|
|
64
|
-
|
|
65
|
-
// Search filter
|
|
66
|
-
if (!string.IsNullOrWhiteSpace(search))
|
|
67
|
-
{
|
|
68
|
-
var searchLower = search.ToLower();
|
|
69
|
-
query = query.Where(x =>
|
|
70
|
-
x.Name.ToLower().Contains(searchLower) ||
|
|
71
|
-
x.Description != null && x.Description.ToLower().Contains(searchLower));
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
var totalCount = await query.CountAsync(cancellationToken);
|
|
75
|
-
|
|
76
|
-
var items = await query
|
|
77
|
-
.OrderBy(x => x.Name)
|
|
78
|
-
.Skip((page - 1) * pageSize)
|
|
79
|
-
.Take(pageSize)
|
|
80
|
-
.Select(x => new {Entity}ListDto(
|
|
81
|
-
x.Id,
|
|
82
|
-
x.Name,
|
|
83
|
-
x.Description,
|
|
84
|
-
x.IsActive,
|
|
85
|
-
x.CreatedAt
|
|
86
|
-
))
|
|
87
|
-
.ToListAsync(cancellationToken);
|
|
88
|
-
|
|
89
|
-
_logger.LogInformation("User {User} retrieved {Count} {Module}",
|
|
90
|
-
_currentUser.Email, items.Count, "{Module}");
|
|
91
|
-
|
|
92
|
-
return Ok(new PaginatedResult<{Entity}ListDto>(items, totalCount, page, pageSize));
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
#endregion
|
|
96
|
-
|
|
97
|
-
#region GET - Single by ID
|
|
98
|
-
|
|
99
|
-
[HttpGet("{id:guid}")]
|
|
100
|
-
[RequirePermission(Permissions.{PermissionClass}.View)]
|
|
101
|
-
[ProducesResponseType(typeof({Entity}DetailDto), StatusCodes.Status200OK)]
|
|
102
|
-
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
103
|
-
public async Task<ActionResult<{Entity}DetailDto>> Get{Entity}(
|
|
104
|
-
Guid id,
|
|
105
|
-
CancellationToken cancellationToken)
|
|
106
|
-
{
|
|
107
|
-
var entity = await _context.{DbSet}
|
|
108
|
-
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
|
109
|
-
|
|
110
|
-
if (entity == null)
|
|
111
|
-
return NotFound(new { message = "{Entity} not found" });
|
|
112
|
-
|
|
113
|
-
return Ok(new {Entity}DetailDto(
|
|
114
|
-
entity.Id,
|
|
115
|
-
entity.Name,
|
|
116
|
-
entity.Description,
|
|
117
|
-
entity.IsActive,
|
|
118
|
-
entity.CreatedAt,
|
|
119
|
-
entity.UpdatedAt
|
|
120
|
-
));
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
#endregion
|
|
124
|
-
|
|
125
|
-
#region POST - Create
|
|
126
|
-
|
|
127
|
-
[HttpPost]
|
|
128
|
-
[RequirePermission(Permissions.{PermissionClass}.Create)]
|
|
129
|
-
[ProducesResponseType(typeof({Entity}DetailDto), StatusCodes.Status201Created)]
|
|
130
|
-
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
131
|
-
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
|
132
|
-
public async Task<ActionResult<{Entity}DetailDto>> Create{Entity}(
|
|
133
|
-
[FromBody] Create{Entity}Request request,
|
|
134
|
-
CancellationToken cancellationToken)
|
|
135
|
-
{
|
|
136
|
-
// Check for duplicates
|
|
137
|
-
var exists = await _context.{DbSet}
|
|
138
|
-
.AnyAsync(x => x.Name == request.Name, cancellationToken);
|
|
139
|
-
|
|
140
|
-
if (exists)
|
|
141
|
-
return Conflict(new { message = "{Entity} with this name already exists" });
|
|
142
|
-
|
|
143
|
-
var entity = {Entity}.Create(
|
|
144
|
-
request.Name,
|
|
145
|
-
request.Description
|
|
146
|
-
);
|
|
147
|
-
|
|
148
|
-
_context.{DbSet}.Add(entity);
|
|
149
|
-
await _context.SaveChangesAsync(cancellationToken);
|
|
150
|
-
|
|
151
|
-
_logger.LogInformation("User {User} created {Entity} {EntityId} ({Name})",
|
|
152
|
-
_currentUser.Email, entity.Id, entity.Name);
|
|
153
|
-
|
|
154
|
-
return CreatedAtAction(
|
|
155
|
-
nameof(Get{Entity}),
|
|
156
|
-
new { id = entity.Id },
|
|
157
|
-
new {Entity}DetailDto(
|
|
158
|
-
entity.Id,
|
|
159
|
-
entity.Name,
|
|
160
|
-
entity.Description,
|
|
161
|
-
entity.IsActive,
|
|
162
|
-
entity.CreatedAt,
|
|
163
|
-
entity.UpdatedAt
|
|
164
|
-
));
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
#endregion
|
|
168
|
-
|
|
169
|
-
#region PUT - Update
|
|
170
|
-
|
|
171
|
-
[HttpPut("{id:guid}")]
|
|
172
|
-
[RequirePermission(Permissions.{PermissionClass}.Update)]
|
|
173
|
-
[ProducesResponseType(typeof({Entity}DetailDto), StatusCodes.Status200OK)]
|
|
174
|
-
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
175
|
-
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
|
176
|
-
public async Task<ActionResult<{Entity}DetailDto>> Update{Entity}(
|
|
177
|
-
Guid id,
|
|
178
|
-
[FromBody] Update{Entity}Request request,
|
|
179
|
-
CancellationToken cancellationToken)
|
|
180
|
-
{
|
|
181
|
-
var entity = await _context.{DbSet}
|
|
182
|
-
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
|
183
|
-
|
|
184
|
-
if (entity == null)
|
|
185
|
-
return NotFound(new { message = "{Entity} not found" });
|
|
186
|
-
|
|
187
|
-
// Check for duplicate name (excluding current)
|
|
188
|
-
if (!string.IsNullOrEmpty(request.Name))
|
|
189
|
-
{
|
|
190
|
-
var duplicate = await _context.{DbSet}
|
|
191
|
-
.AnyAsync(x => x.Name == request.Name && x.Id != id, cancellationToken);
|
|
192
|
-
|
|
193
|
-
if (duplicate)
|
|
194
|
-
return Conflict(new { message = "{Entity} with this name already exists" });
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
entity.Update(
|
|
198
|
-
request.Name ?? entity.Name,
|
|
199
|
-
request.Description ?? entity.Description
|
|
200
|
-
);
|
|
201
|
-
|
|
202
|
-
await _context.SaveChangesAsync(cancellationToken);
|
|
203
|
-
|
|
204
|
-
_logger.LogInformation("User {User} updated {Entity} {EntityId}",
|
|
205
|
-
_currentUser.Email, entity.Id);
|
|
206
|
-
|
|
207
|
-
return Ok(new {Entity}DetailDto(
|
|
208
|
-
entity.Id,
|
|
209
|
-
entity.Name,
|
|
210
|
-
entity.Description,
|
|
211
|
-
entity.IsActive,
|
|
212
|
-
entity.CreatedAt,
|
|
213
|
-
entity.UpdatedAt
|
|
214
|
-
));
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
#endregion
|
|
218
|
-
|
|
219
|
-
#region PATCH - Activate/Deactivate
|
|
220
|
-
|
|
221
|
-
[HttpPatch("{id:guid}/activate")]
|
|
222
|
-
[RequirePermission(Permissions.{PermissionClass}.Update)]
|
|
223
|
-
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
|
224
|
-
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
225
|
-
public async Task<IActionResult> Activate{Entity}(
|
|
226
|
-
Guid id,
|
|
227
|
-
CancellationToken cancellationToken)
|
|
228
|
-
{
|
|
229
|
-
var entity = await _context.{DbSet}
|
|
230
|
-
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
|
231
|
-
|
|
232
|
-
if (entity == null)
|
|
233
|
-
return NotFound(new { message = "{Entity} not found" });
|
|
234
|
-
|
|
235
|
-
entity.Activate();
|
|
236
|
-
await _context.SaveChangesAsync(cancellationToken);
|
|
237
|
-
|
|
238
|
-
_logger.LogInformation("User {User} activated {Entity} {EntityId}",
|
|
239
|
-
_currentUser.Email, entity.Id);
|
|
240
|
-
|
|
241
|
-
return NoContent();
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
[HttpPatch("{id:guid}/deactivate")]
|
|
245
|
-
[RequirePermission(Permissions.{PermissionClass}.Update)]
|
|
246
|
-
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
|
247
|
-
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
248
|
-
public async Task<IActionResult> Deactivate{Entity}(
|
|
249
|
-
Guid id,
|
|
250
|
-
CancellationToken cancellationToken)
|
|
251
|
-
{
|
|
252
|
-
var entity = await _context.{DbSet}
|
|
253
|
-
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
|
254
|
-
|
|
255
|
-
if (entity == null)
|
|
256
|
-
return NotFound(new { message = "{Entity} not found" });
|
|
257
|
-
|
|
258
|
-
entity.Deactivate();
|
|
259
|
-
await _context.SaveChangesAsync(cancellationToken);
|
|
260
|
-
|
|
261
|
-
_logger.LogWarning("User {User} deactivated {Entity} {EntityId}",
|
|
262
|
-
_currentUser.Email, entity.Id);
|
|
263
|
-
|
|
264
|
-
return NoContent();
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
#endregion
|
|
268
|
-
|
|
269
|
-
#region DELETE
|
|
270
|
-
|
|
271
|
-
[HttpDelete("{id:guid}")]
|
|
272
|
-
[RequirePermission(Permissions.{PermissionClass}.Delete)]
|
|
273
|
-
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
|
274
|
-
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
275
|
-
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
276
|
-
public async Task<IActionResult> Delete{Entity}(
|
|
277
|
-
Guid id,
|
|
278
|
-
CancellationToken cancellationToken)
|
|
279
|
-
{
|
|
280
|
-
var entity = await _context.{DbSet}
|
|
281
|
-
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
|
282
|
-
|
|
283
|
-
if (entity == null)
|
|
284
|
-
return NotFound(new { message = "{Entity} not found" });
|
|
285
|
-
|
|
286
|
-
// Check for dependencies before deletion
|
|
287
|
-
// var hasReferences = await _context.ChildEntities.AnyAsync(x => x.{Entity}Id == id, ct);
|
|
288
|
-
// if (hasReferences)
|
|
289
|
-
// return BadRequest(new { message = "Cannot delete: has dependent records" });
|
|
290
|
-
|
|
291
|
-
_context.{DbSet}.Remove(entity);
|
|
292
|
-
await _context.SaveChangesAsync(cancellationToken);
|
|
293
|
-
|
|
294
|
-
_logger.LogWarning("User {User} deleted {Entity} {EntityId} ({Name})",
|
|
295
|
-
_currentUser.Email, id, entity.Name);
|
|
296
|
-
|
|
297
|
-
return NoContent();
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
#endregion
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
#region DTOs
|
|
304
|
-
|
|
305
|
-
public record {Entity}ListDto(
|
|
306
|
-
Guid Id,
|
|
307
|
-
string Name,
|
|
308
|
-
string? Description,
|
|
309
|
-
bool IsActive,
|
|
310
|
-
DateTime CreatedAt
|
|
311
|
-
);
|
|
312
|
-
|
|
313
|
-
public record {Entity}DetailDto(
|
|
314
|
-
Guid Id,
|
|
315
|
-
string Name,
|
|
316
|
-
string? Description,
|
|
317
|
-
bool IsActive,
|
|
318
|
-
DateTime CreatedAt,
|
|
319
|
-
DateTime? UpdatedAt
|
|
320
|
-
);
|
|
321
|
-
|
|
322
|
-
public record Create{Entity}Request(
|
|
323
|
-
string Name,
|
|
324
|
-
string? Description
|
|
325
|
-
);
|
|
326
|
-
|
|
327
|
-
public record Update{Entity}Request(
|
|
328
|
-
string? Name,
|
|
329
|
-
string? Description
|
|
330
|
-
);
|
|
331
|
-
|
|
332
|
-
public record PaginatedResult<T>(
|
|
333
|
-
List<T> Items,
|
|
334
|
-
int TotalCount,
|
|
335
|
-
int Page,
|
|
336
|
-
int PageSize
|
|
337
|
-
)
|
|
338
|
-
{
|
|
339
|
-
public int TotalPages => (int)Math.Ceiling((double)TotalCount / PageSize);
|
|
340
|
-
public bool HasPreviousPage => Page > 1;
|
|
341
|
-
public bool HasNextPage => Page < TotalPages;
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
#endregion
|
|
345
|
-
```
|
|
346
|
-
|
|
347
|
-
---
|
|
348
|
-
|
|
349
|
-
## Template Auth Controller (Login/Logout)
|
|
350
|
-
|
|
351
|
-
```csharp
|
|
352
|
-
// src/SmartStack.Api/Controllers/AuthController.cs
|
|
353
|
-
// NOTE: This controller already exists - use as reference for auth patterns
|
|
354
|
-
|
|
355
|
-
using Microsoft.AspNetCore.Authorization;
|
|
356
|
-
using Microsoft.AspNetCore.Mvc;
|
|
357
|
-
using Microsoft.EntityFrameworkCore;
|
|
358
|
-
using Microsoft.Extensions.Options;
|
|
359
|
-
using SmartStack.Application.Common.Interfaces;
|
|
360
|
-
using SmartStack.Application.Common.Settings;
|
|
361
|
-
using SmartStack.Domain.Platform.Administration.Users;
|
|
362
|
-
|
|
363
|
-
namespace SmartStack.Api.Controllers;
|
|
364
|
-
|
|
365
|
-
[ApiController]
|
|
366
|
-
[Route("api/[controller]")]
|
|
367
|
-
public class AuthController : ControllerBase
|
|
368
|
-
{
|
|
369
|
-
private readonly IApplicationDbContext _context;
|
|
370
|
-
private readonly IPasswordService _passwordService;
|
|
371
|
-
private readonly IJwtService _jwtService;
|
|
372
|
-
private readonly IUserSessionService _sessionService;
|
|
373
|
-
private readonly ISessionValidationService _sessionValidationService;
|
|
374
|
-
private readonly SessionSettings _sessionSettings;
|
|
375
|
-
private readonly ILogger<AuthController> _logger;
|
|
376
|
-
|
|
377
|
-
// ... Constructor avec tous les services auth
|
|
378
|
-
|
|
379
|
-
#region Login - LOGS CRITIQUES OBLIGATOIRES
|
|
380
|
-
|
|
381
|
-
[HttpPost("login")]
|
|
382
|
-
[AllowAnonymous]
|
|
383
|
-
[ProducesResponseType(typeof(LoginResponse), StatusCodes.Status200OK)]
|
|
384
|
-
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status401Unauthorized)]
|
|
385
|
-
public async Task<ActionResult<LoginResponse>> Login(
|
|
386
|
-
[FromBody] LoginRequest request,
|
|
387
|
-
CancellationToken cancellationToken)
|
|
388
|
-
{
|
|
389
|
-
var ipAddress = GetClientIpAddress();
|
|
390
|
-
var userAgent = Request.Headers.UserAgent.ToString();
|
|
391
|
-
|
|
392
|
-
var user = await _context.Users
|
|
393
|
-
.Include(u => u.UserRoles)
|
|
394
|
-
.ThenInclude(ur => ur.Role)
|
|
395
|
-
.ThenInclude(r => r!.RolePermissions)
|
|
396
|
-
.ThenInclude(rp => rp.Permission)
|
|
397
|
-
.FirstOrDefaultAsync(u => u.Email == request.Email, cancellationToken);
|
|
398
|
-
|
|
399
|
-
// ============================================
|
|
400
|
-
// LOGS CRITIQUES - NE JAMAIS OMETTRE
|
|
401
|
-
// ============================================
|
|
402
|
-
|
|
403
|
-
if (user == null)
|
|
404
|
-
{
|
|
405
|
-
// WARNING: User not found (potential enumeration)
|
|
406
|
-
_logger.LogWarning(
|
|
407
|
-
"Login failed: User not found - {Email} from {IpAddress}",
|
|
408
|
-
request.Email, ipAddress);
|
|
409
|
-
return Unauthorized(new ErrorResponse("Identifiants invalides", "INVALID_CREDENTIALS"));
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
if (!user.IsActive)
|
|
413
|
-
{
|
|
414
|
-
// WARNING: Disabled account
|
|
415
|
-
_logger.LogWarning(
|
|
416
|
-
"Login failed: Account disabled - {Email} from {IpAddress}",
|
|
417
|
-
request.Email, ipAddress);
|
|
418
|
-
return Unauthorized(new ErrorResponse("Account disabled", "ACCOUNT_DISABLED"));
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
if (user.IsLocked)
|
|
422
|
-
{
|
|
423
|
-
// CRITICAL: Locked account attempt
|
|
424
|
-
_logger.LogCritical(
|
|
425
|
-
"SECURITY: Login attempt on locked account - User: {Email} (ID: {UserId}) from {IpAddress}",
|
|
426
|
-
request.Email, user.Id, ipAddress);
|
|
427
|
-
return Unauthorized(new ErrorResponse("Account locked", "ACCOUNT_LOCKED_BY_ADMIN"));
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
// Check brute force protection
|
|
431
|
-
var recentFailedAttempts = await _context.UserSessions
|
|
432
|
-
.Where(s => s.UserId == user.Id && !s.IsSuccessful && s.LoginAt > DateTime.UtcNow.AddMinutes(-15))
|
|
433
|
-
.CountAsync(cancellationToken);
|
|
434
|
-
|
|
435
|
-
if (recentFailedAttempts >= 5)
|
|
436
|
-
{
|
|
437
|
-
// CRITICAL: Too many failed attempts
|
|
438
|
-
_logger.LogCritical(
|
|
439
|
-
"SECURITY: Account temporarily locked due to brute force - User: {Email} (ID: {UserId}) from {IpAddress}",
|
|
440
|
-
request.Email, user.Id, ipAddress);
|
|
441
|
-
return Unauthorized(new ErrorResponse("Account temporarily locked", "ACCOUNT_LOCKED"));
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
|
|
445
|
-
{
|
|
446
|
-
// WARNING: Invalid password with remaining attempts
|
|
447
|
-
var remainingAttempts = 5 - recentFailedAttempts - 1;
|
|
448
|
-
_logger.LogWarning(
|
|
449
|
-
"Login failed: Invalid password - {Email} from {IpAddress}, remaining attempts: {Remaining}",
|
|
450
|
-
request.Email, ipAddress, remainingAttempts);
|
|
451
|
-
|
|
452
|
-
// Log failed attempt to session
|
|
453
|
-
await _sessionService.LogFailedLoginAsync(user.Id, ipAddress, userAgent, cancellationToken);
|
|
454
|
-
|
|
455
|
-
return Unauthorized(new ErrorResponse("Mot de passe incorrect", "INVALID_PASSWORD"));
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
// ============================================
|
|
459
|
-
// SUCCESS - Generate tokens
|
|
460
|
-
// ============================================
|
|
461
|
-
|
|
462
|
-
var roles = user.UserRoles.Select(ur => ur.Role!.Name).ToList();
|
|
463
|
-
var permissions = user.UserRoles
|
|
464
|
-
.SelectMany(ur => ur.Role!.RolePermissions)
|
|
465
|
-
.Select(rp => rp.Permission!.Path)
|
|
466
|
-
.Distinct()
|
|
467
|
-
.ToList();
|
|
468
|
-
|
|
469
|
-
var accessToken = _jwtService.GenerateAccessToken(user, roles, permissions);
|
|
470
|
-
var refreshToken = _jwtService.GenerateRefreshToken();
|
|
471
|
-
|
|
472
|
-
await _sessionService.LogLoginAsync(user.Id, accessToken, ipAddress, userAgent, cancellationToken: cancellationToken);
|
|
473
|
-
|
|
474
|
-
// INFO: Successful login
|
|
475
|
-
_logger.LogInformation(
|
|
476
|
-
"User logged in successfully: {Email} from {IpAddress}",
|
|
477
|
-
user.Email, ipAddress);
|
|
478
|
-
|
|
479
|
-
return Ok(new LoginResponse(accessToken, refreshToken, /* UserInfo */));
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
#endregion
|
|
483
|
-
|
|
484
|
-
#region Logout
|
|
485
|
-
|
|
486
|
-
[HttpPost("logout")]
|
|
487
|
-
[Authorize]
|
|
488
|
-
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
|
489
|
-
public async Task<IActionResult> Logout(CancellationToken cancellationToken)
|
|
490
|
-
{
|
|
491
|
-
var token = Request.Headers.Authorization.ToString().Replace("Bearer ", "");
|
|
492
|
-
await _sessionService.LogLogoutAsync(token, cancellationToken);
|
|
493
|
-
|
|
494
|
-
_logger.LogInformation("User logged out: {UserId}", User.FindFirst("sub")?.Value);
|
|
495
|
-
|
|
496
|
-
return NoContent();
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
#endregion
|
|
500
|
-
|
|
501
|
-
#region Change Password - LOG WARNING
|
|
502
|
-
|
|
503
|
-
[HttpPost("change-password")]
|
|
504
|
-
[Authorize]
|
|
505
|
-
[ProducesResponseType(typeof(ChangePasswordResponse), StatusCodes.Status200OK)]
|
|
506
|
-
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)]
|
|
507
|
-
public async Task<ActionResult<ChangePasswordResponse>> ChangePassword(
|
|
508
|
-
[FromBody] ChangePasswordRequest request,
|
|
509
|
-
CancellationToken cancellationToken)
|
|
510
|
-
{
|
|
511
|
-
// ... validation logic
|
|
512
|
-
|
|
513
|
-
user.UpdatePassword(newPasswordHash);
|
|
514
|
-
await _context.SaveChangesAsync(cancellationToken);
|
|
515
|
-
|
|
516
|
-
// Invalidate ALL sessions after password change
|
|
517
|
-
await _sessionService.InvalidateAllUserSessionsAsync(userId, "Password changed", cancellationToken);
|
|
518
|
-
|
|
519
|
-
// WARNING: Sensitive operation
|
|
520
|
-
_logger.LogWarning(
|
|
521
|
-
"Password changed for user {Email} - All sessions invalidated",
|
|
522
|
-
user.Email);
|
|
523
|
-
|
|
524
|
-
return Ok(new ChangePasswordResponse("Password changed", true));
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
#endregion
|
|
528
|
-
|
|
529
|
-
private string GetClientIpAddress()
|
|
530
|
-
{
|
|
531
|
-
var forwardedFor = Request.Headers["X-Forwarded-For"].FirstOrDefault();
|
|
532
|
-
if (!string.IsNullOrEmpty(forwardedFor))
|
|
533
|
-
return forwardedFor.Split(',')[0].Trim();
|
|
534
|
-
return HttpContext.Connection.RemoteIpAddress?.ToString() ?? "Unknown";
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
```
|
|
538
|
-
|
|
539
|
-
---
|
|
540
|
-
|
|
541
|
-
## Template Permissions Constants
|
|
542
|
-
|
|
543
|
-
```csharp
|
|
544
|
-
// src/SmartStack.Application/Common/Authorization/Permissions.cs
|
|
545
|
-
// ADD to existing class
|
|
546
|
-
|
|
547
|
-
public static class Permissions
|
|
548
|
-
{
|
|
549
|
-
// ... existing permissions ...
|
|
550
|
-
|
|
551
|
-
public static class {PermissionClass}
|
|
552
|
-
{
|
|
553
|
-
public const string Access = "{permission.path}";
|
|
554
|
-
public const string View = "{permission.path}.read";
|
|
555
|
-
public const string Create = "{permission.path}.create";
|
|
556
|
-
public const string Update = "{permission.path}.update";
|
|
557
|
-
public const string Delete = "{permission.path}.delete";
|
|
558
|
-
// Optional depending on module
|
|
559
|
-
public const string Assign = "{permission.path}.assign";
|
|
560
|
-
public const string Execute = "{permission.path}.execute";
|
|
561
|
-
public const string Export = "{permission.path}.export";
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
```
|
|
565
|
-
|
|
566
|
-
---
|
|
567
|
-
|
|
568
|
-
## Template PermissionConfiguration Seed
|
|
569
|
-
|
|
570
|
-
> **CRITICAL:** This template is MANDATORY. Without these entries, all API calls will return 403 Forbidden.
|
|
571
|
-
|
|
572
|
-
```csharp
|
|
573
|
-
// src/SmartStack.Infrastructure/Persistence/Configurations/Navigation/PermissionConfiguration.cs
|
|
574
|
-
// ADD in Configure() method, HasData section
|
|
575
|
-
|
|
576
|
-
// ============================================
|
|
577
|
-
// STEP 1: Declare ModuleId
|
|
578
|
-
// ============================================
|
|
579
|
-
// Check in ModuleConfiguration.cs if module already exists
|
|
580
|
-
// Otherwise, create the module first via /application skill
|
|
581
|
-
|
|
582
|
-
var {module}ModuleId = Guid.Parse("{MODULE-GUID}"); // Get from ModuleConfiguration.cs
|
|
583
|
-
|
|
584
|
-
// ============================================
|
|
585
|
-
// STEP 2: Add permissions (HasData)
|
|
586
|
-
// ============================================
|
|
587
|
-
|
|
588
|
-
// Pattern: {application}.{module}.{action}
|
|
589
|
-
// Example: administration.users.read
|
|
590
|
-
|
|
591
|
-
var seedDate = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
|
592
|
-
|
|
593
|
-
builder.HasData(
|
|
594
|
-
// Wildcard permission (full module access)
|
|
595
|
-
new
|
|
596
|
-
{
|
|
597
|
-
Id = Guid.Parse("{NOUVEAU-GUID-1}"),
|
|
598
|
-
Path = "{application}.{module}.*",
|
|
599
|
-
Level = PermissionLevel.Module,
|
|
600
|
-
Action = (PermissionAction?)null,
|
|
601
|
-
IsWildcard = true,
|
|
602
|
-
ModuleId = {module}ModuleId,
|
|
603
|
-
Description = "Full {module} management",
|
|
604
|
-
CreatedAt = seedDate
|
|
605
|
-
},
|
|
606
|
-
|
|
607
|
-
// Read permission
|
|
608
|
-
new
|
|
609
|
-
{
|
|
610
|
-
Id = Guid.Parse("{NOUVEAU-GUID-2}"),
|
|
611
|
-
Path = "{application}.{module}.read",
|
|
612
|
-
Level = PermissionLevel.Module,
|
|
613
|
-
Action = PermissionAction.Read,
|
|
614
|
-
IsWildcard = false,
|
|
615
|
-
ModuleId = {module}ModuleId,
|
|
616
|
-
Description = "View {module}",
|
|
617
|
-
CreatedAt = seedDate
|
|
618
|
-
},
|
|
619
|
-
|
|
620
|
-
// Create permission
|
|
621
|
-
new
|
|
622
|
-
{
|
|
623
|
-
Id = Guid.Parse("{NOUVEAU-GUID-3}"),
|
|
624
|
-
Path = "{application}.{module}.create",
|
|
625
|
-
Level = PermissionLevel.Module,
|
|
626
|
-
Action = PermissionAction.Create,
|
|
627
|
-
IsWildcard = false,
|
|
628
|
-
ModuleId = {module}ModuleId,
|
|
629
|
-
Description = "Create {module}",
|
|
630
|
-
CreatedAt = seedDate
|
|
631
|
-
},
|
|
632
|
-
|
|
633
|
-
// Update permission
|
|
634
|
-
new
|
|
635
|
-
{
|
|
636
|
-
Id = Guid.Parse("{NOUVEAU-GUID-4}"),
|
|
637
|
-
Path = "{application}.{module}.update",
|
|
638
|
-
Level = PermissionLevel.Module,
|
|
639
|
-
Action = PermissionAction.Update,
|
|
640
|
-
IsWildcard = false,
|
|
641
|
-
ModuleId = {module}ModuleId,
|
|
642
|
-
Description = "Update {module}",
|
|
643
|
-
CreatedAt = seedDate
|
|
644
|
-
},
|
|
645
|
-
|
|
646
|
-
// Delete permission
|
|
647
|
-
new
|
|
648
|
-
{
|
|
649
|
-
Id = Guid.Parse("{NOUVEAU-GUID-5}"),
|
|
650
|
-
Path = "{application}.{module}.delete",
|
|
651
|
-
Level = PermissionLevel.Module,
|
|
652
|
-
Action = PermissionAction.Delete,
|
|
653
|
-
IsWildcard = false,
|
|
654
|
-
ModuleId = {module}ModuleId,
|
|
655
|
-
Description = "Delete {module}",
|
|
656
|
-
CreatedAt = seedDate
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
// Optional actions depending on module:
|
|
660
|
-
// - PermissionAction.Assign → To assign resources/roles
|
|
661
|
-
// - PermissionAction.Execute → To execute actions (export, etc.)
|
|
662
|
-
);
|
|
663
|
-
```
|
|
664
|
-
|
|
665
|
-
### GUID Generation
|
|
666
|
-
|
|
667
|
-
```bash
|
|
668
|
-
# PowerShell (Windows)
|
|
669
|
-
[guid]::NewGuid().ToString()
|
|
670
|
-
|
|
671
|
-
# Bash (Linux/Mac)
|
|
672
|
-
uuidgen | tr '[:upper:]' '[:lower:]'
|
|
673
|
-
```
|
|
674
|
-
|
|
675
|
-
### Validation Consistency Permissions.cs ↔ PermissionConfiguration.cs
|
|
676
|
-
|
|
677
|
-
> **RULE:** Each constant in `Permissions.cs` MUST have a corresponding entry in `PermissionConfiguration.cs`
|
|
678
|
-
|
|
679
|
-
```
|
|
680
|
-
┌─────────────────────────────────────────────────────────────────────────────┐
|
|
681
|
-
│ CONSISTENCY VALIDATION │
|
|
682
|
-
├─────────────────────────────────────────────────────────────────────────────┤
|
|
683
|
-
│ │
|
|
684
|
-
│ Permissions.cs PermissionConfiguration.cs │
|
|
685
|
-
│ ────────────────────────────── ────────────────────────────────────── │
|
|
686
|
-
│ Permissions.Support.Tickets.View → Path = "support.tickets.read"│
|
|
687
|
-
│ Permissions.Support.Tickets.Create→ Path = "support.tickets.create"│
|
|
688
|
-
│ Permissions.Support.Tickets.Update→ Path = "support.tickets.update"│
|
|
689
|
-
│ Permissions.Support.Tickets.Delete→ Path = "support.tickets.delete"│
|
|
690
|
-
│ │
|
|
691
|
-
│ WARNING: COMMON ERROR: │
|
|
692
|
-
│ - Permissions.cs: "support.tickets.read" │
|
|
693
|
-
│ - PermissionConfiguration.cs: MISSING │
|
|
694
|
-
│ → Result: 403 Forbidden for ALL users │
|
|
695
|
-
│ │
|
|
696
|
-
└─────────────────────────────────────────────────────────────────────────────┘
|
|
697
|
-
```
|
|
698
|
-
|
|
699
|
-
### Post-generation commands
|
|
700
|
-
|
|
701
|
-
After adding entries in both files:
|
|
702
|
-
|
|
703
|
-
```bash
|
|
704
|
-
# 1. Create migration
|
|
705
|
-
/efcore
|
|
706
|
-
|
|
707
|
-
# 2. Apply migration
|
|
708
|
-
/efcore
|
|
709
|
-
|
|
710
|
-
# 3. Verify (optional)
|
|
711
|
-
/efcore
|
|
712
|
-
```
|
|
713
|
-
|
|
714
|
-
---
|
|
715
|
-
|
|
716
|
-
## Template Controller avec Relations
|
|
717
|
-
|
|
718
|
-
```csharp
|
|
719
|
-
// For controllers with related entities (ex: Tickets with Comments)
|
|
720
|
-
|
|
721
|
-
#region GET with Includes
|
|
722
|
-
|
|
723
|
-
[HttpGet("{id:guid}")]
|
|
724
|
-
[RequirePermission(Permissions.{PermissionClass}.View)]
|
|
725
|
-
[ProducesResponseType(typeof({Entity}DetailDto), StatusCodes.Status200OK)]
|
|
726
|
-
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
727
|
-
public async Task<ActionResult<{Entity}DetailDto>> Get{Entity}(
|
|
728
|
-
Guid id,
|
|
729
|
-
CancellationToken cancellationToken)
|
|
730
|
-
{
|
|
731
|
-
var entity = await _context.{DbSet}
|
|
732
|
-
.Include(x => x.CreatedByUser)
|
|
733
|
-
.Include(x => x.AssignedToUser)
|
|
734
|
-
.Include(x => x.Comments)
|
|
735
|
-
.ThenInclude(c => c.Author)
|
|
736
|
-
.Include(x => x.Attachments)
|
|
737
|
-
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
|
738
|
-
|
|
739
|
-
if (entity == null)
|
|
740
|
-
return NotFound(new { message = "{Entity} not found" });
|
|
741
|
-
|
|
742
|
-
return Ok(MapToDetailDto(entity));
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
#endregion
|
|
746
|
-
|
|
747
|
-
#region Nested Resources
|
|
748
|
-
|
|
749
|
-
[HttpGet("{parentId:guid}/children")]
|
|
750
|
-
[RequirePermission(Permissions.{PermissionClass}.View)]
|
|
751
|
-
[ProducesResponseType(typeof(List<ChildDto>), StatusCodes.Status200OK)]
|
|
752
|
-
public async Task<ActionResult<List<ChildDto>>> GetChildren(
|
|
753
|
-
Guid parentId,
|
|
754
|
-
CancellationToken cancellationToken)
|
|
755
|
-
{
|
|
756
|
-
var children = await _context.Children
|
|
757
|
-
.Where(x => x.ParentId == parentId)
|
|
758
|
-
.OrderByDescending(x => x.CreatedAt)
|
|
759
|
-
.Select(x => new ChildDto(x.Id, x.Name, x.CreatedAt))
|
|
760
|
-
.ToListAsync(cancellationToken);
|
|
761
|
-
|
|
762
|
-
return Ok(children);
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
[HttpPost("{parentId:guid}/children")]
|
|
766
|
-
[RequirePermission(Permissions.{PermissionClass}.Create)]
|
|
767
|
-
[ProducesResponseType(typeof(ChildDto), StatusCodes.Status201Created)]
|
|
768
|
-
public async Task<ActionResult<ChildDto>> AddChild(
|
|
769
|
-
Guid parentId,
|
|
770
|
-
[FromBody] CreateChildRequest request,
|
|
771
|
-
CancellationToken cancellationToken)
|
|
772
|
-
{
|
|
773
|
-
var parent = await _context.{DbSet}
|
|
774
|
-
.FirstOrDefaultAsync(x => x.Id == parentId, cancellationToken);
|
|
775
|
-
|
|
776
|
-
if (parent == null)
|
|
777
|
-
return NotFound(new { message = "Parent not found" });
|
|
778
|
-
|
|
779
|
-
var child = Child.Create(parentId, request.Name, _currentUser.UserId!.Value);
|
|
780
|
-
|
|
781
|
-
_context.Children.Add(child);
|
|
782
|
-
await _context.SaveChangesAsync(cancellationToken);
|
|
783
|
-
|
|
784
|
-
_logger.LogInformation("User {User} added child to {Entity} {ParentId}",
|
|
785
|
-
_currentUser.Email, parentId);
|
|
786
|
-
|
|
787
|
-
return CreatedAtAction(
|
|
788
|
-
nameof(GetChildren),
|
|
789
|
-
new { parentId },
|
|
790
|
-
new ChildDto(child.Id, child.Name, child.CreatedAt));
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
#endregion
|
|
794
|
-
```
|
|
795
|
-
|
|
796
|
-
---
|
|
797
|
-
|
|
798
|
-
## Reusable Patterns
|
|
799
|
-
|
|
800
|
-
### Error Response Standard
|
|
801
|
-
|
|
802
|
-
```csharp
|
|
803
|
-
public record ErrorResponse(string Message, string? Code = null);
|
|
804
|
-
|
|
805
|
-
// Usage:
|
|
806
|
-
return BadRequest(new ErrorResponse("Validation failed", "VALIDATION_ERROR"));
|
|
807
|
-
return Conflict(new ErrorResponse("Already exists", "DUPLICATE"));
|
|
808
|
-
return NotFound(new { message = "Resource not found" });
|
|
809
|
-
```
|
|
810
|
-
|
|
811
|
-
### Pagination Query Extension
|
|
812
|
-
|
|
813
|
-
```csharp
|
|
814
|
-
public static class QueryableExtensions
|
|
815
|
-
{
|
|
816
|
-
public static async Task<PaginatedResult<T>> ToPaginatedResultAsync<T>(
|
|
817
|
-
this IQueryable<T> query,
|
|
818
|
-
int page,
|
|
819
|
-
int pageSize,
|
|
820
|
-
CancellationToken ct = default)
|
|
821
|
-
{
|
|
822
|
-
var totalCount = await query.CountAsync(ct);
|
|
823
|
-
var items = await query
|
|
824
|
-
.Skip((page - 1) * pageSize)
|
|
825
|
-
.Take(pageSize)
|
|
826
|
-
.ToListAsync(ct);
|
|
827
|
-
|
|
828
|
-
return new PaginatedResult<T>(items, totalCount, page, pageSize);
|
|
829
|
-
}
|
|
830
|
-
}
|
|
831
|
-
```
|
|
832
|
-
|
|
833
|
-
### Log Context Pattern
|
|
834
|
-
|
|
835
|
-
```csharp
|
|
836
|
-
// Always include user context in logs
|
|
837
|
-
_logger.LogInformation(
|
|
838
|
-
"User {User} ({UserId}) performed {Action} on {Entity} {EntityId}",
|
|
839
|
-
_currentUser.Email,
|
|
840
|
-
_currentUser.UserId,
|
|
841
|
-
"Create",
|
|
842
|
-
"{Entity}",
|
|
843
|
-
entity.Id);
|
|
844
|
-
```
|
|
845
|
-
|
|
846
|
-
---
|
|
847
|
-
|
|
848
|
-
## Template Section-Level Permissions (Level 3)
|
|
849
|
-
|
|
850
|
-
> **Usage:** When a Module has multiple sub-pages/tabs with different permissions (ex: AI → Dashboard, Settings, Prompts)
|
|
851
|
-
|
|
852
|
-
### Permissions.cs - Section
|
|
853
|
-
|
|
854
|
-
```csharp
|
|
855
|
-
// src/SmartStack.Application/Common/Authorization/Permissions.cs
|
|
856
|
-
|
|
857
|
-
public static class Admin
|
|
858
|
-
{
|
|
859
|
-
public static class {Module}
|
|
860
|
-
{
|
|
861
|
-
// Section permissions (Level 3)
|
|
862
|
-
public static class {Section}
|
|
863
|
-
{
|
|
864
|
-
public const string View = "{application}.{module}.{section}.read";
|
|
865
|
-
public const string Create = "{application}.{module}.{section}.create";
|
|
866
|
-
public const string Update = "{application}.{module}.{section}.update";
|
|
867
|
-
public const string Delete = "{application}.{module}.{section}.delete";
|
|
868
|
-
public const string Execute = "{application}.{module}.{section}.execute";
|
|
869
|
-
}
|
|
870
|
-
}
|
|
871
|
-
}
|
|
872
|
-
```
|
|
873
|
-
|
|
874
|
-
### PermissionConfiguration.cs - Section Seed
|
|
875
|
-
|
|
876
|
-
```csharp
|
|
877
|
-
// src/SmartStack.Infrastructure/Persistence/Configurations/Navigation/PermissionConfiguration.cs
|
|
878
|
-
// ADD in Configure() method, HasData section
|
|
879
|
-
|
|
880
|
-
// ============================================
|
|
881
|
-
// STEP 1: Declare SectionId
|
|
882
|
-
// ============================================
|
|
883
|
-
// Get from NavigationSectionConfiguration.cs
|
|
884
|
-
|
|
885
|
-
var {section}SectionId = Guid.Parse("{SECTION-GUID}");
|
|
886
|
-
|
|
887
|
-
// ============================================
|
|
888
|
-
// STEP 2: Add Section permissions (Level 3)
|
|
889
|
-
// ============================================
|
|
890
|
-
|
|
891
|
-
// Pattern: {application}.{module}.{section}.{action}
|
|
892
|
-
// Example: administration.ai.settings.read
|
|
893
|
-
|
|
894
|
-
builder.HasData(
|
|
895
|
-
// Wildcard permission (full section access)
|
|
896
|
-
new
|
|
897
|
-
{
|
|
898
|
-
Id = Guid.Parse("{NOUVEAU-GUID-1}"),
|
|
899
|
-
Path = "{application}.{module}.{section}.*",
|
|
900
|
-
Level = PermissionLevel.Section,
|
|
901
|
-
Action = (PermissionAction?)null,
|
|
902
|
-
IsWildcard = true,
|
|
903
|
-
SectionId = {section}SectionId,
|
|
904
|
-
Description = "Full {section} access",
|
|
905
|
-
CreatedAt = seedDate
|
|
906
|
-
},
|
|
907
|
-
|
|
908
|
-
// Read permission
|
|
909
|
-
new
|
|
910
|
-
{
|
|
911
|
-
Id = Guid.Parse("{NOUVEAU-GUID-2}"),
|
|
912
|
-
Path = "{application}.{module}.{section}.read",
|
|
913
|
-
Level = PermissionLevel.Section,
|
|
914
|
-
Action = PermissionAction.Read,
|
|
915
|
-
IsWildcard = false,
|
|
916
|
-
SectionId = {section}SectionId,
|
|
917
|
-
Description = "View {section}",
|
|
918
|
-
CreatedAt = seedDate
|
|
919
|
-
},
|
|
920
|
-
|
|
921
|
-
// Create permission
|
|
922
|
-
new
|
|
923
|
-
{
|
|
924
|
-
Id = Guid.Parse("{NOUVEAU-GUID-3}"),
|
|
925
|
-
Path = "{application}.{module}.{section}.create",
|
|
926
|
-
Level = PermissionLevel.Section,
|
|
927
|
-
Action = PermissionAction.Create,
|
|
928
|
-
IsWildcard = false,
|
|
929
|
-
SectionId = {section}SectionId,
|
|
930
|
-
Description = "Create in {section}",
|
|
931
|
-
CreatedAt = seedDate
|
|
932
|
-
},
|
|
933
|
-
|
|
934
|
-
// Update permission
|
|
935
|
-
new
|
|
936
|
-
{
|
|
937
|
-
Id = Guid.Parse("{NOUVEAU-GUID-4}"),
|
|
938
|
-
Path = "{application}.{module}.{section}.update",
|
|
939
|
-
Level = PermissionLevel.Section,
|
|
940
|
-
Action = PermissionAction.Update,
|
|
941
|
-
IsWildcard = false,
|
|
942
|
-
SectionId = {section}SectionId,
|
|
943
|
-
Description = "Update in {section}",
|
|
944
|
-
CreatedAt = seedDate
|
|
945
|
-
},
|
|
946
|
-
|
|
947
|
-
// Delete permission
|
|
948
|
-
new
|
|
949
|
-
{
|
|
950
|
-
Id = Guid.Parse("{NOUVEAU-GUID-5}"),
|
|
951
|
-
Path = "{application}.{module}.{section}.delete",
|
|
952
|
-
Level = PermissionLevel.Section,
|
|
953
|
-
Action = PermissionAction.Delete,
|
|
954
|
-
IsWildcard = false,
|
|
955
|
-
SectionId = {section}SectionId,
|
|
956
|
-
Description = "Delete in {section}",
|
|
957
|
-
CreatedAt = seedDate
|
|
958
|
-
},
|
|
959
|
-
|
|
960
|
-
// Execute permission (optional)
|
|
961
|
-
new
|
|
962
|
-
{
|
|
963
|
-
Id = Guid.Parse("{NOUVEAU-GUID-6}"),
|
|
964
|
-
Path = "{application}.{module}.{section}.execute",
|
|
965
|
-
Level = PermissionLevel.Section,
|
|
966
|
-
Action = PermissionAction.Execute,
|
|
967
|
-
IsWildcard = false,
|
|
968
|
-
SectionId = {section}SectionId,
|
|
969
|
-
Description = "Execute actions in {section}",
|
|
970
|
-
CreatedAt = seedDate
|
|
971
|
-
}
|
|
972
|
-
);
|
|
973
|
-
```
|
|
974
|
-
|
|
975
|
-
---
|
|
976
|
-
|
|
977
|
-
## Template Resource-Level Permissions (Level 4)
|
|
978
|
-
|
|
979
|
-
> **Usage:** For the finest granularity level (ex: Prompts → Blocks, Users → Profiles)
|
|
980
|
-
> **CRITICAL:** Used when a Section contains sub-resources with distinct permissions
|
|
981
|
-
|
|
982
|
-
### Permissions.cs - Resource
|
|
983
|
-
|
|
984
|
-
```csharp
|
|
985
|
-
// src/SmartStack.Application/Common/Authorization/Permissions.cs
|
|
986
|
-
|
|
987
|
-
public static class Admin
|
|
988
|
-
{
|
|
989
|
-
public static class {Module}
|
|
990
|
-
{
|
|
991
|
-
public static class {Section}
|
|
992
|
-
{
|
|
993
|
-
// Section-level permissions...
|
|
994
|
-
|
|
995
|
-
// Resource permissions (Level 4 - finest granularity)
|
|
996
|
-
public static class {Resource}
|
|
997
|
-
{
|
|
998
|
-
public const string View = "{application}.{module}.{section}.{resource}.read";
|
|
999
|
-
public const string Create = "{application}.{module}.{section}.{resource}.create";
|
|
1000
|
-
public const string Update = "{application}.{module}.{section}.{resource}.update";
|
|
1001
|
-
public const string Delete = "{application}.{module}.{section}.{resource}.delete";
|
|
1002
|
-
}
|
|
1003
|
-
}
|
|
1004
|
-
}
|
|
1005
|
-
}
|
|
1006
|
-
```
|
|
1007
|
-
|
|
1008
|
-
### PermissionConfiguration.cs - Resource Seed
|
|
1009
|
-
|
|
1010
|
-
```csharp
|
|
1011
|
-
// src/SmartStack.Infrastructure/Persistence/Configurations/Navigation/PermissionConfiguration.cs
|
|
1012
|
-
// ADD in Configure() method, HasData section
|
|
1013
|
-
|
|
1014
|
-
// ============================================
|
|
1015
|
-
// STEP 1: Declare ResourceId
|
|
1016
|
-
// ============================================
|
|
1017
|
-
// Get from NavigationResourceConfiguration.cs
|
|
1018
|
-
|
|
1019
|
-
var {resource}ResourceId = Guid.Parse("{RESOURCE-GUID}");
|
|
1020
|
-
|
|
1021
|
-
// ============================================
|
|
1022
|
-
// STEP 2: Add Resource permissions (Level 4)
|
|
1023
|
-
// ============================================
|
|
1024
|
-
|
|
1025
|
-
// Pattern: {application}.{module}.{section}.{resource}.{action}
|
|
1026
|
-
// Example: administration.ai.prompts.blocks.read
|
|
1027
|
-
|
|
1028
|
-
builder.HasData(
|
|
1029
|
-
// Wildcard permission (full resource access)
|
|
1030
|
-
new
|
|
1031
|
-
{
|
|
1032
|
-
Id = Guid.Parse("{NOUVEAU-GUID-1}"),
|
|
1033
|
-
Path = "{application}.{module}.{section}.{resource}.*",
|
|
1034
|
-
Level = PermissionLevel.Resource,
|
|
1035
|
-
Action = (PermissionAction?)null,
|
|
1036
|
-
IsWildcard = true,
|
|
1037
|
-
ResourceId = {resource}ResourceId,
|
|
1038
|
-
Description = "Full {resource} access",
|
|
1039
|
-
CreatedAt = seedDate
|
|
1040
|
-
},
|
|
1041
|
-
|
|
1042
|
-
// Read permission
|
|
1043
|
-
new
|
|
1044
|
-
{
|
|
1045
|
-
Id = Guid.Parse("{NOUVEAU-GUID-2}"),
|
|
1046
|
-
Path = "{application}.{module}.{section}.{resource}.read",
|
|
1047
|
-
Level = PermissionLevel.Resource,
|
|
1048
|
-
Action = PermissionAction.Read,
|
|
1049
|
-
IsWildcard = false,
|
|
1050
|
-
ResourceId = {resource}ResourceId,
|
|
1051
|
-
Description = "View {resource}",
|
|
1052
|
-
CreatedAt = seedDate
|
|
1053
|
-
},
|
|
1054
|
-
|
|
1055
|
-
// Create permission
|
|
1056
|
-
new
|
|
1057
|
-
{
|
|
1058
|
-
Id = Guid.Parse("{NOUVEAU-GUID-3}"),
|
|
1059
|
-
Path = "{application}.{module}.{section}.{resource}.create",
|
|
1060
|
-
Level = PermissionLevel.Resource,
|
|
1061
|
-
Action = PermissionAction.Create,
|
|
1062
|
-
IsWildcard = false,
|
|
1063
|
-
ResourceId = {resource}ResourceId,
|
|
1064
|
-
Description = "Create {resource}",
|
|
1065
|
-
CreatedAt = seedDate
|
|
1066
|
-
},
|
|
1067
|
-
|
|
1068
|
-
// Update permission
|
|
1069
|
-
new
|
|
1070
|
-
{
|
|
1071
|
-
Id = Guid.Parse("{NOUVEAU-GUID-4}"),
|
|
1072
|
-
Path = "{application}.{module}.{section}.{resource}.update",
|
|
1073
|
-
Level = PermissionLevel.Resource,
|
|
1074
|
-
Action = PermissionAction.Update,
|
|
1075
|
-
IsWildcard = false,
|
|
1076
|
-
ResourceId = {resource}ResourceId,
|
|
1077
|
-
Description = "Update {resource}",
|
|
1078
|
-
CreatedAt = seedDate
|
|
1079
|
-
},
|
|
1080
|
-
|
|
1081
|
-
// Delete permission
|
|
1082
|
-
new
|
|
1083
|
-
{
|
|
1084
|
-
Id = Guid.Parse("{NOUVEAU-GUID-5}"),
|
|
1085
|
-
Path = "{application}.{module}.{section}.{resource}.delete",
|
|
1086
|
-
Level = PermissionLevel.Resource,
|
|
1087
|
-
Action = PermissionAction.Delete,
|
|
1088
|
-
IsWildcard = false,
|
|
1089
|
-
ResourceId = {resource}ResourceId,
|
|
1090
|
-
Description = "Delete {resource}",
|
|
1091
|
-
CreatedAt = seedDate
|
|
1092
|
-
}
|
|
1093
|
-
);
|
|
1094
|
-
```
|
|
1095
|
-
|
|
1096
|
-
---
|
|
1097
|
-
|
|
1098
|
-
## Template Bulk Operations (Batch Insertion)
|
|
1099
|
-
|
|
1100
|
-
> **MANDATORY:** Always provide bulk endpoints when creating a CRUD controller
|
|
1101
|
-
|
|
1102
|
-
### Permissions.cs - Bulk Operations
|
|
1103
|
-
|
|
1104
|
-
```csharp
|
|
1105
|
-
// src/SmartStack.Application/Common/Authorization/Permissions.cs
|
|
1106
|
-
|
|
1107
|
-
public static class {Module}
|
|
1108
|
-
{
|
|
1109
|
-
// CRUD standard
|
|
1110
|
-
public const string View = "{path}.read";
|
|
1111
|
-
public const string Create = "{path}.create";
|
|
1112
|
-
public const string Update = "{path}.update";
|
|
1113
|
-
public const string Delete = "{path}.delete";
|
|
1114
|
-
|
|
1115
|
-
// Bulk operations (MANDATORY for all CRUD modules)
|
|
1116
|
-
public const string BulkCreate = "{path}.bulk-create";
|
|
1117
|
-
public const string BulkUpdate = "{path}.bulk-update";
|
|
1118
|
-
public const string BulkDelete = "{path}.bulk-delete";
|
|
1119
|
-
public const string Export = "{path}.export";
|
|
1120
|
-
public const string Import = "{path}.import";
|
|
1121
|
-
}
|
|
1122
|
-
```
|
|
1123
|
-
|
|
1124
|
-
### PermissionConfiguration.cs - Bulk Permissions Seed
|
|
1125
|
-
|
|
1126
|
-
```csharp
|
|
1127
|
-
// Add after standard CRUD permissions
|
|
1128
|
-
|
|
1129
|
-
// Bulk Create permission
|
|
1130
|
-
new
|
|
1131
|
-
{
|
|
1132
|
-
Id = Guid.Parse("{NOUVEAU-GUID-BULK-1}"),
|
|
1133
|
-
Path = "{application}.{module}.bulk-create",
|
|
1134
|
-
Level = PermissionLevel.Module,
|
|
1135
|
-
Action = PermissionAction.Create,
|
|
1136
|
-
IsWildcard = false,
|
|
1137
|
-
ModuleId = {module}ModuleId,
|
|
1138
|
-
Description = "Bulk create {module}",
|
|
1139
|
-
CreatedAt = seedDate
|
|
1140
|
-
},
|
|
1141
|
-
|
|
1142
|
-
// Bulk Update permission
|
|
1143
|
-
new
|
|
1144
|
-
{
|
|
1145
|
-
Id = Guid.Parse("{NOUVEAU-GUID-BULK-2}"),
|
|
1146
|
-
Path = "{application}.{module}.bulk-update",
|
|
1147
|
-
Level = PermissionLevel.Module,
|
|
1148
|
-
Action = PermissionAction.Update,
|
|
1149
|
-
IsWildcard = false,
|
|
1150
|
-
ModuleId = {module}ModuleId,
|
|
1151
|
-
Description = "Bulk update {module}",
|
|
1152
|
-
CreatedAt = seedDate
|
|
1153
|
-
},
|
|
1154
|
-
|
|
1155
|
-
// Bulk Delete permission
|
|
1156
|
-
new
|
|
1157
|
-
{
|
|
1158
|
-
Id = Guid.Parse("{NOUVEAU-GUID-BULK-3}"),
|
|
1159
|
-
Path = "{application}.{module}.bulk-delete",
|
|
1160
|
-
Level = PermissionLevel.Module,
|
|
1161
|
-
Action = PermissionAction.Delete,
|
|
1162
|
-
IsWildcard = false,
|
|
1163
|
-
ModuleId = {module}ModuleId,
|
|
1164
|
-
Description = "Bulk delete {module}",
|
|
1165
|
-
CreatedAt = seedDate
|
|
1166
|
-
},
|
|
1167
|
-
|
|
1168
|
-
// Export permission
|
|
1169
|
-
new
|
|
1170
|
-
{
|
|
1171
|
-
Id = Guid.Parse("{NOUVEAU-GUID-EXPORT}"),
|
|
1172
|
-
Path = "{application}.{module}.export",
|
|
1173
|
-
Level = PermissionLevel.Module,
|
|
1174
|
-
Action = PermissionAction.Execute,
|
|
1175
|
-
IsWildcard = false,
|
|
1176
|
-
ModuleId = {module}ModuleId,
|
|
1177
|
-
Description = "Export {module} data",
|
|
1178
|
-
CreatedAt = seedDate
|
|
1179
|
-
},
|
|
1180
|
-
|
|
1181
|
-
// Import permission
|
|
1182
|
-
new
|
|
1183
|
-
{
|
|
1184
|
-
Id = Guid.Parse("{NOUVEAU-GUID-IMPORT}"),
|
|
1185
|
-
Path = "{application}.{module}.import",
|
|
1186
|
-
Level = PermissionLevel.Module,
|
|
1187
|
-
Action = PermissionAction.Create,
|
|
1188
|
-
IsWildcard = false,
|
|
1189
|
-
ModuleId = {module}ModuleId,
|
|
1190
|
-
Description = "Import {module} data",
|
|
1191
|
-
CreatedAt = seedDate
|
|
1192
|
-
}
|
|
1193
|
-
```
|
|
1194
|
-
|
|
1195
|
-
### Controller Endpoints - Bulk Operations
|
|
1196
|
-
|
|
1197
|
-
```csharp
|
|
1198
|
-
// src/SmartStack.Api/Controllers/{Area}/{Module}Controller.cs
|
|
1199
|
-
// ADD after standard CRUD endpoints
|
|
1200
|
-
|
|
1201
|
-
#region BULK OPERATIONS
|
|
1202
|
-
|
|
1203
|
-
/// <summary>
|
|
1204
|
-
/// Bulk create multiple entities
|
|
1205
|
-
/// </summary>
|
|
1206
|
-
[HttpPost("bulk")]
|
|
1207
|
-
[RequirePermission(Permissions.{PermissionClass}.BulkCreate)]
|
|
1208
|
-
[ProducesResponseType(typeof(BulkOperationResult<{Entity}Dto>), StatusCodes.Status201Created)]
|
|
1209
|
-
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
1210
|
-
public async Task<ActionResult<BulkOperationResult<{Entity}Dto>>> BulkCreate{Entity}(
|
|
1211
|
-
[FromBody] List<Create{Entity}Request> requests,
|
|
1212
|
-
CancellationToken cancellationToken)
|
|
1213
|
-
{
|
|
1214
|
-
if (requests == null || requests.Count == 0)
|
|
1215
|
-
return BadRequest(new { message = "No items provided" });
|
|
1216
|
-
|
|
1217
|
-
if (requests.Count > 100)
|
|
1218
|
-
return BadRequest(new { message = "Maximum 100 items per bulk operation" });
|
|
1219
|
-
|
|
1220
|
-
var results = new List<{Entity}Dto>();
|
|
1221
|
-
var errors = new List<BulkOperationError>();
|
|
1222
|
-
|
|
1223
|
-
for (int i = 0; i < requests.Count; i++)
|
|
1224
|
-
{
|
|
1225
|
-
try
|
|
1226
|
-
{
|
|
1227
|
-
var entity = {Entity}.Create(
|
|
1228
|
-
requests[i].Name,
|
|
1229
|
-
requests[i].Description
|
|
1230
|
-
);
|
|
1231
|
-
|
|
1232
|
-
_context.{DbSet}.Add(entity);
|
|
1233
|
-
results.Add(new {Entity}Dto(entity.Id, entity.Name));
|
|
1234
|
-
}
|
|
1235
|
-
catch (Exception ex)
|
|
1236
|
-
{
|
|
1237
|
-
errors.Add(new BulkOperationError(i, requests[i].Name, ex.Message));
|
|
1238
|
-
}
|
|
1239
|
-
}
|
|
1240
|
-
|
|
1241
|
-
await _context.SaveChangesAsync(cancellationToken);
|
|
1242
|
-
|
|
1243
|
-
_logger.LogInformation("User {User} bulk created {Count} {Entity}(s), {Errors} error(s)",
|
|
1244
|
-
_currentUser.Email, results.Count, errors.Count);
|
|
1245
|
-
|
|
1246
|
-
return CreatedAtAction(
|
|
1247
|
-
nameof(Get{Module}),
|
|
1248
|
-
new BulkOperationResult<{Entity}Dto>(results, errors, results.Count, errors.Count));
|
|
1249
|
-
}
|
|
1250
|
-
|
|
1251
|
-
/// <summary>
|
|
1252
|
-
/// Bulk update multiple entities
|
|
1253
|
-
/// </summary>
|
|
1254
|
-
[HttpPut("bulk")]
|
|
1255
|
-
[RequirePermission(Permissions.{PermissionClass}.BulkUpdate)]
|
|
1256
|
-
[ProducesResponseType(typeof(BulkOperationResult), StatusCodes.Status200OK)]
|
|
1257
|
-
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
1258
|
-
public async Task<ActionResult<BulkOperationResult>> BulkUpdate{Entity}(
|
|
1259
|
-
[FromBody] List<BulkUpdate{Entity}Request> requests,
|
|
1260
|
-
CancellationToken cancellationToken)
|
|
1261
|
-
{
|
|
1262
|
-
if (requests == null || requests.Count == 0)
|
|
1263
|
-
return BadRequest(new { message = "No items provided" });
|
|
1264
|
-
|
|
1265
|
-
if (requests.Count > 100)
|
|
1266
|
-
return BadRequest(new { message = "Maximum 100 items per bulk operation" });
|
|
1267
|
-
|
|
1268
|
-
var ids = requests.Select(r => r.Id).ToList();
|
|
1269
|
-
var entities = await _context.{DbSet}
|
|
1270
|
-
.Where(x => ids.Contains(x.Id))
|
|
1271
|
-
.ToDictionaryAsync(x => x.Id, cancellationToken);
|
|
1272
|
-
|
|
1273
|
-
var updated = 0;
|
|
1274
|
-
var errors = new List<BulkOperationError>();
|
|
1275
|
-
|
|
1276
|
-
for (int i = 0; i < requests.Count; i++)
|
|
1277
|
-
{
|
|
1278
|
-
if (!entities.TryGetValue(requests[i].Id, out var entity))
|
|
1279
|
-
{
|
|
1280
|
-
errors.Add(new BulkOperationError(i, requests[i].Id.ToString(), "Entity not found"));
|
|
1281
|
-
continue;
|
|
1282
|
-
}
|
|
1283
|
-
|
|
1284
|
-
try
|
|
1285
|
-
{
|
|
1286
|
-
entity.Update(
|
|
1287
|
-
requests[i].Name ?? entity.Name,
|
|
1288
|
-
requests[i].Description ?? entity.Description
|
|
1289
|
-
);
|
|
1290
|
-
updated++;
|
|
1291
|
-
}
|
|
1292
|
-
catch (Exception ex)
|
|
1293
|
-
{
|
|
1294
|
-
errors.Add(new BulkOperationError(i, requests[i].Id.ToString(), ex.Message));
|
|
1295
|
-
}
|
|
1296
|
-
}
|
|
1297
|
-
|
|
1298
|
-
await _context.SaveChangesAsync(cancellationToken);
|
|
1299
|
-
|
|
1300
|
-
_logger.LogInformation("User {User} bulk updated {Count} {Entity}(s), {Errors} error(s)",
|
|
1301
|
-
_currentUser.Email, updated, errors.Count);
|
|
1302
|
-
|
|
1303
|
-
return Ok(new BulkOperationResult(updated, errors.Count, errors));
|
|
1304
|
-
}
|
|
1305
|
-
|
|
1306
|
-
/// <summary>
|
|
1307
|
-
/// Bulk delete multiple entities by IDs
|
|
1308
|
-
/// </summary>
|
|
1309
|
-
[HttpDelete("bulk")]
|
|
1310
|
-
[RequirePermission(Permissions.{PermissionClass}.BulkDelete)]
|
|
1311
|
-
[ProducesResponseType(typeof(BulkOperationResult), StatusCodes.Status200OK)]
|
|
1312
|
-
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
1313
|
-
public async Task<ActionResult<BulkOperationResult>> BulkDelete{Entity}(
|
|
1314
|
-
[FromBody] List<Guid> ids,
|
|
1315
|
-
CancellationToken cancellationToken)
|
|
1316
|
-
{
|
|
1317
|
-
if (ids == null || ids.Count == 0)
|
|
1318
|
-
return BadRequest(new { message = "No IDs provided" });
|
|
1319
|
-
|
|
1320
|
-
if (ids.Count > 100)
|
|
1321
|
-
return BadRequest(new { message = "Maximum 100 items per bulk operation" });
|
|
1322
|
-
|
|
1323
|
-
var entities = await _context.{DbSet}
|
|
1324
|
-
.Where(x => ids.Contains(x.Id))
|
|
1325
|
-
.ToListAsync(cancellationToken);
|
|
1326
|
-
|
|
1327
|
-
var deleted = entities.Count;
|
|
1328
|
-
var notFound = ids.Count - deleted;
|
|
1329
|
-
|
|
1330
|
-
_context.{DbSet}.RemoveRange(entities);
|
|
1331
|
-
await _context.SaveChangesAsync(cancellationToken);
|
|
1332
|
-
|
|
1333
|
-
_logger.LogWarning("User {User} bulk deleted {Count} {Entity}(s), {NotFound} not found",
|
|
1334
|
-
_currentUser.Email, deleted, notFound);
|
|
1335
|
-
|
|
1336
|
-
var errors = notFound > 0
|
|
1337
|
-
? new List<BulkOperationError> { new(-1, "N/A", $"{notFound} entities not found") }
|
|
1338
|
-
: new List<BulkOperationError>();
|
|
1339
|
-
|
|
1340
|
-
return Ok(new BulkOperationResult(deleted, errors.Count, errors));
|
|
1341
|
-
}
|
|
1342
|
-
|
|
1343
|
-
/// <summary>
|
|
1344
|
-
/// Export entities to CSV/Excel
|
|
1345
|
-
/// </summary>
|
|
1346
|
-
[HttpGet("export")]
|
|
1347
|
-
[RequirePermission(Permissions.{PermissionClass}.Export)]
|
|
1348
|
-
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
|
1349
|
-
public async Task<IActionResult> Export{Module}(
|
|
1350
|
-
[FromQuery] string format = "csv",
|
|
1351
|
-
[FromQuery] string? search = null,
|
|
1352
|
-
CancellationToken cancellationToken = default)
|
|
1353
|
-
{
|
|
1354
|
-
var query = _context.{DbSet}.AsQueryable();
|
|
1355
|
-
|
|
1356
|
-
if (!string.IsNullOrWhiteSpace(search))
|
|
1357
|
-
{
|
|
1358
|
-
var searchLower = search.ToLower();
|
|
1359
|
-
query = query.Where(x => x.Name.ToLower().Contains(searchLower));
|
|
1360
|
-
}
|
|
1361
|
-
|
|
1362
|
-
var entities = await query.ToListAsync(cancellationToken);
|
|
1363
|
-
|
|
1364
|
-
_logger.LogInformation("User {User} exported {Count} {Entity}(s) to {Format}",
|
|
1365
|
-
_currentUser.Email, entities.Count, format);
|
|
1366
|
-
|
|
1367
|
-
// Implement CSV/Excel export logic here
|
|
1368
|
-
// Using libraries like CsvHelper or ClosedXML
|
|
1369
|
-
|
|
1370
|
-
var content = format.ToLower() switch
|
|
1371
|
-
{
|
|
1372
|
-
"xlsx" => GenerateExcel(entities),
|
|
1373
|
-
_ => GenerateCsv(entities)
|
|
1374
|
-
};
|
|
1375
|
-
|
|
1376
|
-
var contentType = format.ToLower() == "xlsx"
|
|
1377
|
-
? "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
1378
|
-
: "text/csv";
|
|
1379
|
-
|
|
1380
|
-
var fileName = $"{module}-export-{DateTime.UtcNow:yyyyMMdd-HHmmss}.{format}";
|
|
1381
|
-
|
|
1382
|
-
return File(content, contentType, fileName);
|
|
1383
|
-
}
|
|
1384
|
-
|
|
1385
|
-
#endregion
|
|
1386
|
-
|
|
1387
|
-
#region Bulk DTOs
|
|
1388
|
-
|
|
1389
|
-
public record BulkOperationResult(
|
|
1390
|
-
int SuccessCount,
|
|
1391
|
-
int ErrorCount,
|
|
1392
|
-
List<BulkOperationError> Errors);
|
|
1393
|
-
|
|
1394
|
-
public record BulkOperationResult<T>(
|
|
1395
|
-
List<T> Created,
|
|
1396
|
-
List<BulkOperationError> Errors,
|
|
1397
|
-
int SuccessCount,
|
|
1398
|
-
int ErrorCount);
|
|
1399
|
-
|
|
1400
|
-
public record BulkOperationError(
|
|
1401
|
-
int Index,
|
|
1402
|
-
string Identifier,
|
|
1403
|
-
string Message);
|
|
1404
|
-
|
|
1405
|
-
public record BulkUpdate{Entity}Request(
|
|
1406
|
-
Guid Id,
|
|
1407
|
-
string? Name,
|
|
1408
|
-
string? Description);
|
|
1409
|
-
|
|
1410
|
-
#endregion
|
|
1411
|
-
```
|
|
1412
|
-
|
|
1413
|
-
---
|
|
1414
|
-
|
|
1415
|
-
## Complete Permissions Hierarchy
|
|
1416
|
-
|
|
1417
|
-
```
|
|
1418
|
-
┌─────────────────────────────────────────────────────────────────────────────────┐
|
|
1419
|
-
│ COMPLETE PERMISSIONS HIERARCHY │
|
|
1420
|
-
├─────────────────────────────────────────────────────────────────────────────────┤
|
|
1421
|
-
│ │
|
|
1422
|
-
│ Level 1: APPLICATION │
|
|
1423
|
-
│ └─ Path: {application}.* │
|
|
1424
|
-
│ └─ Ex: administration.* → Full administration access │
|
|
1425
|
-
│ │
|
|
1426
|
-
│ Level 2: MODULE │
|
|
1427
|
-
│ └─ Path: {application}.{module}.{action} │
|
|
1428
|
-
│ └─ Ex: administration.users.read → Read users │
|
|
1429
|
-
│ └─ BULK: administration.users.bulk-create → Batch create │
|
|
1430
|
-
│ │
|
|
1431
|
-
│ Level 3: SECTION │
|
|
1432
|
-
│ └─ Path: {application}.{module}.{section}.{action} │
|
|
1433
|
-
│ └─ Ex: administration.ai.settings.update → Update AI settings │
|
|
1434
|
-
│ │
|
|
1435
|
-
│ Level 4: RESOURCE (finest granularity) │
|
|
1436
|
-
│ └─ Path: {application}.{module}.{section}.{resource}.{action} │
|
|
1437
|
-
│ └─ Ex: administration.ai.prompts.blocks.delete → Delete blocks │
|
|
1438
|
-
│ │
|
|
1439
|
-
└─────────────────────────────────────────────────────────────────────────────────┘
|
|
1440
|
-
```
|
|
1441
|
-
|
|
1442
|
-
---
|
|
1443
|
-
|
|
1444
|
-
## Controller Checklist with Complete Permissions
|
|
1445
|
-
|
|
1446
|
-
```
|
|
1447
|
-
□ CRUD Standard
|
|
1448
|
-
□ GET /api/.../ → {path}.read
|
|
1449
|
-
□ GET /api/.../{id} → {path}.read
|
|
1450
|
-
□ POST /api/.../ → {path}.create
|
|
1451
|
-
□ PUT /api/.../{id} → {path}.update
|
|
1452
|
-
□ DELETE /api/.../{id} → {path}.delete
|
|
1453
|
-
|
|
1454
|
-
□ Bulk Operations
|
|
1455
|
-
□ POST /api/.../bulk → {path}.bulk-create
|
|
1456
|
-
□ PUT /api/.../bulk → {path}.bulk-update
|
|
1457
|
-
□ DELETE /api/.../bulk → {path}.bulk-delete
|
|
1458
|
-
|
|
1459
|
-
□ Export/Import
|
|
1460
|
-
□ GET /api/.../export → {path}.export
|
|
1461
|
-
□ POST /api/.../import → {path}.import
|
|
1462
|
-
|
|
1463
|
-
□ Permissions Configured
|
|
1464
|
-
□ Permissions.cs - Constants defined
|
|
1465
|
-
□ PermissionConfiguration.cs - Seed HasData
|
|
1466
|
-
□ EF Core Migration created
|
|
1467
|
-
□ Migration applied
|
|
1468
|
-
|
|
1469
|
-
□ API Versioning (if applicable)
|
|
1470
|
-
□ See API Versioning section below
|
|
1471
|
-
|
|
1472
|
-
□ Correct Permission Level
|
|
1473
|
-
□ Module (Level 2) - For main CRUD
|
|
1474
|
-
□ Section (Level 3) - If sub-pages with different permissions
|
|
1475
|
-
□ Resource (Level 4) - If sub-resources with distinct permissions
|
|
1476
|
-
```
|
|
1477
|
-
|
|
1478
|
-
---
|
|
1479
|
-
|
|
1480
|
-
## API Versioning
|
|
1481
|
-
|
|
1482
|
-
**SmartStack convention:** Header-based versioning using `Asp.Versioning.Mvc`.
|
|
1483
|
-
|
|
1484
|
-
### Setup
|
|
1485
|
-
|
|
1486
|
-
```csharp
|
|
1487
|
-
// Program.cs
|
|
1488
|
-
builder.Services.AddApiVersioning(options =>
|
|
1489
|
-
{
|
|
1490
|
-
options.DefaultApiVersion = new ApiVersion(1, 0);
|
|
1491
|
-
options.AssumeDefaultVersionWhenUnspecified = true;
|
|
1492
|
-
options.ReportApiVersions = true; // Adds api-supported-versions header
|
|
1493
|
-
options.ApiVersionReader = new HeaderApiVersionReader("api-version");
|
|
1494
|
-
})
|
|
1495
|
-
.AddApiExplorer(options =>
|
|
1496
|
-
{
|
|
1497
|
-
options.GroupNameFormat = "'v'VVV";
|
|
1498
|
-
options.SubstituteApiVersionInUrl = true;
|
|
1499
|
-
});
|
|
1500
|
-
```
|
|
1501
|
-
|
|
1502
|
-
### Controller Usage
|
|
1503
|
-
|
|
1504
|
-
```csharp
|
|
1505
|
-
// v1 - Default (no attribute needed if only one version exists)
|
|
1506
|
-
[ApiController]
|
|
1507
|
-
[ApiVersion("1.0")]
|
|
1508
|
-
[NavRoute("crm.contacts")]
|
|
1509
|
-
public class ContactsController : ControllerBase
|
|
1510
|
-
{
|
|
1511
|
-
[HttpGet]
|
|
1512
|
-
[RequirePermission(Permissions.Crm.Contacts.Read)]
|
|
1513
|
-
public async Task<ActionResult<PaginatedResult<ContactDto>>> GetAll(...) { ... }
|
|
1514
|
-
}
|
|
1515
|
-
|
|
1516
|
-
// v2 - Breaking changes only
|
|
1517
|
-
[ApiController]
|
|
1518
|
-
[ApiVersion("2.0")]
|
|
1519
|
-
[NavRoute("crm.contacts")]
|
|
1520
|
-
public class ContactsV2Controller : ControllerBase
|
|
1521
|
-
{
|
|
1522
|
-
[HttpGet]
|
|
1523
|
-
[RequirePermission(Permissions.Crm.Contacts.Read)]
|
|
1524
|
-
public async Task<ActionResult<PaginatedResult<ContactV2Dto>>> GetAll(...) { ... }
|
|
1525
|
-
}
|
|
1526
|
-
```
|
|
1527
|
-
|
|
1528
|
-
### Deprecation
|
|
1529
|
-
|
|
1530
|
-
```csharp
|
|
1531
|
-
[ApiVersion("1.0", Deprecated = true)] // Adds api-deprecated-versions header
|
|
1532
|
-
[ApiVersion("2.0")]
|
|
1533
|
-
public class ContactsController : ControllerBase
|
|
1534
|
-
{
|
|
1535
|
-
[HttpGet]
|
|
1536
|
-
[MapToApiVersion("1.0")]
|
|
1537
|
-
public async Task<ActionResult<PaginatedResult<ContactDto>>> GetAllV1(...) { ... }
|
|
1538
|
-
|
|
1539
|
-
[HttpGet]
|
|
1540
|
-
[MapToApiVersion("2.0")]
|
|
1541
|
-
public async Task<ActionResult<PaginatedResult<ContactV2Dto>>> GetAllV2(...) { ... }
|
|
1542
|
-
}
|
|
1543
|
-
```
|
|
1544
|
-
|
|
1545
|
-
### SmartStack Versioning Rules
|
|
1546
|
-
|
|
1547
|
-
| Rule | Detail |
|
|
1548
|
-
|------|--------|
|
|
1549
|
-
| Default version | `1.0` (assumed when no header sent) |
|
|
1550
|
-
| Version header | `api-version: 2.0` |
|
|
1551
|
-
| When to version | Breaking changes only (field removal, type change, behavior change) |
|
|
1552
|
-
| Non-breaking changes | Add to existing version (new fields, new endpoints) |
|
|
1553
|
-
| Naming | `V2Controller` suffix or `[MapToApiVersion]` in same controller |
|
|
1554
|
-
| Deprecation | Mark old version deprecated, maintain for 2 releases minimum |
|
|
1555
|
-
| Documentation | Both versions documented via `[ProducesResponseType]` |
|
|
1
|
+
# SmartStack Controller Templates
|
|
2
|
+
|
|
3
|
+
> **⚠️ OBSOLETE - DO NOT USE THESE TEMPLATES MANUALLY**
|
|
4
|
+
>
|
|
5
|
+
> **The `/controller` skill now uses the MCP `scaffold_extension` tool to generate controllers.**
|
|
6
|
+
> These templates are kept for reference only. All controller generation MUST go through the MCP to ensure:
|
|
7
|
+
> - ✅ `[NavRoute]` attribute is included for frontend/backend sync
|
|
8
|
+
> - ✅ Permissions are correctly generated
|
|
9
|
+
> - ✅ Consistency with SmartStack conventions
|
|
10
|
+
>
|
|
11
|
+
> **To generate a controller, use:** `/controller` skill which calls MCP `scaffold_extension` automatically.
|
|
12
|
+
>
|
|
13
|
+
> **If you modify these templates, they will NOT be used.** The MCP templates in `templates/mcp-scaffolding/controller.cs.hbs` are the source of truth.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Template CRUD Controller (Standard)
|
|
18
|
+
|
|
19
|
+
```csharp
|
|
20
|
+
// src/SmartStack.Api/Controllers/{Area}/{Module}Controller.cs
|
|
21
|
+
|
|
22
|
+
using Microsoft.AspNetCore.Authorization;
|
|
23
|
+
using Microsoft.AspNetCore.Mvc;
|
|
24
|
+
using Microsoft.EntityFrameworkCore;
|
|
25
|
+
using SmartStack.Application.Common.Authorization;
|
|
26
|
+
using SmartStack.Application.Common.Interfaces;
|
|
27
|
+
using SmartStack.Api.Authorization;
|
|
28
|
+
using SmartStack.Domain.{DomainNamespace};
|
|
29
|
+
|
|
30
|
+
namespace SmartStack.Api.Controllers.{Area};
|
|
31
|
+
|
|
32
|
+
[ApiController]
|
|
33
|
+
[Route("api/{area-kebab}/{module-kebab}")]
|
|
34
|
+
[Authorize]
|
|
35
|
+
[Tags("{Module}")]
|
|
36
|
+
public class {Module}Controller : ControllerBase
|
|
37
|
+
{
|
|
38
|
+
private readonly IApplicationDbContext _context;
|
|
39
|
+
private readonly ICurrentUserService _currentUser;
|
|
40
|
+
private readonly ILogger<{Module}Controller> _logger;
|
|
41
|
+
|
|
42
|
+
public {Module}Controller(
|
|
43
|
+
IApplicationDbContext context,
|
|
44
|
+
ICurrentUserService currentUser,
|
|
45
|
+
ILogger<{Module}Controller> logger)
|
|
46
|
+
{
|
|
47
|
+
_context = context;
|
|
48
|
+
_currentUser = currentUser;
|
|
49
|
+
_logger = logger;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
#region GET - List with Pagination
|
|
53
|
+
|
|
54
|
+
[HttpGet]
|
|
55
|
+
[RequirePermission(Permissions.{PermissionClass}.View)]
|
|
56
|
+
[ProducesResponseType(typeof(PaginatedResult<{Entity}ListDto>), StatusCodes.Status200OK)]
|
|
57
|
+
public async Task<ActionResult<PaginatedResult<{Entity}ListDto>>> Get{Module}(
|
|
58
|
+
[FromQuery] int page = 1,
|
|
59
|
+
[FromQuery] int pageSize = 20,
|
|
60
|
+
[FromQuery] string? search = null,
|
|
61
|
+
CancellationToken cancellationToken = default)
|
|
62
|
+
{
|
|
63
|
+
var query = _context.{DbSet}.AsQueryable();
|
|
64
|
+
|
|
65
|
+
// Search filter
|
|
66
|
+
if (!string.IsNullOrWhiteSpace(search))
|
|
67
|
+
{
|
|
68
|
+
var searchLower = search.ToLower();
|
|
69
|
+
query = query.Where(x =>
|
|
70
|
+
x.Name.ToLower().Contains(searchLower) ||
|
|
71
|
+
x.Description != null && x.Description.ToLower().Contains(searchLower));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
var totalCount = await query.CountAsync(cancellationToken);
|
|
75
|
+
|
|
76
|
+
var items = await query
|
|
77
|
+
.OrderBy(x => x.Name)
|
|
78
|
+
.Skip((page - 1) * pageSize)
|
|
79
|
+
.Take(pageSize)
|
|
80
|
+
.Select(x => new {Entity}ListDto(
|
|
81
|
+
x.Id,
|
|
82
|
+
x.Name,
|
|
83
|
+
x.Description,
|
|
84
|
+
x.IsActive,
|
|
85
|
+
x.CreatedAt
|
|
86
|
+
))
|
|
87
|
+
.ToListAsync(cancellationToken);
|
|
88
|
+
|
|
89
|
+
_logger.LogInformation("User {User} retrieved {Count} {Module}",
|
|
90
|
+
_currentUser.Email, items.Count, "{Module}");
|
|
91
|
+
|
|
92
|
+
return Ok(new PaginatedResult<{Entity}ListDto>(items, totalCount, page, pageSize));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
#endregion
|
|
96
|
+
|
|
97
|
+
#region GET - Single by ID
|
|
98
|
+
|
|
99
|
+
[HttpGet("{id:guid}")]
|
|
100
|
+
[RequirePermission(Permissions.{PermissionClass}.View)]
|
|
101
|
+
[ProducesResponseType(typeof({Entity}DetailDto), StatusCodes.Status200OK)]
|
|
102
|
+
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
103
|
+
public async Task<ActionResult<{Entity}DetailDto>> Get{Entity}(
|
|
104
|
+
Guid id,
|
|
105
|
+
CancellationToken cancellationToken)
|
|
106
|
+
{
|
|
107
|
+
var entity = await _context.{DbSet}
|
|
108
|
+
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
|
109
|
+
|
|
110
|
+
if (entity == null)
|
|
111
|
+
return NotFound(new { message = "{Entity} not found" });
|
|
112
|
+
|
|
113
|
+
return Ok(new {Entity}DetailDto(
|
|
114
|
+
entity.Id,
|
|
115
|
+
entity.Name,
|
|
116
|
+
entity.Description,
|
|
117
|
+
entity.IsActive,
|
|
118
|
+
entity.CreatedAt,
|
|
119
|
+
entity.UpdatedAt
|
|
120
|
+
));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
#endregion
|
|
124
|
+
|
|
125
|
+
#region POST - Create
|
|
126
|
+
|
|
127
|
+
[HttpPost]
|
|
128
|
+
[RequirePermission(Permissions.{PermissionClass}.Create)]
|
|
129
|
+
[ProducesResponseType(typeof({Entity}DetailDto), StatusCodes.Status201Created)]
|
|
130
|
+
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
131
|
+
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
|
132
|
+
public async Task<ActionResult<{Entity}DetailDto>> Create{Entity}(
|
|
133
|
+
[FromBody] Create{Entity}Request request,
|
|
134
|
+
CancellationToken cancellationToken)
|
|
135
|
+
{
|
|
136
|
+
// Check for duplicates
|
|
137
|
+
var exists = await _context.{DbSet}
|
|
138
|
+
.AnyAsync(x => x.Name == request.Name, cancellationToken);
|
|
139
|
+
|
|
140
|
+
if (exists)
|
|
141
|
+
return Conflict(new { message = "{Entity} with this name already exists" });
|
|
142
|
+
|
|
143
|
+
var entity = {Entity}.Create(
|
|
144
|
+
request.Name,
|
|
145
|
+
request.Description
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
_context.{DbSet}.Add(entity);
|
|
149
|
+
await _context.SaveChangesAsync(cancellationToken);
|
|
150
|
+
|
|
151
|
+
_logger.LogInformation("User {User} created {Entity} {EntityId} ({Name})",
|
|
152
|
+
_currentUser.Email, entity.Id, entity.Name);
|
|
153
|
+
|
|
154
|
+
return CreatedAtAction(
|
|
155
|
+
nameof(Get{Entity}),
|
|
156
|
+
new { id = entity.Id },
|
|
157
|
+
new {Entity}DetailDto(
|
|
158
|
+
entity.Id,
|
|
159
|
+
entity.Name,
|
|
160
|
+
entity.Description,
|
|
161
|
+
entity.IsActive,
|
|
162
|
+
entity.CreatedAt,
|
|
163
|
+
entity.UpdatedAt
|
|
164
|
+
));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
#endregion
|
|
168
|
+
|
|
169
|
+
#region PUT - Update
|
|
170
|
+
|
|
171
|
+
[HttpPut("{id:guid}")]
|
|
172
|
+
[RequirePermission(Permissions.{PermissionClass}.Update)]
|
|
173
|
+
[ProducesResponseType(typeof({Entity}DetailDto), StatusCodes.Status200OK)]
|
|
174
|
+
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
175
|
+
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
|
176
|
+
public async Task<ActionResult<{Entity}DetailDto>> Update{Entity}(
|
|
177
|
+
Guid id,
|
|
178
|
+
[FromBody] Update{Entity}Request request,
|
|
179
|
+
CancellationToken cancellationToken)
|
|
180
|
+
{
|
|
181
|
+
var entity = await _context.{DbSet}
|
|
182
|
+
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
|
183
|
+
|
|
184
|
+
if (entity == null)
|
|
185
|
+
return NotFound(new { message = "{Entity} not found" });
|
|
186
|
+
|
|
187
|
+
// Check for duplicate name (excluding current)
|
|
188
|
+
if (!string.IsNullOrEmpty(request.Name))
|
|
189
|
+
{
|
|
190
|
+
var duplicate = await _context.{DbSet}
|
|
191
|
+
.AnyAsync(x => x.Name == request.Name && x.Id != id, cancellationToken);
|
|
192
|
+
|
|
193
|
+
if (duplicate)
|
|
194
|
+
return Conflict(new { message = "{Entity} with this name already exists" });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
entity.Update(
|
|
198
|
+
request.Name ?? entity.Name,
|
|
199
|
+
request.Description ?? entity.Description
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
await _context.SaveChangesAsync(cancellationToken);
|
|
203
|
+
|
|
204
|
+
_logger.LogInformation("User {User} updated {Entity} {EntityId}",
|
|
205
|
+
_currentUser.Email, entity.Id);
|
|
206
|
+
|
|
207
|
+
return Ok(new {Entity}DetailDto(
|
|
208
|
+
entity.Id,
|
|
209
|
+
entity.Name,
|
|
210
|
+
entity.Description,
|
|
211
|
+
entity.IsActive,
|
|
212
|
+
entity.CreatedAt,
|
|
213
|
+
entity.UpdatedAt
|
|
214
|
+
));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
#endregion
|
|
218
|
+
|
|
219
|
+
#region PATCH - Activate/Deactivate
|
|
220
|
+
|
|
221
|
+
[HttpPatch("{id:guid}/activate")]
|
|
222
|
+
[RequirePermission(Permissions.{PermissionClass}.Update)]
|
|
223
|
+
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
|
224
|
+
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
225
|
+
public async Task<IActionResult> Activate{Entity}(
|
|
226
|
+
Guid id,
|
|
227
|
+
CancellationToken cancellationToken)
|
|
228
|
+
{
|
|
229
|
+
var entity = await _context.{DbSet}
|
|
230
|
+
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
|
231
|
+
|
|
232
|
+
if (entity == null)
|
|
233
|
+
return NotFound(new { message = "{Entity} not found" });
|
|
234
|
+
|
|
235
|
+
entity.Activate();
|
|
236
|
+
await _context.SaveChangesAsync(cancellationToken);
|
|
237
|
+
|
|
238
|
+
_logger.LogInformation("User {User} activated {Entity} {EntityId}",
|
|
239
|
+
_currentUser.Email, entity.Id);
|
|
240
|
+
|
|
241
|
+
return NoContent();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
[HttpPatch("{id:guid}/deactivate")]
|
|
245
|
+
[RequirePermission(Permissions.{PermissionClass}.Update)]
|
|
246
|
+
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
|
247
|
+
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
248
|
+
public async Task<IActionResult> Deactivate{Entity}(
|
|
249
|
+
Guid id,
|
|
250
|
+
CancellationToken cancellationToken)
|
|
251
|
+
{
|
|
252
|
+
var entity = await _context.{DbSet}
|
|
253
|
+
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
|
254
|
+
|
|
255
|
+
if (entity == null)
|
|
256
|
+
return NotFound(new { message = "{Entity} not found" });
|
|
257
|
+
|
|
258
|
+
entity.Deactivate();
|
|
259
|
+
await _context.SaveChangesAsync(cancellationToken);
|
|
260
|
+
|
|
261
|
+
_logger.LogWarning("User {User} deactivated {Entity} {EntityId}",
|
|
262
|
+
_currentUser.Email, entity.Id);
|
|
263
|
+
|
|
264
|
+
return NoContent();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
#endregion
|
|
268
|
+
|
|
269
|
+
#region DELETE
|
|
270
|
+
|
|
271
|
+
[HttpDelete("{id:guid}")]
|
|
272
|
+
[RequirePermission(Permissions.{PermissionClass}.Delete)]
|
|
273
|
+
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
|
274
|
+
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
275
|
+
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
276
|
+
public async Task<IActionResult> Delete{Entity}(
|
|
277
|
+
Guid id,
|
|
278
|
+
CancellationToken cancellationToken)
|
|
279
|
+
{
|
|
280
|
+
var entity = await _context.{DbSet}
|
|
281
|
+
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
|
282
|
+
|
|
283
|
+
if (entity == null)
|
|
284
|
+
return NotFound(new { message = "{Entity} not found" });
|
|
285
|
+
|
|
286
|
+
// Check for dependencies before deletion
|
|
287
|
+
// var hasReferences = await _context.ChildEntities.AnyAsync(x => x.{Entity}Id == id, ct);
|
|
288
|
+
// if (hasReferences)
|
|
289
|
+
// return BadRequest(new { message = "Cannot delete: has dependent records" });
|
|
290
|
+
|
|
291
|
+
_context.{DbSet}.Remove(entity);
|
|
292
|
+
await _context.SaveChangesAsync(cancellationToken);
|
|
293
|
+
|
|
294
|
+
_logger.LogWarning("User {User} deleted {Entity} {EntityId} ({Name})",
|
|
295
|
+
_currentUser.Email, id, entity.Name);
|
|
296
|
+
|
|
297
|
+
return NoContent();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
#endregion
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
#region DTOs
|
|
304
|
+
|
|
305
|
+
public record {Entity}ListDto(
|
|
306
|
+
Guid Id,
|
|
307
|
+
string Name,
|
|
308
|
+
string? Description,
|
|
309
|
+
bool IsActive,
|
|
310
|
+
DateTime CreatedAt
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
public record {Entity}DetailDto(
|
|
314
|
+
Guid Id,
|
|
315
|
+
string Name,
|
|
316
|
+
string? Description,
|
|
317
|
+
bool IsActive,
|
|
318
|
+
DateTime CreatedAt,
|
|
319
|
+
DateTime? UpdatedAt
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
public record Create{Entity}Request(
|
|
323
|
+
string Name,
|
|
324
|
+
string? Description
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
public record Update{Entity}Request(
|
|
328
|
+
string? Name,
|
|
329
|
+
string? Description
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
public record PaginatedResult<T>(
|
|
333
|
+
List<T> Items,
|
|
334
|
+
int TotalCount,
|
|
335
|
+
int Page,
|
|
336
|
+
int PageSize
|
|
337
|
+
)
|
|
338
|
+
{
|
|
339
|
+
public int TotalPages => (int)Math.Ceiling((double)TotalCount / PageSize);
|
|
340
|
+
public bool HasPreviousPage => Page > 1;
|
|
341
|
+
public bool HasNextPage => Page < TotalPages;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
#endregion
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
---
|
|
348
|
+
|
|
349
|
+
## Template Auth Controller (Login/Logout)
|
|
350
|
+
|
|
351
|
+
```csharp
|
|
352
|
+
// src/SmartStack.Api/Controllers/AuthController.cs
|
|
353
|
+
// NOTE: This controller already exists - use as reference for auth patterns
|
|
354
|
+
|
|
355
|
+
using Microsoft.AspNetCore.Authorization;
|
|
356
|
+
using Microsoft.AspNetCore.Mvc;
|
|
357
|
+
using Microsoft.EntityFrameworkCore;
|
|
358
|
+
using Microsoft.Extensions.Options;
|
|
359
|
+
using SmartStack.Application.Common.Interfaces;
|
|
360
|
+
using SmartStack.Application.Common.Settings;
|
|
361
|
+
using SmartStack.Domain.Platform.Administration.Users;
|
|
362
|
+
|
|
363
|
+
namespace SmartStack.Api.Controllers;
|
|
364
|
+
|
|
365
|
+
[ApiController]
|
|
366
|
+
[Route("api/[controller]")]
|
|
367
|
+
public class AuthController : ControllerBase
|
|
368
|
+
{
|
|
369
|
+
private readonly IApplicationDbContext _context;
|
|
370
|
+
private readonly IPasswordService _passwordService;
|
|
371
|
+
private readonly IJwtService _jwtService;
|
|
372
|
+
private readonly IUserSessionService _sessionService;
|
|
373
|
+
private readonly ISessionValidationService _sessionValidationService;
|
|
374
|
+
private readonly SessionSettings _sessionSettings;
|
|
375
|
+
private readonly ILogger<AuthController> _logger;
|
|
376
|
+
|
|
377
|
+
// ... Constructor avec tous les services auth
|
|
378
|
+
|
|
379
|
+
#region Login - LOGS CRITIQUES OBLIGATOIRES
|
|
380
|
+
|
|
381
|
+
[HttpPost("login")]
|
|
382
|
+
[AllowAnonymous]
|
|
383
|
+
[ProducesResponseType(typeof(LoginResponse), StatusCodes.Status200OK)]
|
|
384
|
+
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status401Unauthorized)]
|
|
385
|
+
public async Task<ActionResult<LoginResponse>> Login(
|
|
386
|
+
[FromBody] LoginRequest request,
|
|
387
|
+
CancellationToken cancellationToken)
|
|
388
|
+
{
|
|
389
|
+
var ipAddress = GetClientIpAddress();
|
|
390
|
+
var userAgent = Request.Headers.UserAgent.ToString();
|
|
391
|
+
|
|
392
|
+
var user = await _context.Users
|
|
393
|
+
.Include(u => u.UserRoles)
|
|
394
|
+
.ThenInclude(ur => ur.Role)
|
|
395
|
+
.ThenInclude(r => r!.RolePermissions)
|
|
396
|
+
.ThenInclude(rp => rp.Permission)
|
|
397
|
+
.FirstOrDefaultAsync(u => u.Email == request.Email, cancellationToken);
|
|
398
|
+
|
|
399
|
+
// ============================================
|
|
400
|
+
// LOGS CRITIQUES - NE JAMAIS OMETTRE
|
|
401
|
+
// ============================================
|
|
402
|
+
|
|
403
|
+
if (user == null)
|
|
404
|
+
{
|
|
405
|
+
// WARNING: User not found (potential enumeration)
|
|
406
|
+
_logger.LogWarning(
|
|
407
|
+
"Login failed: User not found - {Email} from {IpAddress}",
|
|
408
|
+
request.Email, ipAddress);
|
|
409
|
+
return Unauthorized(new ErrorResponse("Identifiants invalides", "INVALID_CREDENTIALS"));
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (!user.IsActive)
|
|
413
|
+
{
|
|
414
|
+
// WARNING: Disabled account
|
|
415
|
+
_logger.LogWarning(
|
|
416
|
+
"Login failed: Account disabled - {Email} from {IpAddress}",
|
|
417
|
+
request.Email, ipAddress);
|
|
418
|
+
return Unauthorized(new ErrorResponse("Account disabled", "ACCOUNT_DISABLED"));
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (user.IsLocked)
|
|
422
|
+
{
|
|
423
|
+
// CRITICAL: Locked account attempt
|
|
424
|
+
_logger.LogCritical(
|
|
425
|
+
"SECURITY: Login attempt on locked account - User: {Email} (ID: {UserId}) from {IpAddress}",
|
|
426
|
+
request.Email, user.Id, ipAddress);
|
|
427
|
+
return Unauthorized(new ErrorResponse("Account locked", "ACCOUNT_LOCKED_BY_ADMIN"));
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Check brute force protection
|
|
431
|
+
var recentFailedAttempts = await _context.UserSessions
|
|
432
|
+
.Where(s => s.UserId == user.Id && !s.IsSuccessful && s.LoginAt > DateTime.UtcNow.AddMinutes(-15))
|
|
433
|
+
.CountAsync(cancellationToken);
|
|
434
|
+
|
|
435
|
+
if (recentFailedAttempts >= 5)
|
|
436
|
+
{
|
|
437
|
+
// CRITICAL: Too many failed attempts
|
|
438
|
+
_logger.LogCritical(
|
|
439
|
+
"SECURITY: Account temporarily locked due to brute force - User: {Email} (ID: {UserId}) from {IpAddress}",
|
|
440
|
+
request.Email, user.Id, ipAddress);
|
|
441
|
+
return Unauthorized(new ErrorResponse("Account temporarily locked", "ACCOUNT_LOCKED"));
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
|
|
445
|
+
{
|
|
446
|
+
// WARNING: Invalid password with remaining attempts
|
|
447
|
+
var remainingAttempts = 5 - recentFailedAttempts - 1;
|
|
448
|
+
_logger.LogWarning(
|
|
449
|
+
"Login failed: Invalid password - {Email} from {IpAddress}, remaining attempts: {Remaining}",
|
|
450
|
+
request.Email, ipAddress, remainingAttempts);
|
|
451
|
+
|
|
452
|
+
// Log failed attempt to session
|
|
453
|
+
await _sessionService.LogFailedLoginAsync(user.Id, ipAddress, userAgent, cancellationToken);
|
|
454
|
+
|
|
455
|
+
return Unauthorized(new ErrorResponse("Mot de passe incorrect", "INVALID_PASSWORD"));
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// ============================================
|
|
459
|
+
// SUCCESS - Generate tokens
|
|
460
|
+
// ============================================
|
|
461
|
+
|
|
462
|
+
var roles = user.UserRoles.Select(ur => ur.Role!.Name).ToList();
|
|
463
|
+
var permissions = user.UserRoles
|
|
464
|
+
.SelectMany(ur => ur.Role!.RolePermissions)
|
|
465
|
+
.Select(rp => rp.Permission!.Path)
|
|
466
|
+
.Distinct()
|
|
467
|
+
.ToList();
|
|
468
|
+
|
|
469
|
+
var accessToken = _jwtService.GenerateAccessToken(user, roles, permissions);
|
|
470
|
+
var refreshToken = _jwtService.GenerateRefreshToken();
|
|
471
|
+
|
|
472
|
+
await _sessionService.LogLoginAsync(user.Id, accessToken, ipAddress, userAgent, cancellationToken: cancellationToken);
|
|
473
|
+
|
|
474
|
+
// INFO: Successful login
|
|
475
|
+
_logger.LogInformation(
|
|
476
|
+
"User logged in successfully: {Email} from {IpAddress}",
|
|
477
|
+
user.Email, ipAddress);
|
|
478
|
+
|
|
479
|
+
return Ok(new LoginResponse(accessToken, refreshToken, /* UserInfo */));
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
#endregion
|
|
483
|
+
|
|
484
|
+
#region Logout
|
|
485
|
+
|
|
486
|
+
[HttpPost("logout")]
|
|
487
|
+
[Authorize]
|
|
488
|
+
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
|
489
|
+
public async Task<IActionResult> Logout(CancellationToken cancellationToken)
|
|
490
|
+
{
|
|
491
|
+
var token = Request.Headers.Authorization.ToString().Replace("Bearer ", "");
|
|
492
|
+
await _sessionService.LogLogoutAsync(token, cancellationToken);
|
|
493
|
+
|
|
494
|
+
_logger.LogInformation("User logged out: {UserId}", User.FindFirst("sub")?.Value);
|
|
495
|
+
|
|
496
|
+
return NoContent();
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
#endregion
|
|
500
|
+
|
|
501
|
+
#region Change Password - LOG WARNING
|
|
502
|
+
|
|
503
|
+
[HttpPost("change-password")]
|
|
504
|
+
[Authorize]
|
|
505
|
+
[ProducesResponseType(typeof(ChangePasswordResponse), StatusCodes.Status200OK)]
|
|
506
|
+
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)]
|
|
507
|
+
public async Task<ActionResult<ChangePasswordResponse>> ChangePassword(
|
|
508
|
+
[FromBody] ChangePasswordRequest request,
|
|
509
|
+
CancellationToken cancellationToken)
|
|
510
|
+
{
|
|
511
|
+
// ... validation logic
|
|
512
|
+
|
|
513
|
+
user.UpdatePassword(newPasswordHash);
|
|
514
|
+
await _context.SaveChangesAsync(cancellationToken);
|
|
515
|
+
|
|
516
|
+
// Invalidate ALL sessions after password change
|
|
517
|
+
await _sessionService.InvalidateAllUserSessionsAsync(userId, "Password changed", cancellationToken);
|
|
518
|
+
|
|
519
|
+
// WARNING: Sensitive operation
|
|
520
|
+
_logger.LogWarning(
|
|
521
|
+
"Password changed for user {Email} - All sessions invalidated",
|
|
522
|
+
user.Email);
|
|
523
|
+
|
|
524
|
+
return Ok(new ChangePasswordResponse("Password changed", true));
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
#endregion
|
|
528
|
+
|
|
529
|
+
private string GetClientIpAddress()
|
|
530
|
+
{
|
|
531
|
+
var forwardedFor = Request.Headers["X-Forwarded-For"].FirstOrDefault();
|
|
532
|
+
if (!string.IsNullOrEmpty(forwardedFor))
|
|
533
|
+
return forwardedFor.Split(',')[0].Trim();
|
|
534
|
+
return HttpContext.Connection.RemoteIpAddress?.ToString() ?? "Unknown";
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
---
|
|
540
|
+
|
|
541
|
+
## Template Permissions Constants
|
|
542
|
+
|
|
543
|
+
```csharp
|
|
544
|
+
// src/SmartStack.Application/Common/Authorization/Permissions.cs
|
|
545
|
+
// ADD to existing class
|
|
546
|
+
|
|
547
|
+
public static class Permissions
|
|
548
|
+
{
|
|
549
|
+
// ... existing permissions ...
|
|
550
|
+
|
|
551
|
+
public static class {PermissionClass}
|
|
552
|
+
{
|
|
553
|
+
public const string Access = "{permission.path}";
|
|
554
|
+
public const string View = "{permission.path}.read";
|
|
555
|
+
public const string Create = "{permission.path}.create";
|
|
556
|
+
public const string Update = "{permission.path}.update";
|
|
557
|
+
public const string Delete = "{permission.path}.delete";
|
|
558
|
+
// Optional depending on module
|
|
559
|
+
public const string Assign = "{permission.path}.assign";
|
|
560
|
+
public const string Execute = "{permission.path}.execute";
|
|
561
|
+
public const string Export = "{permission.path}.export";
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
```
|
|
565
|
+
|
|
566
|
+
---
|
|
567
|
+
|
|
568
|
+
## Template PermissionConfiguration Seed
|
|
569
|
+
|
|
570
|
+
> **CRITICAL:** This template is MANDATORY. Without these entries, all API calls will return 403 Forbidden.
|
|
571
|
+
|
|
572
|
+
```csharp
|
|
573
|
+
// src/SmartStack.Infrastructure/Persistence/Configurations/Navigation/PermissionConfiguration.cs
|
|
574
|
+
// ADD in Configure() method, HasData section
|
|
575
|
+
|
|
576
|
+
// ============================================
|
|
577
|
+
// STEP 1: Declare ModuleId
|
|
578
|
+
// ============================================
|
|
579
|
+
// Check in ModuleConfiguration.cs if module already exists
|
|
580
|
+
// Otherwise, create the module first via /application skill
|
|
581
|
+
|
|
582
|
+
var {module}ModuleId = Guid.Parse("{MODULE-GUID}"); // Get from ModuleConfiguration.cs
|
|
583
|
+
|
|
584
|
+
// ============================================
|
|
585
|
+
// STEP 2: Add permissions (HasData)
|
|
586
|
+
// ============================================
|
|
587
|
+
|
|
588
|
+
// Pattern: {application}.{module}.{action}
|
|
589
|
+
// Example: administration.users.read
|
|
590
|
+
|
|
591
|
+
var seedDate = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
|
592
|
+
|
|
593
|
+
builder.HasData(
|
|
594
|
+
// Wildcard permission (full module access)
|
|
595
|
+
new
|
|
596
|
+
{
|
|
597
|
+
Id = Guid.Parse("{NOUVEAU-GUID-1}"),
|
|
598
|
+
Path = "{application}.{module}.*",
|
|
599
|
+
Level = PermissionLevel.Module,
|
|
600
|
+
Action = (PermissionAction?)null,
|
|
601
|
+
IsWildcard = true,
|
|
602
|
+
ModuleId = {module}ModuleId,
|
|
603
|
+
Description = "Full {module} management",
|
|
604
|
+
CreatedAt = seedDate
|
|
605
|
+
},
|
|
606
|
+
|
|
607
|
+
// Read permission
|
|
608
|
+
new
|
|
609
|
+
{
|
|
610
|
+
Id = Guid.Parse("{NOUVEAU-GUID-2}"),
|
|
611
|
+
Path = "{application}.{module}.read",
|
|
612
|
+
Level = PermissionLevel.Module,
|
|
613
|
+
Action = PermissionAction.Read,
|
|
614
|
+
IsWildcard = false,
|
|
615
|
+
ModuleId = {module}ModuleId,
|
|
616
|
+
Description = "View {module}",
|
|
617
|
+
CreatedAt = seedDate
|
|
618
|
+
},
|
|
619
|
+
|
|
620
|
+
// Create permission
|
|
621
|
+
new
|
|
622
|
+
{
|
|
623
|
+
Id = Guid.Parse("{NOUVEAU-GUID-3}"),
|
|
624
|
+
Path = "{application}.{module}.create",
|
|
625
|
+
Level = PermissionLevel.Module,
|
|
626
|
+
Action = PermissionAction.Create,
|
|
627
|
+
IsWildcard = false,
|
|
628
|
+
ModuleId = {module}ModuleId,
|
|
629
|
+
Description = "Create {module}",
|
|
630
|
+
CreatedAt = seedDate
|
|
631
|
+
},
|
|
632
|
+
|
|
633
|
+
// Update permission
|
|
634
|
+
new
|
|
635
|
+
{
|
|
636
|
+
Id = Guid.Parse("{NOUVEAU-GUID-4}"),
|
|
637
|
+
Path = "{application}.{module}.update",
|
|
638
|
+
Level = PermissionLevel.Module,
|
|
639
|
+
Action = PermissionAction.Update,
|
|
640
|
+
IsWildcard = false,
|
|
641
|
+
ModuleId = {module}ModuleId,
|
|
642
|
+
Description = "Update {module}",
|
|
643
|
+
CreatedAt = seedDate
|
|
644
|
+
},
|
|
645
|
+
|
|
646
|
+
// Delete permission
|
|
647
|
+
new
|
|
648
|
+
{
|
|
649
|
+
Id = Guid.Parse("{NOUVEAU-GUID-5}"),
|
|
650
|
+
Path = "{application}.{module}.delete",
|
|
651
|
+
Level = PermissionLevel.Module,
|
|
652
|
+
Action = PermissionAction.Delete,
|
|
653
|
+
IsWildcard = false,
|
|
654
|
+
ModuleId = {module}ModuleId,
|
|
655
|
+
Description = "Delete {module}",
|
|
656
|
+
CreatedAt = seedDate
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// Optional actions depending on module:
|
|
660
|
+
// - PermissionAction.Assign → To assign resources/roles
|
|
661
|
+
// - PermissionAction.Execute → To execute actions (export, etc.)
|
|
662
|
+
);
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
### GUID Generation
|
|
666
|
+
|
|
667
|
+
```bash
|
|
668
|
+
# PowerShell (Windows)
|
|
669
|
+
[guid]::NewGuid().ToString()
|
|
670
|
+
|
|
671
|
+
# Bash (Linux/Mac)
|
|
672
|
+
uuidgen | tr '[:upper:]' '[:lower:]'
|
|
673
|
+
```
|
|
674
|
+
|
|
675
|
+
### Validation Consistency Permissions.cs ↔ PermissionConfiguration.cs
|
|
676
|
+
|
|
677
|
+
> **RULE:** Each constant in `Permissions.cs` MUST have a corresponding entry in `PermissionConfiguration.cs`
|
|
678
|
+
|
|
679
|
+
```
|
|
680
|
+
┌─────────────────────────────────────────────────────────────────────────────┐
|
|
681
|
+
│ CONSISTENCY VALIDATION │
|
|
682
|
+
├─────────────────────────────────────────────────────────────────────────────┤
|
|
683
|
+
│ │
|
|
684
|
+
│ Permissions.cs PermissionConfiguration.cs │
|
|
685
|
+
│ ────────────────────────────── ────────────────────────────────────── │
|
|
686
|
+
│ Permissions.Support.Tickets.View → Path = "support.tickets.read"│
|
|
687
|
+
│ Permissions.Support.Tickets.Create→ Path = "support.tickets.create"│
|
|
688
|
+
│ Permissions.Support.Tickets.Update→ Path = "support.tickets.update"│
|
|
689
|
+
│ Permissions.Support.Tickets.Delete→ Path = "support.tickets.delete"│
|
|
690
|
+
│ │
|
|
691
|
+
│ WARNING: COMMON ERROR: │
|
|
692
|
+
│ - Permissions.cs: "support.tickets.read" │
|
|
693
|
+
│ - PermissionConfiguration.cs: MISSING │
|
|
694
|
+
│ → Result: 403 Forbidden for ALL users │
|
|
695
|
+
│ │
|
|
696
|
+
└─────────────────────────────────────────────────────────────────────────────┘
|
|
697
|
+
```
|
|
698
|
+
|
|
699
|
+
### Post-generation commands
|
|
700
|
+
|
|
701
|
+
After adding entries in both files:
|
|
702
|
+
|
|
703
|
+
```bash
|
|
704
|
+
# 1. Create migration
|
|
705
|
+
/efcore migration Add{Module}Permissions
|
|
706
|
+
|
|
707
|
+
# 2. Apply migration
|
|
708
|
+
/efcore db-deploy
|
|
709
|
+
|
|
710
|
+
# 3. Verify (optional)
|
|
711
|
+
/efcore db-status
|
|
712
|
+
```
|
|
713
|
+
|
|
714
|
+
---
|
|
715
|
+
|
|
716
|
+
## Template Controller avec Relations
|
|
717
|
+
|
|
718
|
+
```csharp
|
|
719
|
+
// For controllers with related entities (ex: Tickets with Comments)
|
|
720
|
+
|
|
721
|
+
#region GET with Includes
|
|
722
|
+
|
|
723
|
+
[HttpGet("{id:guid}")]
|
|
724
|
+
[RequirePermission(Permissions.{PermissionClass}.View)]
|
|
725
|
+
[ProducesResponseType(typeof({Entity}DetailDto), StatusCodes.Status200OK)]
|
|
726
|
+
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
727
|
+
public async Task<ActionResult<{Entity}DetailDto>> Get{Entity}(
|
|
728
|
+
Guid id,
|
|
729
|
+
CancellationToken cancellationToken)
|
|
730
|
+
{
|
|
731
|
+
var entity = await _context.{DbSet}
|
|
732
|
+
.Include(x => x.CreatedByUser)
|
|
733
|
+
.Include(x => x.AssignedToUser)
|
|
734
|
+
.Include(x => x.Comments)
|
|
735
|
+
.ThenInclude(c => c.Author)
|
|
736
|
+
.Include(x => x.Attachments)
|
|
737
|
+
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
|
738
|
+
|
|
739
|
+
if (entity == null)
|
|
740
|
+
return NotFound(new { message = "{Entity} not found" });
|
|
741
|
+
|
|
742
|
+
return Ok(MapToDetailDto(entity));
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
#endregion
|
|
746
|
+
|
|
747
|
+
#region Nested Resources
|
|
748
|
+
|
|
749
|
+
[HttpGet("{parentId:guid}/children")]
|
|
750
|
+
[RequirePermission(Permissions.{PermissionClass}.View)]
|
|
751
|
+
[ProducesResponseType(typeof(List<ChildDto>), StatusCodes.Status200OK)]
|
|
752
|
+
public async Task<ActionResult<List<ChildDto>>> GetChildren(
|
|
753
|
+
Guid parentId,
|
|
754
|
+
CancellationToken cancellationToken)
|
|
755
|
+
{
|
|
756
|
+
var children = await _context.Children
|
|
757
|
+
.Where(x => x.ParentId == parentId)
|
|
758
|
+
.OrderByDescending(x => x.CreatedAt)
|
|
759
|
+
.Select(x => new ChildDto(x.Id, x.Name, x.CreatedAt))
|
|
760
|
+
.ToListAsync(cancellationToken);
|
|
761
|
+
|
|
762
|
+
return Ok(children);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
[HttpPost("{parentId:guid}/children")]
|
|
766
|
+
[RequirePermission(Permissions.{PermissionClass}.Create)]
|
|
767
|
+
[ProducesResponseType(typeof(ChildDto), StatusCodes.Status201Created)]
|
|
768
|
+
public async Task<ActionResult<ChildDto>> AddChild(
|
|
769
|
+
Guid parentId,
|
|
770
|
+
[FromBody] CreateChildRequest request,
|
|
771
|
+
CancellationToken cancellationToken)
|
|
772
|
+
{
|
|
773
|
+
var parent = await _context.{DbSet}
|
|
774
|
+
.FirstOrDefaultAsync(x => x.Id == parentId, cancellationToken);
|
|
775
|
+
|
|
776
|
+
if (parent == null)
|
|
777
|
+
return NotFound(new { message = "Parent not found" });
|
|
778
|
+
|
|
779
|
+
var child = Child.Create(parentId, request.Name, _currentUser.UserId!.Value);
|
|
780
|
+
|
|
781
|
+
_context.Children.Add(child);
|
|
782
|
+
await _context.SaveChangesAsync(cancellationToken);
|
|
783
|
+
|
|
784
|
+
_logger.LogInformation("User {User} added child to {Entity} {ParentId}",
|
|
785
|
+
_currentUser.Email, parentId);
|
|
786
|
+
|
|
787
|
+
return CreatedAtAction(
|
|
788
|
+
nameof(GetChildren),
|
|
789
|
+
new { parentId },
|
|
790
|
+
new ChildDto(child.Id, child.Name, child.CreatedAt));
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
#endregion
|
|
794
|
+
```
|
|
795
|
+
|
|
796
|
+
---
|
|
797
|
+
|
|
798
|
+
## Reusable Patterns
|
|
799
|
+
|
|
800
|
+
### Error Response Standard
|
|
801
|
+
|
|
802
|
+
```csharp
|
|
803
|
+
public record ErrorResponse(string Message, string? Code = null);
|
|
804
|
+
|
|
805
|
+
// Usage:
|
|
806
|
+
return BadRequest(new ErrorResponse("Validation failed", "VALIDATION_ERROR"));
|
|
807
|
+
return Conflict(new ErrorResponse("Already exists", "DUPLICATE"));
|
|
808
|
+
return NotFound(new { message = "Resource not found" });
|
|
809
|
+
```
|
|
810
|
+
|
|
811
|
+
### Pagination Query Extension
|
|
812
|
+
|
|
813
|
+
```csharp
|
|
814
|
+
public static class QueryableExtensions
|
|
815
|
+
{
|
|
816
|
+
public static async Task<PaginatedResult<T>> ToPaginatedResultAsync<T>(
|
|
817
|
+
this IQueryable<T> query,
|
|
818
|
+
int page,
|
|
819
|
+
int pageSize,
|
|
820
|
+
CancellationToken ct = default)
|
|
821
|
+
{
|
|
822
|
+
var totalCount = await query.CountAsync(ct);
|
|
823
|
+
var items = await query
|
|
824
|
+
.Skip((page - 1) * pageSize)
|
|
825
|
+
.Take(pageSize)
|
|
826
|
+
.ToListAsync(ct);
|
|
827
|
+
|
|
828
|
+
return new PaginatedResult<T>(items, totalCount, page, pageSize);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
```
|
|
832
|
+
|
|
833
|
+
### Log Context Pattern
|
|
834
|
+
|
|
835
|
+
```csharp
|
|
836
|
+
// Always include user context in logs
|
|
837
|
+
_logger.LogInformation(
|
|
838
|
+
"User {User} ({UserId}) performed {Action} on {Entity} {EntityId}",
|
|
839
|
+
_currentUser.Email,
|
|
840
|
+
_currentUser.UserId,
|
|
841
|
+
"Create",
|
|
842
|
+
"{Entity}",
|
|
843
|
+
entity.Id);
|
|
844
|
+
```
|
|
845
|
+
|
|
846
|
+
---
|
|
847
|
+
|
|
848
|
+
## Template Section-Level Permissions (Level 3)
|
|
849
|
+
|
|
850
|
+
> **Usage:** When a Module has multiple sub-pages/tabs with different permissions (ex: AI → Dashboard, Settings, Prompts)
|
|
851
|
+
|
|
852
|
+
### Permissions.cs - Section
|
|
853
|
+
|
|
854
|
+
```csharp
|
|
855
|
+
// src/SmartStack.Application/Common/Authorization/Permissions.cs
|
|
856
|
+
|
|
857
|
+
public static class Admin
|
|
858
|
+
{
|
|
859
|
+
public static class {Module}
|
|
860
|
+
{
|
|
861
|
+
// Section permissions (Level 3)
|
|
862
|
+
public static class {Section}
|
|
863
|
+
{
|
|
864
|
+
public const string View = "{application}.{module}.{section}.read";
|
|
865
|
+
public const string Create = "{application}.{module}.{section}.create";
|
|
866
|
+
public const string Update = "{application}.{module}.{section}.update";
|
|
867
|
+
public const string Delete = "{application}.{module}.{section}.delete";
|
|
868
|
+
public const string Execute = "{application}.{module}.{section}.execute";
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
```
|
|
873
|
+
|
|
874
|
+
### PermissionConfiguration.cs - Section Seed
|
|
875
|
+
|
|
876
|
+
```csharp
|
|
877
|
+
// src/SmartStack.Infrastructure/Persistence/Configurations/Navigation/PermissionConfiguration.cs
|
|
878
|
+
// ADD in Configure() method, HasData section
|
|
879
|
+
|
|
880
|
+
// ============================================
|
|
881
|
+
// STEP 1: Declare SectionId
|
|
882
|
+
// ============================================
|
|
883
|
+
// Get from NavigationSectionConfiguration.cs
|
|
884
|
+
|
|
885
|
+
var {section}SectionId = Guid.Parse("{SECTION-GUID}");
|
|
886
|
+
|
|
887
|
+
// ============================================
|
|
888
|
+
// STEP 2: Add Section permissions (Level 3)
|
|
889
|
+
// ============================================
|
|
890
|
+
|
|
891
|
+
// Pattern: {application}.{module}.{section}.{action}
|
|
892
|
+
// Example: administration.ai.settings.read
|
|
893
|
+
|
|
894
|
+
builder.HasData(
|
|
895
|
+
// Wildcard permission (full section access)
|
|
896
|
+
new
|
|
897
|
+
{
|
|
898
|
+
Id = Guid.Parse("{NOUVEAU-GUID-1}"),
|
|
899
|
+
Path = "{application}.{module}.{section}.*",
|
|
900
|
+
Level = PermissionLevel.Section,
|
|
901
|
+
Action = (PermissionAction?)null,
|
|
902
|
+
IsWildcard = true,
|
|
903
|
+
SectionId = {section}SectionId,
|
|
904
|
+
Description = "Full {section} access",
|
|
905
|
+
CreatedAt = seedDate
|
|
906
|
+
},
|
|
907
|
+
|
|
908
|
+
// Read permission
|
|
909
|
+
new
|
|
910
|
+
{
|
|
911
|
+
Id = Guid.Parse("{NOUVEAU-GUID-2}"),
|
|
912
|
+
Path = "{application}.{module}.{section}.read",
|
|
913
|
+
Level = PermissionLevel.Section,
|
|
914
|
+
Action = PermissionAction.Read,
|
|
915
|
+
IsWildcard = false,
|
|
916
|
+
SectionId = {section}SectionId,
|
|
917
|
+
Description = "View {section}",
|
|
918
|
+
CreatedAt = seedDate
|
|
919
|
+
},
|
|
920
|
+
|
|
921
|
+
// Create permission
|
|
922
|
+
new
|
|
923
|
+
{
|
|
924
|
+
Id = Guid.Parse("{NOUVEAU-GUID-3}"),
|
|
925
|
+
Path = "{application}.{module}.{section}.create",
|
|
926
|
+
Level = PermissionLevel.Section,
|
|
927
|
+
Action = PermissionAction.Create,
|
|
928
|
+
IsWildcard = false,
|
|
929
|
+
SectionId = {section}SectionId,
|
|
930
|
+
Description = "Create in {section}",
|
|
931
|
+
CreatedAt = seedDate
|
|
932
|
+
},
|
|
933
|
+
|
|
934
|
+
// Update permission
|
|
935
|
+
new
|
|
936
|
+
{
|
|
937
|
+
Id = Guid.Parse("{NOUVEAU-GUID-4}"),
|
|
938
|
+
Path = "{application}.{module}.{section}.update",
|
|
939
|
+
Level = PermissionLevel.Section,
|
|
940
|
+
Action = PermissionAction.Update,
|
|
941
|
+
IsWildcard = false,
|
|
942
|
+
SectionId = {section}SectionId,
|
|
943
|
+
Description = "Update in {section}",
|
|
944
|
+
CreatedAt = seedDate
|
|
945
|
+
},
|
|
946
|
+
|
|
947
|
+
// Delete permission
|
|
948
|
+
new
|
|
949
|
+
{
|
|
950
|
+
Id = Guid.Parse("{NOUVEAU-GUID-5}"),
|
|
951
|
+
Path = "{application}.{module}.{section}.delete",
|
|
952
|
+
Level = PermissionLevel.Section,
|
|
953
|
+
Action = PermissionAction.Delete,
|
|
954
|
+
IsWildcard = false,
|
|
955
|
+
SectionId = {section}SectionId,
|
|
956
|
+
Description = "Delete in {section}",
|
|
957
|
+
CreatedAt = seedDate
|
|
958
|
+
},
|
|
959
|
+
|
|
960
|
+
// Execute permission (optional)
|
|
961
|
+
new
|
|
962
|
+
{
|
|
963
|
+
Id = Guid.Parse("{NOUVEAU-GUID-6}"),
|
|
964
|
+
Path = "{application}.{module}.{section}.execute",
|
|
965
|
+
Level = PermissionLevel.Section,
|
|
966
|
+
Action = PermissionAction.Execute,
|
|
967
|
+
IsWildcard = false,
|
|
968
|
+
SectionId = {section}SectionId,
|
|
969
|
+
Description = "Execute actions in {section}",
|
|
970
|
+
CreatedAt = seedDate
|
|
971
|
+
}
|
|
972
|
+
);
|
|
973
|
+
```
|
|
974
|
+
|
|
975
|
+
---
|
|
976
|
+
|
|
977
|
+
## Template Resource-Level Permissions (Level 4)
|
|
978
|
+
|
|
979
|
+
> **Usage:** For the finest granularity level (ex: Prompts → Blocks, Users → Profiles)
|
|
980
|
+
> **CRITICAL:** Used when a Section contains sub-resources with distinct permissions
|
|
981
|
+
|
|
982
|
+
### Permissions.cs - Resource
|
|
983
|
+
|
|
984
|
+
```csharp
|
|
985
|
+
// src/SmartStack.Application/Common/Authorization/Permissions.cs
|
|
986
|
+
|
|
987
|
+
public static class Admin
|
|
988
|
+
{
|
|
989
|
+
public static class {Module}
|
|
990
|
+
{
|
|
991
|
+
public static class {Section}
|
|
992
|
+
{
|
|
993
|
+
// Section-level permissions...
|
|
994
|
+
|
|
995
|
+
// Resource permissions (Level 4 - finest granularity)
|
|
996
|
+
public static class {Resource}
|
|
997
|
+
{
|
|
998
|
+
public const string View = "{application}.{module}.{section}.{resource}.read";
|
|
999
|
+
public const string Create = "{application}.{module}.{section}.{resource}.create";
|
|
1000
|
+
public const string Update = "{application}.{module}.{section}.{resource}.update";
|
|
1001
|
+
public const string Delete = "{application}.{module}.{section}.{resource}.delete";
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
```
|
|
1007
|
+
|
|
1008
|
+
### PermissionConfiguration.cs - Resource Seed
|
|
1009
|
+
|
|
1010
|
+
```csharp
|
|
1011
|
+
// src/SmartStack.Infrastructure/Persistence/Configurations/Navigation/PermissionConfiguration.cs
|
|
1012
|
+
// ADD in Configure() method, HasData section
|
|
1013
|
+
|
|
1014
|
+
// ============================================
|
|
1015
|
+
// STEP 1: Declare ResourceId
|
|
1016
|
+
// ============================================
|
|
1017
|
+
// Get from NavigationResourceConfiguration.cs
|
|
1018
|
+
|
|
1019
|
+
var {resource}ResourceId = Guid.Parse("{RESOURCE-GUID}");
|
|
1020
|
+
|
|
1021
|
+
// ============================================
|
|
1022
|
+
// STEP 2: Add Resource permissions (Level 4)
|
|
1023
|
+
// ============================================
|
|
1024
|
+
|
|
1025
|
+
// Pattern: {application}.{module}.{section}.{resource}.{action}
|
|
1026
|
+
// Example: administration.ai.prompts.blocks.read
|
|
1027
|
+
|
|
1028
|
+
builder.HasData(
|
|
1029
|
+
// Wildcard permission (full resource access)
|
|
1030
|
+
new
|
|
1031
|
+
{
|
|
1032
|
+
Id = Guid.Parse("{NOUVEAU-GUID-1}"),
|
|
1033
|
+
Path = "{application}.{module}.{section}.{resource}.*",
|
|
1034
|
+
Level = PermissionLevel.Resource,
|
|
1035
|
+
Action = (PermissionAction?)null,
|
|
1036
|
+
IsWildcard = true,
|
|
1037
|
+
ResourceId = {resource}ResourceId,
|
|
1038
|
+
Description = "Full {resource} access",
|
|
1039
|
+
CreatedAt = seedDate
|
|
1040
|
+
},
|
|
1041
|
+
|
|
1042
|
+
// Read permission
|
|
1043
|
+
new
|
|
1044
|
+
{
|
|
1045
|
+
Id = Guid.Parse("{NOUVEAU-GUID-2}"),
|
|
1046
|
+
Path = "{application}.{module}.{section}.{resource}.read",
|
|
1047
|
+
Level = PermissionLevel.Resource,
|
|
1048
|
+
Action = PermissionAction.Read,
|
|
1049
|
+
IsWildcard = false,
|
|
1050
|
+
ResourceId = {resource}ResourceId,
|
|
1051
|
+
Description = "View {resource}",
|
|
1052
|
+
CreatedAt = seedDate
|
|
1053
|
+
},
|
|
1054
|
+
|
|
1055
|
+
// Create permission
|
|
1056
|
+
new
|
|
1057
|
+
{
|
|
1058
|
+
Id = Guid.Parse("{NOUVEAU-GUID-3}"),
|
|
1059
|
+
Path = "{application}.{module}.{section}.{resource}.create",
|
|
1060
|
+
Level = PermissionLevel.Resource,
|
|
1061
|
+
Action = PermissionAction.Create,
|
|
1062
|
+
IsWildcard = false,
|
|
1063
|
+
ResourceId = {resource}ResourceId,
|
|
1064
|
+
Description = "Create {resource}",
|
|
1065
|
+
CreatedAt = seedDate
|
|
1066
|
+
},
|
|
1067
|
+
|
|
1068
|
+
// Update permission
|
|
1069
|
+
new
|
|
1070
|
+
{
|
|
1071
|
+
Id = Guid.Parse("{NOUVEAU-GUID-4}"),
|
|
1072
|
+
Path = "{application}.{module}.{section}.{resource}.update",
|
|
1073
|
+
Level = PermissionLevel.Resource,
|
|
1074
|
+
Action = PermissionAction.Update,
|
|
1075
|
+
IsWildcard = false,
|
|
1076
|
+
ResourceId = {resource}ResourceId,
|
|
1077
|
+
Description = "Update {resource}",
|
|
1078
|
+
CreatedAt = seedDate
|
|
1079
|
+
},
|
|
1080
|
+
|
|
1081
|
+
// Delete permission
|
|
1082
|
+
new
|
|
1083
|
+
{
|
|
1084
|
+
Id = Guid.Parse("{NOUVEAU-GUID-5}"),
|
|
1085
|
+
Path = "{application}.{module}.{section}.{resource}.delete",
|
|
1086
|
+
Level = PermissionLevel.Resource,
|
|
1087
|
+
Action = PermissionAction.Delete,
|
|
1088
|
+
IsWildcard = false,
|
|
1089
|
+
ResourceId = {resource}ResourceId,
|
|
1090
|
+
Description = "Delete {resource}",
|
|
1091
|
+
CreatedAt = seedDate
|
|
1092
|
+
}
|
|
1093
|
+
);
|
|
1094
|
+
```
|
|
1095
|
+
|
|
1096
|
+
---
|
|
1097
|
+
|
|
1098
|
+
## Template Bulk Operations (Batch Insertion)
|
|
1099
|
+
|
|
1100
|
+
> **MANDATORY:** Always provide bulk endpoints when creating a CRUD controller
|
|
1101
|
+
|
|
1102
|
+
### Permissions.cs - Bulk Operations
|
|
1103
|
+
|
|
1104
|
+
```csharp
|
|
1105
|
+
// src/SmartStack.Application/Common/Authorization/Permissions.cs
|
|
1106
|
+
|
|
1107
|
+
public static class {Module}
|
|
1108
|
+
{
|
|
1109
|
+
// CRUD standard
|
|
1110
|
+
public const string View = "{path}.read";
|
|
1111
|
+
public const string Create = "{path}.create";
|
|
1112
|
+
public const string Update = "{path}.update";
|
|
1113
|
+
public const string Delete = "{path}.delete";
|
|
1114
|
+
|
|
1115
|
+
// Bulk operations (MANDATORY for all CRUD modules)
|
|
1116
|
+
public const string BulkCreate = "{path}.bulk-create";
|
|
1117
|
+
public const string BulkUpdate = "{path}.bulk-update";
|
|
1118
|
+
public const string BulkDelete = "{path}.bulk-delete";
|
|
1119
|
+
public const string Export = "{path}.export";
|
|
1120
|
+
public const string Import = "{path}.import";
|
|
1121
|
+
}
|
|
1122
|
+
```
|
|
1123
|
+
|
|
1124
|
+
### PermissionConfiguration.cs - Bulk Permissions Seed
|
|
1125
|
+
|
|
1126
|
+
```csharp
|
|
1127
|
+
// Add after standard CRUD permissions
|
|
1128
|
+
|
|
1129
|
+
// Bulk Create permission
|
|
1130
|
+
new
|
|
1131
|
+
{
|
|
1132
|
+
Id = Guid.Parse("{NOUVEAU-GUID-BULK-1}"),
|
|
1133
|
+
Path = "{application}.{module}.bulk-create",
|
|
1134
|
+
Level = PermissionLevel.Module,
|
|
1135
|
+
Action = PermissionAction.Create,
|
|
1136
|
+
IsWildcard = false,
|
|
1137
|
+
ModuleId = {module}ModuleId,
|
|
1138
|
+
Description = "Bulk create {module}",
|
|
1139
|
+
CreatedAt = seedDate
|
|
1140
|
+
},
|
|
1141
|
+
|
|
1142
|
+
// Bulk Update permission
|
|
1143
|
+
new
|
|
1144
|
+
{
|
|
1145
|
+
Id = Guid.Parse("{NOUVEAU-GUID-BULK-2}"),
|
|
1146
|
+
Path = "{application}.{module}.bulk-update",
|
|
1147
|
+
Level = PermissionLevel.Module,
|
|
1148
|
+
Action = PermissionAction.Update,
|
|
1149
|
+
IsWildcard = false,
|
|
1150
|
+
ModuleId = {module}ModuleId,
|
|
1151
|
+
Description = "Bulk update {module}",
|
|
1152
|
+
CreatedAt = seedDate
|
|
1153
|
+
},
|
|
1154
|
+
|
|
1155
|
+
// Bulk Delete permission
|
|
1156
|
+
new
|
|
1157
|
+
{
|
|
1158
|
+
Id = Guid.Parse("{NOUVEAU-GUID-BULK-3}"),
|
|
1159
|
+
Path = "{application}.{module}.bulk-delete",
|
|
1160
|
+
Level = PermissionLevel.Module,
|
|
1161
|
+
Action = PermissionAction.Delete,
|
|
1162
|
+
IsWildcard = false,
|
|
1163
|
+
ModuleId = {module}ModuleId,
|
|
1164
|
+
Description = "Bulk delete {module}",
|
|
1165
|
+
CreatedAt = seedDate
|
|
1166
|
+
},
|
|
1167
|
+
|
|
1168
|
+
// Export permission
|
|
1169
|
+
new
|
|
1170
|
+
{
|
|
1171
|
+
Id = Guid.Parse("{NOUVEAU-GUID-EXPORT}"),
|
|
1172
|
+
Path = "{application}.{module}.export",
|
|
1173
|
+
Level = PermissionLevel.Module,
|
|
1174
|
+
Action = PermissionAction.Execute,
|
|
1175
|
+
IsWildcard = false,
|
|
1176
|
+
ModuleId = {module}ModuleId,
|
|
1177
|
+
Description = "Export {module} data",
|
|
1178
|
+
CreatedAt = seedDate
|
|
1179
|
+
},
|
|
1180
|
+
|
|
1181
|
+
// Import permission
|
|
1182
|
+
new
|
|
1183
|
+
{
|
|
1184
|
+
Id = Guid.Parse("{NOUVEAU-GUID-IMPORT}"),
|
|
1185
|
+
Path = "{application}.{module}.import",
|
|
1186
|
+
Level = PermissionLevel.Module,
|
|
1187
|
+
Action = PermissionAction.Create,
|
|
1188
|
+
IsWildcard = false,
|
|
1189
|
+
ModuleId = {module}ModuleId,
|
|
1190
|
+
Description = "Import {module} data",
|
|
1191
|
+
CreatedAt = seedDate
|
|
1192
|
+
}
|
|
1193
|
+
```
|
|
1194
|
+
|
|
1195
|
+
### Controller Endpoints - Bulk Operations
|
|
1196
|
+
|
|
1197
|
+
```csharp
|
|
1198
|
+
// src/SmartStack.Api/Controllers/{Area}/{Module}Controller.cs
|
|
1199
|
+
// ADD after standard CRUD endpoints
|
|
1200
|
+
|
|
1201
|
+
#region BULK OPERATIONS
|
|
1202
|
+
|
|
1203
|
+
/// <summary>
|
|
1204
|
+
/// Bulk create multiple entities
|
|
1205
|
+
/// </summary>
|
|
1206
|
+
[HttpPost("bulk")]
|
|
1207
|
+
[RequirePermission(Permissions.{PermissionClass}.BulkCreate)]
|
|
1208
|
+
[ProducesResponseType(typeof(BulkOperationResult<{Entity}Dto>), StatusCodes.Status201Created)]
|
|
1209
|
+
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
1210
|
+
public async Task<ActionResult<BulkOperationResult<{Entity}Dto>>> BulkCreate{Entity}(
|
|
1211
|
+
[FromBody] List<Create{Entity}Request> requests,
|
|
1212
|
+
CancellationToken cancellationToken)
|
|
1213
|
+
{
|
|
1214
|
+
if (requests == null || requests.Count == 0)
|
|
1215
|
+
return BadRequest(new { message = "No items provided" });
|
|
1216
|
+
|
|
1217
|
+
if (requests.Count > 100)
|
|
1218
|
+
return BadRequest(new { message = "Maximum 100 items per bulk operation" });
|
|
1219
|
+
|
|
1220
|
+
var results = new List<{Entity}Dto>();
|
|
1221
|
+
var errors = new List<BulkOperationError>();
|
|
1222
|
+
|
|
1223
|
+
for (int i = 0; i < requests.Count; i++)
|
|
1224
|
+
{
|
|
1225
|
+
try
|
|
1226
|
+
{
|
|
1227
|
+
var entity = {Entity}.Create(
|
|
1228
|
+
requests[i].Name,
|
|
1229
|
+
requests[i].Description
|
|
1230
|
+
);
|
|
1231
|
+
|
|
1232
|
+
_context.{DbSet}.Add(entity);
|
|
1233
|
+
results.Add(new {Entity}Dto(entity.Id, entity.Name));
|
|
1234
|
+
}
|
|
1235
|
+
catch (Exception ex)
|
|
1236
|
+
{
|
|
1237
|
+
errors.Add(new BulkOperationError(i, requests[i].Name, ex.Message));
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
await _context.SaveChangesAsync(cancellationToken);
|
|
1242
|
+
|
|
1243
|
+
_logger.LogInformation("User {User} bulk created {Count} {Entity}(s), {Errors} error(s)",
|
|
1244
|
+
_currentUser.Email, results.Count, errors.Count);
|
|
1245
|
+
|
|
1246
|
+
return CreatedAtAction(
|
|
1247
|
+
nameof(Get{Module}),
|
|
1248
|
+
new BulkOperationResult<{Entity}Dto>(results, errors, results.Count, errors.Count));
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
/// <summary>
|
|
1252
|
+
/// Bulk update multiple entities
|
|
1253
|
+
/// </summary>
|
|
1254
|
+
[HttpPut("bulk")]
|
|
1255
|
+
[RequirePermission(Permissions.{PermissionClass}.BulkUpdate)]
|
|
1256
|
+
[ProducesResponseType(typeof(BulkOperationResult), StatusCodes.Status200OK)]
|
|
1257
|
+
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
1258
|
+
public async Task<ActionResult<BulkOperationResult>> BulkUpdate{Entity}(
|
|
1259
|
+
[FromBody] List<BulkUpdate{Entity}Request> requests,
|
|
1260
|
+
CancellationToken cancellationToken)
|
|
1261
|
+
{
|
|
1262
|
+
if (requests == null || requests.Count == 0)
|
|
1263
|
+
return BadRequest(new { message = "No items provided" });
|
|
1264
|
+
|
|
1265
|
+
if (requests.Count > 100)
|
|
1266
|
+
return BadRequest(new { message = "Maximum 100 items per bulk operation" });
|
|
1267
|
+
|
|
1268
|
+
var ids = requests.Select(r => r.Id).ToList();
|
|
1269
|
+
var entities = await _context.{DbSet}
|
|
1270
|
+
.Where(x => ids.Contains(x.Id))
|
|
1271
|
+
.ToDictionaryAsync(x => x.Id, cancellationToken);
|
|
1272
|
+
|
|
1273
|
+
var updated = 0;
|
|
1274
|
+
var errors = new List<BulkOperationError>();
|
|
1275
|
+
|
|
1276
|
+
for (int i = 0; i < requests.Count; i++)
|
|
1277
|
+
{
|
|
1278
|
+
if (!entities.TryGetValue(requests[i].Id, out var entity))
|
|
1279
|
+
{
|
|
1280
|
+
errors.Add(new BulkOperationError(i, requests[i].Id.ToString(), "Entity not found"));
|
|
1281
|
+
continue;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
try
|
|
1285
|
+
{
|
|
1286
|
+
entity.Update(
|
|
1287
|
+
requests[i].Name ?? entity.Name,
|
|
1288
|
+
requests[i].Description ?? entity.Description
|
|
1289
|
+
);
|
|
1290
|
+
updated++;
|
|
1291
|
+
}
|
|
1292
|
+
catch (Exception ex)
|
|
1293
|
+
{
|
|
1294
|
+
errors.Add(new BulkOperationError(i, requests[i].Id.ToString(), ex.Message));
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
await _context.SaveChangesAsync(cancellationToken);
|
|
1299
|
+
|
|
1300
|
+
_logger.LogInformation("User {User} bulk updated {Count} {Entity}(s), {Errors} error(s)",
|
|
1301
|
+
_currentUser.Email, updated, errors.Count);
|
|
1302
|
+
|
|
1303
|
+
return Ok(new BulkOperationResult(updated, errors.Count, errors));
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
/// <summary>
|
|
1307
|
+
/// Bulk delete multiple entities by IDs
|
|
1308
|
+
/// </summary>
|
|
1309
|
+
[HttpDelete("bulk")]
|
|
1310
|
+
[RequirePermission(Permissions.{PermissionClass}.BulkDelete)]
|
|
1311
|
+
[ProducesResponseType(typeof(BulkOperationResult), StatusCodes.Status200OK)]
|
|
1312
|
+
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
1313
|
+
public async Task<ActionResult<BulkOperationResult>> BulkDelete{Entity}(
|
|
1314
|
+
[FromBody] List<Guid> ids,
|
|
1315
|
+
CancellationToken cancellationToken)
|
|
1316
|
+
{
|
|
1317
|
+
if (ids == null || ids.Count == 0)
|
|
1318
|
+
return BadRequest(new { message = "No IDs provided" });
|
|
1319
|
+
|
|
1320
|
+
if (ids.Count > 100)
|
|
1321
|
+
return BadRequest(new { message = "Maximum 100 items per bulk operation" });
|
|
1322
|
+
|
|
1323
|
+
var entities = await _context.{DbSet}
|
|
1324
|
+
.Where(x => ids.Contains(x.Id))
|
|
1325
|
+
.ToListAsync(cancellationToken);
|
|
1326
|
+
|
|
1327
|
+
var deleted = entities.Count;
|
|
1328
|
+
var notFound = ids.Count - deleted;
|
|
1329
|
+
|
|
1330
|
+
_context.{DbSet}.RemoveRange(entities);
|
|
1331
|
+
await _context.SaveChangesAsync(cancellationToken);
|
|
1332
|
+
|
|
1333
|
+
_logger.LogWarning("User {User} bulk deleted {Count} {Entity}(s), {NotFound} not found",
|
|
1334
|
+
_currentUser.Email, deleted, notFound);
|
|
1335
|
+
|
|
1336
|
+
var errors = notFound > 0
|
|
1337
|
+
? new List<BulkOperationError> { new(-1, "N/A", $"{notFound} entities not found") }
|
|
1338
|
+
: new List<BulkOperationError>();
|
|
1339
|
+
|
|
1340
|
+
return Ok(new BulkOperationResult(deleted, errors.Count, errors));
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
/// <summary>
|
|
1344
|
+
/// Export entities to CSV/Excel
|
|
1345
|
+
/// </summary>
|
|
1346
|
+
[HttpGet("export")]
|
|
1347
|
+
[RequirePermission(Permissions.{PermissionClass}.Export)]
|
|
1348
|
+
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
|
1349
|
+
public async Task<IActionResult> Export{Module}(
|
|
1350
|
+
[FromQuery] string format = "csv",
|
|
1351
|
+
[FromQuery] string? search = null,
|
|
1352
|
+
CancellationToken cancellationToken = default)
|
|
1353
|
+
{
|
|
1354
|
+
var query = _context.{DbSet}.AsQueryable();
|
|
1355
|
+
|
|
1356
|
+
if (!string.IsNullOrWhiteSpace(search))
|
|
1357
|
+
{
|
|
1358
|
+
var searchLower = search.ToLower();
|
|
1359
|
+
query = query.Where(x => x.Name.ToLower().Contains(searchLower));
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
var entities = await query.ToListAsync(cancellationToken);
|
|
1363
|
+
|
|
1364
|
+
_logger.LogInformation("User {User} exported {Count} {Entity}(s) to {Format}",
|
|
1365
|
+
_currentUser.Email, entities.Count, format);
|
|
1366
|
+
|
|
1367
|
+
// Implement CSV/Excel export logic here
|
|
1368
|
+
// Using libraries like CsvHelper or ClosedXML
|
|
1369
|
+
|
|
1370
|
+
var content = format.ToLower() switch
|
|
1371
|
+
{
|
|
1372
|
+
"xlsx" => GenerateExcel(entities),
|
|
1373
|
+
_ => GenerateCsv(entities)
|
|
1374
|
+
};
|
|
1375
|
+
|
|
1376
|
+
var contentType = format.ToLower() == "xlsx"
|
|
1377
|
+
? "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
1378
|
+
: "text/csv";
|
|
1379
|
+
|
|
1380
|
+
var fileName = $"{module}-export-{DateTime.UtcNow:yyyyMMdd-HHmmss}.{format}";
|
|
1381
|
+
|
|
1382
|
+
return File(content, contentType, fileName);
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
#endregion
|
|
1386
|
+
|
|
1387
|
+
#region Bulk DTOs
|
|
1388
|
+
|
|
1389
|
+
public record BulkOperationResult(
|
|
1390
|
+
int SuccessCount,
|
|
1391
|
+
int ErrorCount,
|
|
1392
|
+
List<BulkOperationError> Errors);
|
|
1393
|
+
|
|
1394
|
+
public record BulkOperationResult<T>(
|
|
1395
|
+
List<T> Created,
|
|
1396
|
+
List<BulkOperationError> Errors,
|
|
1397
|
+
int SuccessCount,
|
|
1398
|
+
int ErrorCount);
|
|
1399
|
+
|
|
1400
|
+
public record BulkOperationError(
|
|
1401
|
+
int Index,
|
|
1402
|
+
string Identifier,
|
|
1403
|
+
string Message);
|
|
1404
|
+
|
|
1405
|
+
public record BulkUpdate{Entity}Request(
|
|
1406
|
+
Guid Id,
|
|
1407
|
+
string? Name,
|
|
1408
|
+
string? Description);
|
|
1409
|
+
|
|
1410
|
+
#endregion
|
|
1411
|
+
```
|
|
1412
|
+
|
|
1413
|
+
---
|
|
1414
|
+
|
|
1415
|
+
## Complete Permissions Hierarchy
|
|
1416
|
+
|
|
1417
|
+
```
|
|
1418
|
+
┌─────────────────────────────────────────────────────────────────────────────────┐
|
|
1419
|
+
│ COMPLETE PERMISSIONS HIERARCHY │
|
|
1420
|
+
├─────────────────────────────────────────────────────────────────────────────────┤
|
|
1421
|
+
│ │
|
|
1422
|
+
│ Level 1: APPLICATION │
|
|
1423
|
+
│ └─ Path: {application}.* │
|
|
1424
|
+
│ └─ Ex: administration.* → Full administration access │
|
|
1425
|
+
│ │
|
|
1426
|
+
│ Level 2: MODULE │
|
|
1427
|
+
│ └─ Path: {application}.{module}.{action} │
|
|
1428
|
+
│ └─ Ex: administration.users.read → Read users │
|
|
1429
|
+
│ └─ BULK: administration.users.bulk-create → Batch create │
|
|
1430
|
+
│ │
|
|
1431
|
+
│ Level 3: SECTION │
|
|
1432
|
+
│ └─ Path: {application}.{module}.{section}.{action} │
|
|
1433
|
+
│ └─ Ex: administration.ai.settings.update → Update AI settings │
|
|
1434
|
+
│ │
|
|
1435
|
+
│ Level 4: RESOURCE (finest granularity) │
|
|
1436
|
+
│ └─ Path: {application}.{module}.{section}.{resource}.{action} │
|
|
1437
|
+
│ └─ Ex: administration.ai.prompts.blocks.delete → Delete blocks │
|
|
1438
|
+
│ │
|
|
1439
|
+
└─────────────────────────────────────────────────────────────────────────────────┘
|
|
1440
|
+
```
|
|
1441
|
+
|
|
1442
|
+
---
|
|
1443
|
+
|
|
1444
|
+
## Controller Checklist with Complete Permissions
|
|
1445
|
+
|
|
1446
|
+
```
|
|
1447
|
+
□ CRUD Standard
|
|
1448
|
+
□ GET /api/.../ → {path}.read
|
|
1449
|
+
□ GET /api/.../{id} → {path}.read
|
|
1450
|
+
□ POST /api/.../ → {path}.create
|
|
1451
|
+
□ PUT /api/.../{id} → {path}.update
|
|
1452
|
+
□ DELETE /api/.../{id} → {path}.delete
|
|
1453
|
+
|
|
1454
|
+
□ Bulk Operations
|
|
1455
|
+
□ POST /api/.../bulk → {path}.bulk-create
|
|
1456
|
+
□ PUT /api/.../bulk → {path}.bulk-update
|
|
1457
|
+
□ DELETE /api/.../bulk → {path}.bulk-delete
|
|
1458
|
+
|
|
1459
|
+
□ Export/Import
|
|
1460
|
+
□ GET /api/.../export → {path}.export
|
|
1461
|
+
□ POST /api/.../import → {path}.import
|
|
1462
|
+
|
|
1463
|
+
□ Permissions Configured
|
|
1464
|
+
□ Permissions.cs - Constants defined
|
|
1465
|
+
□ PermissionConfiguration.cs - Seed HasData
|
|
1466
|
+
□ EF Core Migration created
|
|
1467
|
+
□ Migration applied
|
|
1468
|
+
|
|
1469
|
+
□ API Versioning (if applicable)
|
|
1470
|
+
□ See API Versioning section below
|
|
1471
|
+
|
|
1472
|
+
□ Correct Permission Level
|
|
1473
|
+
□ Module (Level 2) - For main CRUD
|
|
1474
|
+
□ Section (Level 3) - If sub-pages with different permissions
|
|
1475
|
+
□ Resource (Level 4) - If sub-resources with distinct permissions
|
|
1476
|
+
```
|
|
1477
|
+
|
|
1478
|
+
---
|
|
1479
|
+
|
|
1480
|
+
## API Versioning
|
|
1481
|
+
|
|
1482
|
+
**SmartStack convention:** Header-based versioning using `Asp.Versioning.Mvc`.
|
|
1483
|
+
|
|
1484
|
+
### Setup
|
|
1485
|
+
|
|
1486
|
+
```csharp
|
|
1487
|
+
// Program.cs
|
|
1488
|
+
builder.Services.AddApiVersioning(options =>
|
|
1489
|
+
{
|
|
1490
|
+
options.DefaultApiVersion = new ApiVersion(1, 0);
|
|
1491
|
+
options.AssumeDefaultVersionWhenUnspecified = true;
|
|
1492
|
+
options.ReportApiVersions = true; // Adds api-supported-versions header
|
|
1493
|
+
options.ApiVersionReader = new HeaderApiVersionReader("api-version");
|
|
1494
|
+
})
|
|
1495
|
+
.AddApiExplorer(options =>
|
|
1496
|
+
{
|
|
1497
|
+
options.GroupNameFormat = "'v'VVV";
|
|
1498
|
+
options.SubstituteApiVersionInUrl = true;
|
|
1499
|
+
});
|
|
1500
|
+
```
|
|
1501
|
+
|
|
1502
|
+
### Controller Usage
|
|
1503
|
+
|
|
1504
|
+
```csharp
|
|
1505
|
+
// v1 - Default (no attribute needed if only one version exists)
|
|
1506
|
+
[ApiController]
|
|
1507
|
+
[ApiVersion("1.0")]
|
|
1508
|
+
[NavRoute("crm.contacts")]
|
|
1509
|
+
public class ContactsController : ControllerBase
|
|
1510
|
+
{
|
|
1511
|
+
[HttpGet]
|
|
1512
|
+
[RequirePermission(Permissions.Crm.Contacts.Read)]
|
|
1513
|
+
public async Task<ActionResult<PaginatedResult<ContactDto>>> GetAll(...) { ... }
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
// v2 - Breaking changes only
|
|
1517
|
+
[ApiController]
|
|
1518
|
+
[ApiVersion("2.0")]
|
|
1519
|
+
[NavRoute("crm.contacts")]
|
|
1520
|
+
public class ContactsV2Controller : ControllerBase
|
|
1521
|
+
{
|
|
1522
|
+
[HttpGet]
|
|
1523
|
+
[RequirePermission(Permissions.Crm.Contacts.Read)]
|
|
1524
|
+
public async Task<ActionResult<PaginatedResult<ContactV2Dto>>> GetAll(...) { ... }
|
|
1525
|
+
}
|
|
1526
|
+
```
|
|
1527
|
+
|
|
1528
|
+
### Deprecation
|
|
1529
|
+
|
|
1530
|
+
```csharp
|
|
1531
|
+
[ApiVersion("1.0", Deprecated = true)] // Adds api-deprecated-versions header
|
|
1532
|
+
[ApiVersion("2.0")]
|
|
1533
|
+
public class ContactsController : ControllerBase
|
|
1534
|
+
{
|
|
1535
|
+
[HttpGet]
|
|
1536
|
+
[MapToApiVersion("1.0")]
|
|
1537
|
+
public async Task<ActionResult<PaginatedResult<ContactDto>>> GetAllV1(...) { ... }
|
|
1538
|
+
|
|
1539
|
+
[HttpGet]
|
|
1540
|
+
[MapToApiVersion("2.0")]
|
|
1541
|
+
public async Task<ActionResult<PaginatedResult<ContactV2Dto>>> GetAllV2(...) { ... }
|
|
1542
|
+
}
|
|
1543
|
+
```
|
|
1544
|
+
|
|
1545
|
+
### SmartStack Versioning Rules
|
|
1546
|
+
|
|
1547
|
+
| Rule | Detail |
|
|
1548
|
+
|------|--------|
|
|
1549
|
+
| Default version | `1.0` (assumed when no header sent) |
|
|
1550
|
+
| Version header | `api-version: 2.0` |
|
|
1551
|
+
| When to version | Breaking changes only (field removal, type change, behavior change) |
|
|
1552
|
+
| Non-breaking changes | Add to existing version (new fields, new endpoints) |
|
|
1553
|
+
| Naming | `V2Controller` suffix or `[MapToApiVersion]` in same controller |
|
|
1554
|
+
| Deprecation | Mark old version deprecated, maintain for 2 releases minimum |
|
|
1555
|
+
| Documentation | Both versions documented via `[ProducesResponseType]` |
|