@atlashub/smartstack-cli 1.5.1 → 1.5.3
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/css/styles.css +2168 -2168
- package/.documentation/js/app.js +794 -794
- package/config/default-config.json +86 -86
- package/config/settings.json +53 -53
- package/config/settings.local.example.json +16 -16
- package/dist/index.js +0 -0
- package/dist/index.js.map +1 -1
- package/package.json +88 -88
- package/templates/agents/action.md +36 -36
- package/templates/agents/efcore/conflicts.md +84 -84
- package/templates/agents/efcore/db-deploy.md +51 -51
- package/templates/agents/efcore/db-reset.md +59 -59
- package/templates/agents/efcore/db-seed.md +56 -56
- package/templates/agents/efcore/db-status.md +64 -64
- package/templates/agents/efcore/migration.md +85 -85
- package/templates/agents/efcore/rebase-snapshot.md +62 -62
- package/templates/agents/efcore/scan.md +90 -90
- package/templates/agents/efcore/squash.md +67 -67
- package/templates/agents/explore-codebase.md +65 -65
- package/templates/agents/explore-docs.md +97 -97
- package/templates/agents/fix-grammar.md +49 -49
- package/templates/agents/gitflow/abort.md +45 -45
- package/templates/agents/gitflow/cleanup.md +85 -85
- package/templates/agents/gitflow/commit.md +40 -40
- package/templates/agents/gitflow/exec.md +48 -48
- package/templates/agents/gitflow/finish.md +92 -92
- package/templates/agents/gitflow/init.md +139 -139
- package/templates/agents/gitflow/merge.md +62 -62
- package/templates/agents/gitflow/plan.md +42 -42
- package/templates/agents/gitflow/pr.md +78 -78
- package/templates/agents/gitflow/review.md +49 -49
- package/templates/agents/gitflow/start.md +61 -61
- package/templates/agents/gitflow/status.md +32 -32
- package/templates/agents/snipper.md +36 -36
- package/templates/agents/websearch.md +46 -46
- package/templates/commands/_resources/formatting-guide.md +124 -124
- package/templates/commands/ai-prompt.md +315 -315
- package/templates/commands/apex/1-analyze.md +100 -100
- package/templates/commands/apex/2-plan.md +145 -145
- package/templates/commands/apex/3-execute.md +171 -171
- package/templates/commands/apex/4-examine.md +116 -116
- package/templates/commands/apex/5-tasks.md +209 -209
- package/templates/commands/apex.md +76 -76
- package/templates/commands/application/create.md +362 -362
- package/templates/commands/application/templates-backend.md +463 -463
- package/templates/commands/application/templates-frontend.md +517 -517
- package/templates/commands/application/templates-i18n.md +478 -478
- package/templates/commands/application/templates-seed.md +362 -362
- package/templates/commands/application.md +303 -303
- package/templates/commands/business-analyse/0-orchestrate.md +640 -640
- package/templates/commands/business-analyse/1-init.md +269 -269
- package/templates/commands/business-analyse/2-discover.md +520 -520
- package/templates/commands/business-analyse/3-analyse.md +408 -408
- package/templates/commands/business-analyse/4-specify.md +598 -598
- package/templates/commands/business-analyse/5-validate.md +326 -326
- package/templates/commands/business-analyse/6-handoff.md +746 -746
- package/templates/commands/business-analyse/7-doc-html.md +602 -602
- package/templates/commands/business-analyse/bug.md +325 -325
- package/templates/commands/business-analyse/change-request.md +368 -368
- package/templates/commands/business-analyse/hotfix.md +200 -200
- package/templates/commands/business-analyse.md +640 -640
- package/templates/commands/controller/create.md +216 -216
- package/templates/commands/controller/postman-templates.md +528 -528
- package/templates/commands/controller/templates.md +600 -600
- package/templates/commands/controller.md +337 -337
- package/templates/commands/create/agent.md +138 -138
- package/templates/commands/create/command.md +166 -166
- package/templates/commands/create/hook.md +234 -234
- package/templates/commands/create/plugin.md +329 -329
- package/templates/commands/create/project.md +507 -507
- package/templates/commands/create/skill.md +199 -199
- package/templates/commands/create.md +220 -220
- package/templates/commands/debug.md +95 -95
- package/templates/commands/documentation/module.md +202 -202
- package/templates/commands/documentation/templates.md +432 -432
- package/templates/commands/documentation.md +190 -190
- package/templates/commands/efcore/_env-check.md +153 -153
- package/templates/commands/efcore/conflicts.md +186 -186
- package/templates/commands/efcore/db-deploy.md +193 -193
- package/templates/commands/efcore/db-reset.md +426 -426
- package/templates/commands/efcore/db-seed.md +326 -326
- package/templates/commands/efcore/db-status.md +226 -226
- package/templates/commands/efcore/migration.md +400 -400
- package/templates/commands/efcore/rebase-snapshot.md +264 -264
- package/templates/commands/efcore/scan.md +198 -198
- package/templates/commands/efcore/squash.md +298 -298
- package/templates/commands/efcore.md +224 -224
- package/templates/commands/epct.md +69 -69
- package/templates/commands/explain.md +186 -186
- package/templates/commands/explore.md +45 -45
- package/templates/commands/feature-full.md +267 -267
- package/templates/commands/gitflow/1-init.md +1038 -1038
- package/templates/commands/gitflow/10-start.md +768 -768
- package/templates/commands/gitflow/11-finish.md +457 -457
- package/templates/commands/gitflow/12-cleanup.md +276 -276
- package/templates/commands/gitflow/13-sync.md +216 -216
- package/templates/commands/gitflow/14-rebase.md +251 -251
- package/templates/commands/gitflow/2-status.md +277 -277
- package/templates/commands/gitflow/3-commit.md +344 -344
- package/templates/commands/gitflow/4-plan.md +145 -145
- package/templates/commands/gitflow/5-exec.md +147 -147
- package/templates/commands/gitflow/6-abort.md +344 -344
- package/templates/commands/gitflow/7-pull-request.md +453 -355
- package/templates/commands/gitflow/8-review.md +240 -176
- package/templates/commands/gitflow/9-merge.md +451 -365
- package/templates/commands/gitflow.md +128 -128
- package/templates/commands/implement.md +663 -663
- package/templates/commands/init.md +567 -567
- package/templates/commands/mcp-integration.md +330 -330
- package/templates/commands/notification.md +129 -129
- package/templates/commands/oneshot.md +57 -57
- package/templates/commands/quick-search.md +72 -72
- package/templates/commands/ralph-loop/cancel-ralph.md +18 -18
- package/templates/commands/ralph-loop/help.md +126 -126
- package/templates/commands/ralph-loop/ralph-loop.md +18 -18
- package/templates/commands/review.md +106 -106
- package/templates/commands/utils/test-web-config.md +160 -160
- package/templates/commands/utils/test-web.md +151 -151
- package/templates/commands/validate.md +233 -233
- package/templates/commands/workflow.md +193 -193
- package/templates/gitflow/config.json +138 -138
- package/templates/hooks/ef-migration-check.md +139 -139
- package/templates/hooks/hooks.json +25 -25
- package/templates/hooks/stop-hook.sh +177 -177
- package/templates/skills/ai-prompt/SKILL.md +778 -778
- package/templates/skills/application/SKILL.md +563 -563
- package/templates/skills/application/templates-backend.md +450 -450
- package/templates/skills/application/templates-frontend.md +531 -531
- package/templates/skills/application/templates-i18n.md +520 -520
- package/templates/skills/application/templates-seed.md +647 -647
- package/templates/skills/business-analyse/SKILL.md +191 -191
- package/templates/skills/business-analyse/questionnaire.md +283 -283
- package/templates/skills/business-analyse/templates-frd.md +477 -477
- package/templates/skills/business-analyse/templates-react.md +580 -580
- package/templates/skills/controller/SKILL.md +240 -240
- package/templates/skills/controller/postman-templates.md +614 -614
- package/templates/skills/controller/templates.md +1468 -1468
- package/templates/skills/documentation/SKILL.md +133 -133
- package/templates/skills/documentation/templates.md +476 -476
- package/templates/skills/feature-full/SKILL.md +838 -838
- package/templates/skills/notification/SKILL.md +555 -555
- package/templates/skills/ui-components/SKILL.md +870 -870
- package/templates/skills/workflow/SKILL.md +582 -582
- 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,600 +1,600 @@
|
|
|
1
|
-
# Templates Controller SmartStack
|
|
2
|
-
|
|
3
|
-
> **Note:** Ces templates sont utilisés par le skill `controller` et la commande `/controller:create`.
|
|
4
|
-
> Adapter les variables `{Area}`, `{Module}`, `{Entity}`, `{PermissionPath}` selon le contexte.
|
|
5
|
-
|
|
6
|
-
---
|
|
7
|
-
|
|
8
|
-
## Template CRUD Controller (Standard)
|
|
9
|
-
|
|
10
|
-
```csharp
|
|
11
|
-
// src/SmartStack.Api/Controllers/{Area}/{Module}Controller.cs
|
|
12
|
-
|
|
13
|
-
using Microsoft.AspNetCore.Authorization;
|
|
14
|
-
using Microsoft.AspNetCore.Mvc;
|
|
15
|
-
using Microsoft.EntityFrameworkCore;
|
|
16
|
-
using SmartStack.Application.Common.Authorization;
|
|
17
|
-
using SmartStack.Application.Common.Interfaces;
|
|
18
|
-
using SmartStack.Api.Authorization;
|
|
19
|
-
using SmartStack.Domain.{DomainNamespace};
|
|
20
|
-
|
|
21
|
-
namespace SmartStack.Api.Controllers.{Area};
|
|
22
|
-
|
|
23
|
-
[ApiController]
|
|
24
|
-
[Route("api/{area-kebab}/{module-kebab}")]
|
|
25
|
-
[Authorize]
|
|
26
|
-
[Tags("{Module}")]
|
|
27
|
-
public class {Module}Controller : ControllerBase
|
|
28
|
-
{
|
|
29
|
-
private readonly IApplicationDbContext _context;
|
|
30
|
-
private readonly ICurrentUserService _currentUser;
|
|
31
|
-
private readonly ILogger<{Module}Controller> _logger;
|
|
32
|
-
|
|
33
|
-
public {Module}Controller(
|
|
34
|
-
IApplicationDbContext context,
|
|
35
|
-
ICurrentUserService currentUser,
|
|
36
|
-
ILogger<{Module}Controller> logger)
|
|
37
|
-
{
|
|
38
|
-
_context = context;
|
|
39
|
-
_currentUser = currentUser;
|
|
40
|
-
_logger = logger;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
#region GET - List with Pagination
|
|
44
|
-
|
|
45
|
-
[HttpGet]
|
|
46
|
-
[RequirePermission(Permissions.{PermissionClass}.View)]
|
|
47
|
-
[ProducesResponseType(typeof(PagedResult<{Entity}ListDto>), StatusCodes.Status200OK)]
|
|
48
|
-
public async Task<ActionResult<PagedResult<{Entity}ListDto>>> Get{Module}(
|
|
49
|
-
[FromQuery] int page = 1,
|
|
50
|
-
[FromQuery] int pageSize = 20,
|
|
51
|
-
[FromQuery] string? search = null,
|
|
52
|
-
CancellationToken cancellationToken = default)
|
|
53
|
-
{
|
|
54
|
-
var query = _context.{DbSet}.AsQueryable();
|
|
55
|
-
|
|
56
|
-
// Search filter
|
|
57
|
-
if (!string.IsNullOrWhiteSpace(search))
|
|
58
|
-
{
|
|
59
|
-
var searchLower = search.ToLower();
|
|
60
|
-
query = query.Where(x =>
|
|
61
|
-
x.Name.ToLower().Contains(searchLower) ||
|
|
62
|
-
x.Description != null && x.Description.ToLower().Contains(searchLower));
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
var totalCount = await query.CountAsync(cancellationToken);
|
|
66
|
-
|
|
67
|
-
var items = await query
|
|
68
|
-
.OrderBy(x => x.Name)
|
|
69
|
-
.Skip((page - 1) * pageSize)
|
|
70
|
-
.Take(pageSize)
|
|
71
|
-
.Select(x => new {Entity}ListDto(
|
|
72
|
-
x.Id,
|
|
73
|
-
x.Name,
|
|
74
|
-
x.Description,
|
|
75
|
-
x.IsActive,
|
|
76
|
-
x.CreatedAt
|
|
77
|
-
))
|
|
78
|
-
.ToListAsync(cancellationToken);
|
|
79
|
-
|
|
80
|
-
_logger.LogInformation("User {User} retrieved {Count} {Module}",
|
|
81
|
-
_currentUser.Email, items.Count, "{Module}");
|
|
82
|
-
|
|
83
|
-
return Ok(new PagedResult<{Entity}ListDto>(items, totalCount, page, pageSize));
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
#endregion
|
|
87
|
-
|
|
88
|
-
#region GET - Single by ID
|
|
89
|
-
|
|
90
|
-
[HttpGet("{id:guid}")]
|
|
91
|
-
[RequirePermission(Permissions.{PermissionClass}.View)]
|
|
92
|
-
[ProducesResponseType(typeof({Entity}DetailDto), StatusCodes.Status200OK)]
|
|
93
|
-
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
94
|
-
public async Task<ActionResult<{Entity}DetailDto>> Get{Entity}(
|
|
95
|
-
Guid id,
|
|
96
|
-
CancellationToken cancellationToken)
|
|
97
|
-
{
|
|
98
|
-
var entity = await _context.{DbSet}
|
|
99
|
-
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
|
100
|
-
|
|
101
|
-
if (entity == null)
|
|
102
|
-
return NotFound(new { message = "{Entity} not found" });
|
|
103
|
-
|
|
104
|
-
return Ok(new {Entity}DetailDto(
|
|
105
|
-
entity.Id,
|
|
106
|
-
entity.Name,
|
|
107
|
-
entity.Description,
|
|
108
|
-
entity.IsActive,
|
|
109
|
-
entity.CreatedAt,
|
|
110
|
-
entity.UpdatedAt
|
|
111
|
-
));
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
#endregion
|
|
115
|
-
|
|
116
|
-
#region POST - Create
|
|
117
|
-
|
|
118
|
-
[HttpPost]
|
|
119
|
-
[RequirePermission(Permissions.{PermissionClass}.Create)]
|
|
120
|
-
[ProducesResponseType(typeof({Entity}DetailDto), StatusCodes.Status201Created)]
|
|
121
|
-
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
122
|
-
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
|
123
|
-
public async Task<ActionResult<{Entity}DetailDto>> Create{Entity}(
|
|
124
|
-
[FromBody] Create{Entity}Request request,
|
|
125
|
-
CancellationToken cancellationToken)
|
|
126
|
-
{
|
|
127
|
-
// Check for duplicates
|
|
128
|
-
var exists = await _context.{DbSet}
|
|
129
|
-
.AnyAsync(x => x.Name == request.Name, cancellationToken);
|
|
130
|
-
|
|
131
|
-
if (exists)
|
|
132
|
-
return Conflict(new { message = "{Entity} with this name already exists" });
|
|
133
|
-
|
|
134
|
-
var entity = {Entity}.Create(
|
|
135
|
-
request.Name,
|
|
136
|
-
request.Description
|
|
137
|
-
);
|
|
138
|
-
|
|
139
|
-
_context.{DbSet}.Add(entity);
|
|
140
|
-
await _context.SaveChangesAsync(cancellationToken);
|
|
141
|
-
|
|
142
|
-
_logger.LogInformation("User {User} created {Entity} {EntityId} ({Name})",
|
|
143
|
-
_currentUser.Email, entity.Id, entity.Name);
|
|
144
|
-
|
|
145
|
-
return CreatedAtAction(
|
|
146
|
-
nameof(Get{Entity}),
|
|
147
|
-
new { id = entity.Id },
|
|
148
|
-
new {Entity}DetailDto(
|
|
149
|
-
entity.Id,
|
|
150
|
-
entity.Name,
|
|
151
|
-
entity.Description,
|
|
152
|
-
entity.IsActive,
|
|
153
|
-
entity.CreatedAt,
|
|
154
|
-
entity.UpdatedAt
|
|
155
|
-
));
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
#endregion
|
|
159
|
-
|
|
160
|
-
#region PUT - Update
|
|
161
|
-
|
|
162
|
-
[HttpPut("{id:guid}")]
|
|
163
|
-
[RequirePermission(Permissions.{PermissionClass}.Update)]
|
|
164
|
-
[ProducesResponseType(typeof({Entity}DetailDto), StatusCodes.Status200OK)]
|
|
165
|
-
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
166
|
-
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
|
167
|
-
public async Task<ActionResult<{Entity}DetailDto>> Update{Entity}(
|
|
168
|
-
Guid id,
|
|
169
|
-
[FromBody] Update{Entity}Request request,
|
|
170
|
-
CancellationToken cancellationToken)
|
|
171
|
-
{
|
|
172
|
-
var entity = await _context.{DbSet}
|
|
173
|
-
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
|
174
|
-
|
|
175
|
-
if (entity == null)
|
|
176
|
-
return NotFound(new { message = "{Entity} not found" });
|
|
177
|
-
|
|
178
|
-
// Check for duplicate name (excluding current)
|
|
179
|
-
if (!string.IsNullOrEmpty(request.Name))
|
|
180
|
-
{
|
|
181
|
-
var duplicate = await _context.{DbSet}
|
|
182
|
-
.AnyAsync(x => x.Name == request.Name && x.Id != id, cancellationToken);
|
|
183
|
-
|
|
184
|
-
if (duplicate)
|
|
185
|
-
return Conflict(new { message = "{Entity} with this name already exists" });
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
entity.Update(
|
|
189
|
-
request.Name ?? entity.Name,
|
|
190
|
-
request.Description ?? entity.Description
|
|
191
|
-
);
|
|
192
|
-
|
|
193
|
-
await _context.SaveChangesAsync(cancellationToken);
|
|
194
|
-
|
|
195
|
-
_logger.LogInformation("User {User} updated {Entity} {EntityId}",
|
|
196
|
-
_currentUser.Email, entity.Id);
|
|
197
|
-
|
|
198
|
-
return Ok(new {Entity}DetailDto(
|
|
199
|
-
entity.Id,
|
|
200
|
-
entity.Name,
|
|
201
|
-
entity.Description,
|
|
202
|
-
entity.IsActive,
|
|
203
|
-
entity.CreatedAt,
|
|
204
|
-
entity.UpdatedAt
|
|
205
|
-
));
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
#endregion
|
|
209
|
-
|
|
210
|
-
#region PATCH - Activate/Deactivate
|
|
211
|
-
|
|
212
|
-
[HttpPatch("{id:guid}/activate")]
|
|
213
|
-
[RequirePermission(Permissions.{PermissionClass}.Update)]
|
|
214
|
-
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
|
215
|
-
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
216
|
-
public async Task<IActionResult> Activate{Entity}(
|
|
217
|
-
Guid id,
|
|
218
|
-
CancellationToken cancellationToken)
|
|
219
|
-
{
|
|
220
|
-
var entity = await _context.{DbSet}
|
|
221
|
-
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
|
222
|
-
|
|
223
|
-
if (entity == null)
|
|
224
|
-
return NotFound(new { message = "{Entity} not found" });
|
|
225
|
-
|
|
226
|
-
entity.Activate();
|
|
227
|
-
await _context.SaveChangesAsync(cancellationToken);
|
|
228
|
-
|
|
229
|
-
_logger.LogInformation("User {User} activated {Entity} {EntityId}",
|
|
230
|
-
_currentUser.Email, entity.Id);
|
|
231
|
-
|
|
232
|
-
return NoContent();
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
[HttpPatch("{id:guid}/deactivate")]
|
|
236
|
-
[RequirePermission(Permissions.{PermissionClass}.Update)]
|
|
237
|
-
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
|
238
|
-
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
239
|
-
public async Task<IActionResult> Deactivate{Entity}(
|
|
240
|
-
Guid id,
|
|
241
|
-
CancellationToken cancellationToken)
|
|
242
|
-
{
|
|
243
|
-
var entity = await _context.{DbSet}
|
|
244
|
-
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
|
245
|
-
|
|
246
|
-
if (entity == null)
|
|
247
|
-
return NotFound(new { message = "{Entity} not found" });
|
|
248
|
-
|
|
249
|
-
entity.Deactivate();
|
|
250
|
-
await _context.SaveChangesAsync(cancellationToken);
|
|
251
|
-
|
|
252
|
-
_logger.LogWarning("User {User} deactivated {Entity} {EntityId}",
|
|
253
|
-
_currentUser.Email, entity.Id);
|
|
254
|
-
|
|
255
|
-
return NoContent();
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
#endregion
|
|
259
|
-
|
|
260
|
-
#region DELETE
|
|
261
|
-
|
|
262
|
-
[HttpDelete("{id:guid}")]
|
|
263
|
-
[RequirePermission(Permissions.{PermissionClass}.Delete)]
|
|
264
|
-
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
|
265
|
-
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
266
|
-
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
267
|
-
public async Task<IActionResult> Delete{Entity}(
|
|
268
|
-
Guid id,
|
|
269
|
-
CancellationToken cancellationToken)
|
|
270
|
-
{
|
|
271
|
-
var entity = await _context.{DbSet}
|
|
272
|
-
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
|
273
|
-
|
|
274
|
-
if (entity == null)
|
|
275
|
-
return NotFound(new { message = "{Entity} not found" });
|
|
276
|
-
|
|
277
|
-
// Check for dependencies before deletion
|
|
278
|
-
// var hasReferences = await _context.ChildEntities.AnyAsync(x => x.{Entity}Id == id, ct);
|
|
279
|
-
// if (hasReferences)
|
|
280
|
-
// return BadRequest(new { message = "Cannot delete: has dependent records" });
|
|
281
|
-
|
|
282
|
-
_context.{DbSet}.Remove(entity);
|
|
283
|
-
await _context.SaveChangesAsync(cancellationToken);
|
|
284
|
-
|
|
285
|
-
_logger.LogWarning("User {User} deleted {Entity} {EntityId} ({Name})",
|
|
286
|
-
_currentUser.Email, id, entity.Name);
|
|
287
|
-
|
|
288
|
-
return NoContent();
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
#endregion
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
#region DTOs
|
|
295
|
-
|
|
296
|
-
public record {Entity}ListDto(
|
|
297
|
-
Guid Id,
|
|
298
|
-
string Name,
|
|
299
|
-
string? Description,
|
|
300
|
-
bool IsActive,
|
|
301
|
-
DateTime CreatedAt
|
|
302
|
-
);
|
|
303
|
-
|
|
304
|
-
public record {Entity}DetailDto(
|
|
305
|
-
Guid Id,
|
|
306
|
-
string Name,
|
|
307
|
-
string? Description,
|
|
308
|
-
bool IsActive,
|
|
309
|
-
DateTime CreatedAt,
|
|
310
|
-
DateTime? UpdatedAt
|
|
311
|
-
);
|
|
312
|
-
|
|
313
|
-
public record Create{Entity}Request(
|
|
314
|
-
string Name,
|
|
315
|
-
string? Description
|
|
316
|
-
);
|
|
317
|
-
|
|
318
|
-
public record Update{Entity}Request(
|
|
319
|
-
string? Name,
|
|
320
|
-
string? Description
|
|
321
|
-
);
|
|
322
|
-
|
|
323
|
-
public record PagedResult<T>(
|
|
324
|
-
List<T> Items,
|
|
325
|
-
int TotalCount,
|
|
326
|
-
int Page,
|
|
327
|
-
int PageSize
|
|
328
|
-
)
|
|
329
|
-
{
|
|
330
|
-
public int TotalPages => (int)Math.Ceiling((double)TotalCount / PageSize);
|
|
331
|
-
public bool HasPrevious => Page > 1;
|
|
332
|
-
public bool HasNext => Page < TotalPages;
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
#endregion
|
|
336
|
-
```
|
|
337
|
-
|
|
338
|
-
---
|
|
339
|
-
|
|
340
|
-
## Template Permissions Constants
|
|
341
|
-
|
|
342
|
-
```csharp
|
|
343
|
-
// src/SmartStack.Application/Common/Authorization/Permissions.cs
|
|
344
|
-
// AJOUTER dans la classe existante
|
|
345
|
-
|
|
346
|
-
public static class Permissions
|
|
347
|
-
{
|
|
348
|
-
// ... existing permissions ...
|
|
349
|
-
|
|
350
|
-
public static class {PermissionClass}
|
|
351
|
-
{
|
|
352
|
-
public const string Access = "{permission.path}";
|
|
353
|
-
public const string View = "{permission.path}.read";
|
|
354
|
-
public const string Create = "{permission.path}.create";
|
|
355
|
-
public const string Update = "{permission.path}.update";
|
|
356
|
-
public const string Delete = "{permission.path}.delete";
|
|
357
|
-
// Optionnel selon module
|
|
358
|
-
public const string Assign = "{permission.path}.assign";
|
|
359
|
-
public const string Execute = "{permission.path}.execute";
|
|
360
|
-
public const string Export = "{permission.path}.export";
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
```
|
|
364
|
-
|
|
365
|
-
---
|
|
366
|
-
|
|
367
|
-
## Template PermissionConfiguration Seed
|
|
368
|
-
|
|
369
|
-
> **CRITIQUE:** Ce template est OBLIGATOIRE. Sans ces entrées, tous les appels API retourneront 403 Forbidden.
|
|
370
|
-
|
|
371
|
-
```csharp
|
|
372
|
-
// src/SmartStack.Infrastructure/Persistence/Configurations/Authorization/PermissionConfiguration.cs
|
|
373
|
-
// AJOUTER dans la méthode Configure(), section HasData
|
|
374
|
-
|
|
375
|
-
// ============================================
|
|
376
|
-
// ÉTAPE 1: Déclarer le ModuleId
|
|
377
|
-
// ============================================
|
|
378
|
-
// Vérifier dans ModuleConfiguration.cs si le module existe déjà
|
|
379
|
-
// Sinon, créer le module d'abord via /application skill
|
|
380
|
-
|
|
381
|
-
var {module}ModuleId = Guid.Parse("{GUID-DU-MODULE}"); // Récupérer depuis ModuleConfiguration.cs
|
|
382
|
-
|
|
383
|
-
// ============================================
|
|
384
|
-
// ÉTAPE 2: Ajouter les permissions (HasData)
|
|
385
|
-
// ============================================
|
|
386
|
-
|
|
387
|
-
// Pattern: {context}.{application}.{module}.{action}
|
|
388
|
-
// Exemple: business.crm.leads.read
|
|
389
|
-
|
|
390
|
-
var seedDate = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
|
391
|
-
|
|
392
|
-
builder.HasData(
|
|
393
|
-
// Wildcard permission (accès complet au module)
|
|
394
|
-
new
|
|
395
|
-
{
|
|
396
|
-
Id = Guid.Parse("{RANDOM-GUID-1}"), // Générer avec [guid]::NewGuid()
|
|
397
|
-
Path = "{context}.{application}.{module}.*",
|
|
398
|
-
Level = PermissionLevel.Module,
|
|
399
|
-
Action = (PermissionAction?)null,
|
|
400
|
-
IsWildcard = true,
|
|
401
|
-
ModuleId = {module}ModuleId,
|
|
402
|
-
Description = "Full {module} management",
|
|
403
|
-
CreatedAt = seedDate
|
|
404
|
-
},
|
|
405
|
-
|
|
406
|
-
// Read permission
|
|
407
|
-
new
|
|
408
|
-
{
|
|
409
|
-
Id = Guid.Parse("{RANDOM-GUID-2}"),
|
|
410
|
-
Path = "{context}.{application}.{module}.read",
|
|
411
|
-
Level = PermissionLevel.Module,
|
|
412
|
-
Action = PermissionAction.Read,
|
|
413
|
-
IsWildcard = false,
|
|
414
|
-
ModuleId = {module}ModuleId,
|
|
415
|
-
Description = "View {module}",
|
|
416
|
-
CreatedAt = seedDate
|
|
417
|
-
},
|
|
418
|
-
|
|
419
|
-
// Create permission
|
|
420
|
-
new
|
|
421
|
-
{
|
|
422
|
-
Id = Guid.Parse("{RANDOM-GUID-3}"),
|
|
423
|
-
Path = "{context}.{application}.{module}.create",
|
|
424
|
-
Level = PermissionLevel.Module,
|
|
425
|
-
Action = PermissionAction.Create,
|
|
426
|
-
IsWildcard = false,
|
|
427
|
-
ModuleId = {module}ModuleId,
|
|
428
|
-
Description = "Create {module}",
|
|
429
|
-
CreatedAt = seedDate
|
|
430
|
-
},
|
|
431
|
-
|
|
432
|
-
// Update permission
|
|
433
|
-
new
|
|
434
|
-
{
|
|
435
|
-
Id = Guid.Parse("{RANDOM-GUID-4}"),
|
|
436
|
-
Path = "{context}.{application}.{module}.update",
|
|
437
|
-
Level = PermissionLevel.Module,
|
|
438
|
-
Action = PermissionAction.Update,
|
|
439
|
-
IsWildcard = false,
|
|
440
|
-
ModuleId = {module}ModuleId,
|
|
441
|
-
Description = "Update {module}",
|
|
442
|
-
CreatedAt = seedDate
|
|
443
|
-
},
|
|
444
|
-
|
|
445
|
-
// Delete permission
|
|
446
|
-
new
|
|
447
|
-
{
|
|
448
|
-
Id = Guid.Parse("{RANDOM-GUID-5}"),
|
|
449
|
-
Path = "{context}.{application}.{module}.delete",
|
|
450
|
-
Level = PermissionLevel.Module,
|
|
451
|
-
Action = PermissionAction.Delete,
|
|
452
|
-
IsWildcard = false,
|
|
453
|
-
ModuleId = {module}ModuleId,
|
|
454
|
-
Description = "Delete {module}",
|
|
455
|
-
CreatedAt = seedDate
|
|
456
|
-
}
|
|
457
|
-
);
|
|
458
|
-
```
|
|
459
|
-
|
|
460
|
-
### Génération de GUIDs
|
|
461
|
-
|
|
462
|
-
```bash
|
|
463
|
-
# PowerShell (Windows)
|
|
464
|
-
[guid]::NewGuid().ToString()
|
|
465
|
-
|
|
466
|
-
# Bash (Linux/Mac)
|
|
467
|
-
uuidgen | tr '[:upper:]' '[:lower:]'
|
|
468
|
-
```
|
|
469
|
-
|
|
470
|
-
---
|
|
471
|
-
|
|
472
|
-
## Template Controller avec Relations
|
|
473
|
-
|
|
474
|
-
```csharp
|
|
475
|
-
// Pour les controllers avec entités liées (ex: Tickets avec Comments)
|
|
476
|
-
|
|
477
|
-
#region GET with Includes
|
|
478
|
-
|
|
479
|
-
[HttpGet("{id:guid}")]
|
|
480
|
-
[RequirePermission(Permissions.{PermissionClass}.View)]
|
|
481
|
-
[ProducesResponseType(typeof({Entity}DetailDto), StatusCodes.Status200OK)]
|
|
482
|
-
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
483
|
-
public async Task<ActionResult<{Entity}DetailDto>> Get{Entity}(
|
|
484
|
-
Guid id,
|
|
485
|
-
CancellationToken cancellationToken)
|
|
486
|
-
{
|
|
487
|
-
var entity = await _context.{DbSet}
|
|
488
|
-
.Include(x => x.CreatedByUser)
|
|
489
|
-
.Include(x => x.AssignedToUser)
|
|
490
|
-
.Include(x => x.Comments)
|
|
491
|
-
.ThenInclude(c => c.Author)
|
|
492
|
-
.Include(x => x.Attachments)
|
|
493
|
-
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
|
494
|
-
|
|
495
|
-
if (entity == null)
|
|
496
|
-
return NotFound(new { message = "{Entity} not found" });
|
|
497
|
-
|
|
498
|
-
return Ok(MapToDetailDto(entity));
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
#endregion
|
|
502
|
-
|
|
503
|
-
#region Nested Resources
|
|
504
|
-
|
|
505
|
-
[HttpGet("{parentId:guid}/children")]
|
|
506
|
-
[RequirePermission(Permissions.{PermissionClass}.View)]
|
|
507
|
-
[ProducesResponseType(typeof(List<ChildDto>), StatusCodes.Status200OK)]
|
|
508
|
-
public async Task<ActionResult<List<ChildDto>>> GetChildren(
|
|
509
|
-
Guid parentId,
|
|
510
|
-
CancellationToken cancellationToken)
|
|
511
|
-
{
|
|
512
|
-
var children = await _context.Children
|
|
513
|
-
.Where(x => x.ParentId == parentId)
|
|
514
|
-
.OrderByDescending(x => x.CreatedAt)
|
|
515
|
-
.Select(x => new ChildDto(x.Id, x.Name, x.CreatedAt))
|
|
516
|
-
.ToListAsync(cancellationToken);
|
|
517
|
-
|
|
518
|
-
return Ok(children);
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
[HttpPost("{parentId:guid}/children")]
|
|
522
|
-
[RequirePermission(Permissions.{PermissionClass}.Create)]
|
|
523
|
-
[ProducesResponseType(typeof(ChildDto), StatusCodes.Status201Created)]
|
|
524
|
-
public async Task<ActionResult<ChildDto>> AddChild(
|
|
525
|
-
Guid parentId,
|
|
526
|
-
[FromBody] CreateChildRequest request,
|
|
527
|
-
CancellationToken cancellationToken)
|
|
528
|
-
{
|
|
529
|
-
var parent = await _context.{DbSet}
|
|
530
|
-
.FirstOrDefaultAsync(x => x.Id == parentId, cancellationToken);
|
|
531
|
-
|
|
532
|
-
if (parent == null)
|
|
533
|
-
return NotFound(new { message = "Parent not found" });
|
|
534
|
-
|
|
535
|
-
var child = Child.Create(parentId, request.Name, _currentUser.UserId!.Value);
|
|
536
|
-
|
|
537
|
-
_context.Children.Add(child);
|
|
538
|
-
await _context.SaveChangesAsync(cancellationToken);
|
|
539
|
-
|
|
540
|
-
_logger.LogInformation("User {User} added child to {Entity} {ParentId}",
|
|
541
|
-
_currentUser.Email, parentId);
|
|
542
|
-
|
|
543
|
-
return CreatedAtAction(
|
|
544
|
-
nameof(GetChildren),
|
|
545
|
-
new { parentId },
|
|
546
|
-
new ChildDto(child.Id, child.Name, child.CreatedAt));
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
#endregion
|
|
550
|
-
```
|
|
551
|
-
|
|
552
|
-
---
|
|
553
|
-
|
|
554
|
-
## Patterns Réutilisables
|
|
555
|
-
|
|
556
|
-
### Error Response Standard
|
|
557
|
-
|
|
558
|
-
```csharp
|
|
559
|
-
public record ErrorResponse(string Message, string? Code = null);
|
|
560
|
-
|
|
561
|
-
// Usage:
|
|
562
|
-
return BadRequest(new ErrorResponse("Validation failed", "VALIDATION_ERROR"));
|
|
563
|
-
return Conflict(new ErrorResponse("Already exists", "DUPLICATE"));
|
|
564
|
-
return NotFound(new { message = "Resource not found" });
|
|
565
|
-
```
|
|
566
|
-
|
|
567
|
-
### Pagination Query Extension
|
|
568
|
-
|
|
569
|
-
```csharp
|
|
570
|
-
public static class QueryableExtensions
|
|
571
|
-
{
|
|
572
|
-
public static async Task<PagedResult<T>> ToPagedResultAsync<T>(
|
|
573
|
-
this IQueryable<T> query,
|
|
574
|
-
int page,
|
|
575
|
-
int pageSize,
|
|
576
|
-
CancellationToken ct = default)
|
|
577
|
-
{
|
|
578
|
-
var totalCount = await query.CountAsync(ct);
|
|
579
|
-
var items = await query
|
|
580
|
-
.Skip((page - 1) * pageSize)
|
|
581
|
-
.Take(pageSize)
|
|
582
|
-
.ToListAsync(ct);
|
|
583
|
-
|
|
584
|
-
return new PagedResult<T>(items, totalCount, page, pageSize);
|
|
585
|
-
}
|
|
586
|
-
}
|
|
587
|
-
```
|
|
588
|
-
|
|
589
|
-
### Log Context Pattern
|
|
590
|
-
|
|
591
|
-
```csharp
|
|
592
|
-
// Toujours inclure le contexte utilisateur dans les logs
|
|
593
|
-
_logger.LogInformation(
|
|
594
|
-
"User {User} ({UserId}) performed {Action} on {Entity} {EntityId}",
|
|
595
|
-
_currentUser.Email,
|
|
596
|
-
_currentUser.UserId,
|
|
597
|
-
"Create",
|
|
598
|
-
"{Entity}",
|
|
599
|
-
entity.Id);
|
|
600
|
-
```
|
|
1
|
+
# Templates Controller SmartStack
|
|
2
|
+
|
|
3
|
+
> **Note:** Ces templates sont utilisés par le skill `controller` et la commande `/controller:create`.
|
|
4
|
+
> Adapter les variables `{Area}`, `{Module}`, `{Entity}`, `{PermissionPath}` selon le contexte.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Template CRUD Controller (Standard)
|
|
9
|
+
|
|
10
|
+
```csharp
|
|
11
|
+
// src/SmartStack.Api/Controllers/{Area}/{Module}Controller.cs
|
|
12
|
+
|
|
13
|
+
using Microsoft.AspNetCore.Authorization;
|
|
14
|
+
using Microsoft.AspNetCore.Mvc;
|
|
15
|
+
using Microsoft.EntityFrameworkCore;
|
|
16
|
+
using SmartStack.Application.Common.Authorization;
|
|
17
|
+
using SmartStack.Application.Common.Interfaces;
|
|
18
|
+
using SmartStack.Api.Authorization;
|
|
19
|
+
using SmartStack.Domain.{DomainNamespace};
|
|
20
|
+
|
|
21
|
+
namespace SmartStack.Api.Controllers.{Area};
|
|
22
|
+
|
|
23
|
+
[ApiController]
|
|
24
|
+
[Route("api/{area-kebab}/{module-kebab}")]
|
|
25
|
+
[Authorize]
|
|
26
|
+
[Tags("{Module}")]
|
|
27
|
+
public class {Module}Controller : ControllerBase
|
|
28
|
+
{
|
|
29
|
+
private readonly IApplicationDbContext _context;
|
|
30
|
+
private readonly ICurrentUserService _currentUser;
|
|
31
|
+
private readonly ILogger<{Module}Controller> _logger;
|
|
32
|
+
|
|
33
|
+
public {Module}Controller(
|
|
34
|
+
IApplicationDbContext context,
|
|
35
|
+
ICurrentUserService currentUser,
|
|
36
|
+
ILogger<{Module}Controller> logger)
|
|
37
|
+
{
|
|
38
|
+
_context = context;
|
|
39
|
+
_currentUser = currentUser;
|
|
40
|
+
_logger = logger;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
#region GET - List with Pagination
|
|
44
|
+
|
|
45
|
+
[HttpGet]
|
|
46
|
+
[RequirePermission(Permissions.{PermissionClass}.View)]
|
|
47
|
+
[ProducesResponseType(typeof(PagedResult<{Entity}ListDto>), StatusCodes.Status200OK)]
|
|
48
|
+
public async Task<ActionResult<PagedResult<{Entity}ListDto>>> Get{Module}(
|
|
49
|
+
[FromQuery] int page = 1,
|
|
50
|
+
[FromQuery] int pageSize = 20,
|
|
51
|
+
[FromQuery] string? search = null,
|
|
52
|
+
CancellationToken cancellationToken = default)
|
|
53
|
+
{
|
|
54
|
+
var query = _context.{DbSet}.AsQueryable();
|
|
55
|
+
|
|
56
|
+
// Search filter
|
|
57
|
+
if (!string.IsNullOrWhiteSpace(search))
|
|
58
|
+
{
|
|
59
|
+
var searchLower = search.ToLower();
|
|
60
|
+
query = query.Where(x =>
|
|
61
|
+
x.Name.ToLower().Contains(searchLower) ||
|
|
62
|
+
x.Description != null && x.Description.ToLower().Contains(searchLower));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
var totalCount = await query.CountAsync(cancellationToken);
|
|
66
|
+
|
|
67
|
+
var items = await query
|
|
68
|
+
.OrderBy(x => x.Name)
|
|
69
|
+
.Skip((page - 1) * pageSize)
|
|
70
|
+
.Take(pageSize)
|
|
71
|
+
.Select(x => new {Entity}ListDto(
|
|
72
|
+
x.Id,
|
|
73
|
+
x.Name,
|
|
74
|
+
x.Description,
|
|
75
|
+
x.IsActive,
|
|
76
|
+
x.CreatedAt
|
|
77
|
+
))
|
|
78
|
+
.ToListAsync(cancellationToken);
|
|
79
|
+
|
|
80
|
+
_logger.LogInformation("User {User} retrieved {Count} {Module}",
|
|
81
|
+
_currentUser.Email, items.Count, "{Module}");
|
|
82
|
+
|
|
83
|
+
return Ok(new PagedResult<{Entity}ListDto>(items, totalCount, page, pageSize));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
#endregion
|
|
87
|
+
|
|
88
|
+
#region GET - Single by ID
|
|
89
|
+
|
|
90
|
+
[HttpGet("{id:guid}")]
|
|
91
|
+
[RequirePermission(Permissions.{PermissionClass}.View)]
|
|
92
|
+
[ProducesResponseType(typeof({Entity}DetailDto), StatusCodes.Status200OK)]
|
|
93
|
+
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
94
|
+
public async Task<ActionResult<{Entity}DetailDto>> Get{Entity}(
|
|
95
|
+
Guid id,
|
|
96
|
+
CancellationToken cancellationToken)
|
|
97
|
+
{
|
|
98
|
+
var entity = await _context.{DbSet}
|
|
99
|
+
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
|
100
|
+
|
|
101
|
+
if (entity == null)
|
|
102
|
+
return NotFound(new { message = "{Entity} not found" });
|
|
103
|
+
|
|
104
|
+
return Ok(new {Entity}DetailDto(
|
|
105
|
+
entity.Id,
|
|
106
|
+
entity.Name,
|
|
107
|
+
entity.Description,
|
|
108
|
+
entity.IsActive,
|
|
109
|
+
entity.CreatedAt,
|
|
110
|
+
entity.UpdatedAt
|
|
111
|
+
));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
#endregion
|
|
115
|
+
|
|
116
|
+
#region POST - Create
|
|
117
|
+
|
|
118
|
+
[HttpPost]
|
|
119
|
+
[RequirePermission(Permissions.{PermissionClass}.Create)]
|
|
120
|
+
[ProducesResponseType(typeof({Entity}DetailDto), StatusCodes.Status201Created)]
|
|
121
|
+
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
122
|
+
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
|
123
|
+
public async Task<ActionResult<{Entity}DetailDto>> Create{Entity}(
|
|
124
|
+
[FromBody] Create{Entity}Request request,
|
|
125
|
+
CancellationToken cancellationToken)
|
|
126
|
+
{
|
|
127
|
+
// Check for duplicates
|
|
128
|
+
var exists = await _context.{DbSet}
|
|
129
|
+
.AnyAsync(x => x.Name == request.Name, cancellationToken);
|
|
130
|
+
|
|
131
|
+
if (exists)
|
|
132
|
+
return Conflict(new { message = "{Entity} with this name already exists" });
|
|
133
|
+
|
|
134
|
+
var entity = {Entity}.Create(
|
|
135
|
+
request.Name,
|
|
136
|
+
request.Description
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
_context.{DbSet}.Add(entity);
|
|
140
|
+
await _context.SaveChangesAsync(cancellationToken);
|
|
141
|
+
|
|
142
|
+
_logger.LogInformation("User {User} created {Entity} {EntityId} ({Name})",
|
|
143
|
+
_currentUser.Email, entity.Id, entity.Name);
|
|
144
|
+
|
|
145
|
+
return CreatedAtAction(
|
|
146
|
+
nameof(Get{Entity}),
|
|
147
|
+
new { id = entity.Id },
|
|
148
|
+
new {Entity}DetailDto(
|
|
149
|
+
entity.Id,
|
|
150
|
+
entity.Name,
|
|
151
|
+
entity.Description,
|
|
152
|
+
entity.IsActive,
|
|
153
|
+
entity.CreatedAt,
|
|
154
|
+
entity.UpdatedAt
|
|
155
|
+
));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
#endregion
|
|
159
|
+
|
|
160
|
+
#region PUT - Update
|
|
161
|
+
|
|
162
|
+
[HttpPut("{id:guid}")]
|
|
163
|
+
[RequirePermission(Permissions.{PermissionClass}.Update)]
|
|
164
|
+
[ProducesResponseType(typeof({Entity}DetailDto), StatusCodes.Status200OK)]
|
|
165
|
+
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
166
|
+
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
|
167
|
+
public async Task<ActionResult<{Entity}DetailDto>> Update{Entity}(
|
|
168
|
+
Guid id,
|
|
169
|
+
[FromBody] Update{Entity}Request request,
|
|
170
|
+
CancellationToken cancellationToken)
|
|
171
|
+
{
|
|
172
|
+
var entity = await _context.{DbSet}
|
|
173
|
+
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
|
174
|
+
|
|
175
|
+
if (entity == null)
|
|
176
|
+
return NotFound(new { message = "{Entity} not found" });
|
|
177
|
+
|
|
178
|
+
// Check for duplicate name (excluding current)
|
|
179
|
+
if (!string.IsNullOrEmpty(request.Name))
|
|
180
|
+
{
|
|
181
|
+
var duplicate = await _context.{DbSet}
|
|
182
|
+
.AnyAsync(x => x.Name == request.Name && x.Id != id, cancellationToken);
|
|
183
|
+
|
|
184
|
+
if (duplicate)
|
|
185
|
+
return Conflict(new { message = "{Entity} with this name already exists" });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
entity.Update(
|
|
189
|
+
request.Name ?? entity.Name,
|
|
190
|
+
request.Description ?? entity.Description
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
await _context.SaveChangesAsync(cancellationToken);
|
|
194
|
+
|
|
195
|
+
_logger.LogInformation("User {User} updated {Entity} {EntityId}",
|
|
196
|
+
_currentUser.Email, entity.Id);
|
|
197
|
+
|
|
198
|
+
return Ok(new {Entity}DetailDto(
|
|
199
|
+
entity.Id,
|
|
200
|
+
entity.Name,
|
|
201
|
+
entity.Description,
|
|
202
|
+
entity.IsActive,
|
|
203
|
+
entity.CreatedAt,
|
|
204
|
+
entity.UpdatedAt
|
|
205
|
+
));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
#endregion
|
|
209
|
+
|
|
210
|
+
#region PATCH - Activate/Deactivate
|
|
211
|
+
|
|
212
|
+
[HttpPatch("{id:guid}/activate")]
|
|
213
|
+
[RequirePermission(Permissions.{PermissionClass}.Update)]
|
|
214
|
+
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
|
215
|
+
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
216
|
+
public async Task<IActionResult> Activate{Entity}(
|
|
217
|
+
Guid id,
|
|
218
|
+
CancellationToken cancellationToken)
|
|
219
|
+
{
|
|
220
|
+
var entity = await _context.{DbSet}
|
|
221
|
+
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
|
222
|
+
|
|
223
|
+
if (entity == null)
|
|
224
|
+
return NotFound(new { message = "{Entity} not found" });
|
|
225
|
+
|
|
226
|
+
entity.Activate();
|
|
227
|
+
await _context.SaveChangesAsync(cancellationToken);
|
|
228
|
+
|
|
229
|
+
_logger.LogInformation("User {User} activated {Entity} {EntityId}",
|
|
230
|
+
_currentUser.Email, entity.Id);
|
|
231
|
+
|
|
232
|
+
return NoContent();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
[HttpPatch("{id:guid}/deactivate")]
|
|
236
|
+
[RequirePermission(Permissions.{PermissionClass}.Update)]
|
|
237
|
+
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
|
238
|
+
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
239
|
+
public async Task<IActionResult> Deactivate{Entity}(
|
|
240
|
+
Guid id,
|
|
241
|
+
CancellationToken cancellationToken)
|
|
242
|
+
{
|
|
243
|
+
var entity = await _context.{DbSet}
|
|
244
|
+
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
|
245
|
+
|
|
246
|
+
if (entity == null)
|
|
247
|
+
return NotFound(new { message = "{Entity} not found" });
|
|
248
|
+
|
|
249
|
+
entity.Deactivate();
|
|
250
|
+
await _context.SaveChangesAsync(cancellationToken);
|
|
251
|
+
|
|
252
|
+
_logger.LogWarning("User {User} deactivated {Entity} {EntityId}",
|
|
253
|
+
_currentUser.Email, entity.Id);
|
|
254
|
+
|
|
255
|
+
return NoContent();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
#endregion
|
|
259
|
+
|
|
260
|
+
#region DELETE
|
|
261
|
+
|
|
262
|
+
[HttpDelete("{id:guid}")]
|
|
263
|
+
[RequirePermission(Permissions.{PermissionClass}.Delete)]
|
|
264
|
+
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
|
265
|
+
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
266
|
+
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
267
|
+
public async Task<IActionResult> Delete{Entity}(
|
|
268
|
+
Guid id,
|
|
269
|
+
CancellationToken cancellationToken)
|
|
270
|
+
{
|
|
271
|
+
var entity = await _context.{DbSet}
|
|
272
|
+
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
|
273
|
+
|
|
274
|
+
if (entity == null)
|
|
275
|
+
return NotFound(new { message = "{Entity} not found" });
|
|
276
|
+
|
|
277
|
+
// Check for dependencies before deletion
|
|
278
|
+
// var hasReferences = await _context.ChildEntities.AnyAsync(x => x.{Entity}Id == id, ct);
|
|
279
|
+
// if (hasReferences)
|
|
280
|
+
// return BadRequest(new { message = "Cannot delete: has dependent records" });
|
|
281
|
+
|
|
282
|
+
_context.{DbSet}.Remove(entity);
|
|
283
|
+
await _context.SaveChangesAsync(cancellationToken);
|
|
284
|
+
|
|
285
|
+
_logger.LogWarning("User {User} deleted {Entity} {EntityId} ({Name})",
|
|
286
|
+
_currentUser.Email, id, entity.Name);
|
|
287
|
+
|
|
288
|
+
return NoContent();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
#endregion
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
#region DTOs
|
|
295
|
+
|
|
296
|
+
public record {Entity}ListDto(
|
|
297
|
+
Guid Id,
|
|
298
|
+
string Name,
|
|
299
|
+
string? Description,
|
|
300
|
+
bool IsActive,
|
|
301
|
+
DateTime CreatedAt
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
public record {Entity}DetailDto(
|
|
305
|
+
Guid Id,
|
|
306
|
+
string Name,
|
|
307
|
+
string? Description,
|
|
308
|
+
bool IsActive,
|
|
309
|
+
DateTime CreatedAt,
|
|
310
|
+
DateTime? UpdatedAt
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
public record Create{Entity}Request(
|
|
314
|
+
string Name,
|
|
315
|
+
string? Description
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
public record Update{Entity}Request(
|
|
319
|
+
string? Name,
|
|
320
|
+
string? Description
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
public record PagedResult<T>(
|
|
324
|
+
List<T> Items,
|
|
325
|
+
int TotalCount,
|
|
326
|
+
int Page,
|
|
327
|
+
int PageSize
|
|
328
|
+
)
|
|
329
|
+
{
|
|
330
|
+
public int TotalPages => (int)Math.Ceiling((double)TotalCount / PageSize);
|
|
331
|
+
public bool HasPrevious => Page > 1;
|
|
332
|
+
public bool HasNext => Page < TotalPages;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
#endregion
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
---
|
|
339
|
+
|
|
340
|
+
## Template Permissions Constants
|
|
341
|
+
|
|
342
|
+
```csharp
|
|
343
|
+
// src/SmartStack.Application/Common/Authorization/Permissions.cs
|
|
344
|
+
// AJOUTER dans la classe existante
|
|
345
|
+
|
|
346
|
+
public static class Permissions
|
|
347
|
+
{
|
|
348
|
+
// ... existing permissions ...
|
|
349
|
+
|
|
350
|
+
public static class {PermissionClass}
|
|
351
|
+
{
|
|
352
|
+
public const string Access = "{permission.path}";
|
|
353
|
+
public const string View = "{permission.path}.read";
|
|
354
|
+
public const string Create = "{permission.path}.create";
|
|
355
|
+
public const string Update = "{permission.path}.update";
|
|
356
|
+
public const string Delete = "{permission.path}.delete";
|
|
357
|
+
// Optionnel selon module
|
|
358
|
+
public const string Assign = "{permission.path}.assign";
|
|
359
|
+
public const string Execute = "{permission.path}.execute";
|
|
360
|
+
public const string Export = "{permission.path}.export";
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
---
|
|
366
|
+
|
|
367
|
+
## Template PermissionConfiguration Seed
|
|
368
|
+
|
|
369
|
+
> **CRITIQUE:** Ce template est OBLIGATOIRE. Sans ces entrées, tous les appels API retourneront 403 Forbidden.
|
|
370
|
+
|
|
371
|
+
```csharp
|
|
372
|
+
// src/SmartStack.Infrastructure/Persistence/Configurations/Authorization/PermissionConfiguration.cs
|
|
373
|
+
// AJOUTER dans la méthode Configure(), section HasData
|
|
374
|
+
|
|
375
|
+
// ============================================
|
|
376
|
+
// ÉTAPE 1: Déclarer le ModuleId
|
|
377
|
+
// ============================================
|
|
378
|
+
// Vérifier dans ModuleConfiguration.cs si le module existe déjà
|
|
379
|
+
// Sinon, créer le module d'abord via /application skill
|
|
380
|
+
|
|
381
|
+
var {module}ModuleId = Guid.Parse("{GUID-DU-MODULE}"); // Récupérer depuis ModuleConfiguration.cs
|
|
382
|
+
|
|
383
|
+
// ============================================
|
|
384
|
+
// ÉTAPE 2: Ajouter les permissions (HasData)
|
|
385
|
+
// ============================================
|
|
386
|
+
|
|
387
|
+
// Pattern: {context}.{application}.{module}.{action}
|
|
388
|
+
// Exemple: business.crm.leads.read
|
|
389
|
+
|
|
390
|
+
var seedDate = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
|
391
|
+
|
|
392
|
+
builder.HasData(
|
|
393
|
+
// Wildcard permission (accès complet au module)
|
|
394
|
+
new
|
|
395
|
+
{
|
|
396
|
+
Id = Guid.Parse("{RANDOM-GUID-1}"), // Générer avec [guid]::NewGuid()
|
|
397
|
+
Path = "{context}.{application}.{module}.*",
|
|
398
|
+
Level = PermissionLevel.Module,
|
|
399
|
+
Action = (PermissionAction?)null,
|
|
400
|
+
IsWildcard = true,
|
|
401
|
+
ModuleId = {module}ModuleId,
|
|
402
|
+
Description = "Full {module} management",
|
|
403
|
+
CreatedAt = seedDate
|
|
404
|
+
},
|
|
405
|
+
|
|
406
|
+
// Read permission
|
|
407
|
+
new
|
|
408
|
+
{
|
|
409
|
+
Id = Guid.Parse("{RANDOM-GUID-2}"),
|
|
410
|
+
Path = "{context}.{application}.{module}.read",
|
|
411
|
+
Level = PermissionLevel.Module,
|
|
412
|
+
Action = PermissionAction.Read,
|
|
413
|
+
IsWildcard = false,
|
|
414
|
+
ModuleId = {module}ModuleId,
|
|
415
|
+
Description = "View {module}",
|
|
416
|
+
CreatedAt = seedDate
|
|
417
|
+
},
|
|
418
|
+
|
|
419
|
+
// Create permission
|
|
420
|
+
new
|
|
421
|
+
{
|
|
422
|
+
Id = Guid.Parse("{RANDOM-GUID-3}"),
|
|
423
|
+
Path = "{context}.{application}.{module}.create",
|
|
424
|
+
Level = PermissionLevel.Module,
|
|
425
|
+
Action = PermissionAction.Create,
|
|
426
|
+
IsWildcard = false,
|
|
427
|
+
ModuleId = {module}ModuleId,
|
|
428
|
+
Description = "Create {module}",
|
|
429
|
+
CreatedAt = seedDate
|
|
430
|
+
},
|
|
431
|
+
|
|
432
|
+
// Update permission
|
|
433
|
+
new
|
|
434
|
+
{
|
|
435
|
+
Id = Guid.Parse("{RANDOM-GUID-4}"),
|
|
436
|
+
Path = "{context}.{application}.{module}.update",
|
|
437
|
+
Level = PermissionLevel.Module,
|
|
438
|
+
Action = PermissionAction.Update,
|
|
439
|
+
IsWildcard = false,
|
|
440
|
+
ModuleId = {module}ModuleId,
|
|
441
|
+
Description = "Update {module}",
|
|
442
|
+
CreatedAt = seedDate
|
|
443
|
+
},
|
|
444
|
+
|
|
445
|
+
// Delete permission
|
|
446
|
+
new
|
|
447
|
+
{
|
|
448
|
+
Id = Guid.Parse("{RANDOM-GUID-5}"),
|
|
449
|
+
Path = "{context}.{application}.{module}.delete",
|
|
450
|
+
Level = PermissionLevel.Module,
|
|
451
|
+
Action = PermissionAction.Delete,
|
|
452
|
+
IsWildcard = false,
|
|
453
|
+
ModuleId = {module}ModuleId,
|
|
454
|
+
Description = "Delete {module}",
|
|
455
|
+
CreatedAt = seedDate
|
|
456
|
+
}
|
|
457
|
+
);
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
### Génération de GUIDs
|
|
461
|
+
|
|
462
|
+
```bash
|
|
463
|
+
# PowerShell (Windows)
|
|
464
|
+
[guid]::NewGuid().ToString()
|
|
465
|
+
|
|
466
|
+
# Bash (Linux/Mac)
|
|
467
|
+
uuidgen | tr '[:upper:]' '[:lower:]'
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
---
|
|
471
|
+
|
|
472
|
+
## Template Controller avec Relations
|
|
473
|
+
|
|
474
|
+
```csharp
|
|
475
|
+
// Pour les controllers avec entités liées (ex: Tickets avec Comments)
|
|
476
|
+
|
|
477
|
+
#region GET with Includes
|
|
478
|
+
|
|
479
|
+
[HttpGet("{id:guid}")]
|
|
480
|
+
[RequirePermission(Permissions.{PermissionClass}.View)]
|
|
481
|
+
[ProducesResponseType(typeof({Entity}DetailDto), StatusCodes.Status200OK)]
|
|
482
|
+
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
483
|
+
public async Task<ActionResult<{Entity}DetailDto>> Get{Entity}(
|
|
484
|
+
Guid id,
|
|
485
|
+
CancellationToken cancellationToken)
|
|
486
|
+
{
|
|
487
|
+
var entity = await _context.{DbSet}
|
|
488
|
+
.Include(x => x.CreatedByUser)
|
|
489
|
+
.Include(x => x.AssignedToUser)
|
|
490
|
+
.Include(x => x.Comments)
|
|
491
|
+
.ThenInclude(c => c.Author)
|
|
492
|
+
.Include(x => x.Attachments)
|
|
493
|
+
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
|
494
|
+
|
|
495
|
+
if (entity == null)
|
|
496
|
+
return NotFound(new { message = "{Entity} not found" });
|
|
497
|
+
|
|
498
|
+
return Ok(MapToDetailDto(entity));
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
#endregion
|
|
502
|
+
|
|
503
|
+
#region Nested Resources
|
|
504
|
+
|
|
505
|
+
[HttpGet("{parentId:guid}/children")]
|
|
506
|
+
[RequirePermission(Permissions.{PermissionClass}.View)]
|
|
507
|
+
[ProducesResponseType(typeof(List<ChildDto>), StatusCodes.Status200OK)]
|
|
508
|
+
public async Task<ActionResult<List<ChildDto>>> GetChildren(
|
|
509
|
+
Guid parentId,
|
|
510
|
+
CancellationToken cancellationToken)
|
|
511
|
+
{
|
|
512
|
+
var children = await _context.Children
|
|
513
|
+
.Where(x => x.ParentId == parentId)
|
|
514
|
+
.OrderByDescending(x => x.CreatedAt)
|
|
515
|
+
.Select(x => new ChildDto(x.Id, x.Name, x.CreatedAt))
|
|
516
|
+
.ToListAsync(cancellationToken);
|
|
517
|
+
|
|
518
|
+
return Ok(children);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
[HttpPost("{parentId:guid}/children")]
|
|
522
|
+
[RequirePermission(Permissions.{PermissionClass}.Create)]
|
|
523
|
+
[ProducesResponseType(typeof(ChildDto), StatusCodes.Status201Created)]
|
|
524
|
+
public async Task<ActionResult<ChildDto>> AddChild(
|
|
525
|
+
Guid parentId,
|
|
526
|
+
[FromBody] CreateChildRequest request,
|
|
527
|
+
CancellationToken cancellationToken)
|
|
528
|
+
{
|
|
529
|
+
var parent = await _context.{DbSet}
|
|
530
|
+
.FirstOrDefaultAsync(x => x.Id == parentId, cancellationToken);
|
|
531
|
+
|
|
532
|
+
if (parent == null)
|
|
533
|
+
return NotFound(new { message = "Parent not found" });
|
|
534
|
+
|
|
535
|
+
var child = Child.Create(parentId, request.Name, _currentUser.UserId!.Value);
|
|
536
|
+
|
|
537
|
+
_context.Children.Add(child);
|
|
538
|
+
await _context.SaveChangesAsync(cancellationToken);
|
|
539
|
+
|
|
540
|
+
_logger.LogInformation("User {User} added child to {Entity} {ParentId}",
|
|
541
|
+
_currentUser.Email, parentId);
|
|
542
|
+
|
|
543
|
+
return CreatedAtAction(
|
|
544
|
+
nameof(GetChildren),
|
|
545
|
+
new { parentId },
|
|
546
|
+
new ChildDto(child.Id, child.Name, child.CreatedAt));
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
#endregion
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
---
|
|
553
|
+
|
|
554
|
+
## Patterns Réutilisables
|
|
555
|
+
|
|
556
|
+
### Error Response Standard
|
|
557
|
+
|
|
558
|
+
```csharp
|
|
559
|
+
public record ErrorResponse(string Message, string? Code = null);
|
|
560
|
+
|
|
561
|
+
// Usage:
|
|
562
|
+
return BadRequest(new ErrorResponse("Validation failed", "VALIDATION_ERROR"));
|
|
563
|
+
return Conflict(new ErrorResponse("Already exists", "DUPLICATE"));
|
|
564
|
+
return NotFound(new { message = "Resource not found" });
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
### Pagination Query Extension
|
|
568
|
+
|
|
569
|
+
```csharp
|
|
570
|
+
public static class QueryableExtensions
|
|
571
|
+
{
|
|
572
|
+
public static async Task<PagedResult<T>> ToPagedResultAsync<T>(
|
|
573
|
+
this IQueryable<T> query,
|
|
574
|
+
int page,
|
|
575
|
+
int pageSize,
|
|
576
|
+
CancellationToken ct = default)
|
|
577
|
+
{
|
|
578
|
+
var totalCount = await query.CountAsync(ct);
|
|
579
|
+
var items = await query
|
|
580
|
+
.Skip((page - 1) * pageSize)
|
|
581
|
+
.Take(pageSize)
|
|
582
|
+
.ToListAsync(ct);
|
|
583
|
+
|
|
584
|
+
return new PagedResult<T>(items, totalCount, page, pageSize);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
### Log Context Pattern
|
|
590
|
+
|
|
591
|
+
```csharp
|
|
592
|
+
// Toujours inclure le contexte utilisateur dans les logs
|
|
593
|
+
_logger.LogInformation(
|
|
594
|
+
"User {User} ({UserId}) performed {Action} on {Entity} {EntityId}",
|
|
595
|
+
_currentUser.Email,
|
|
596
|
+
_currentUser.UserId,
|
|
597
|
+
"Create",
|
|
598
|
+
"{Entity}",
|
|
599
|
+
entity.Id);
|
|
600
|
+
```
|