@atlashub/smartstack-cli 1.5.1 → 1.5.2
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,1468 +1,1468 @@
|
|
|
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 Auth Controller (Login/Logout)
|
|
341
|
-
|
|
342
|
-
```csharp
|
|
343
|
-
// src/SmartStack.Api/Controllers/AuthController.cs
|
|
344
|
-
// NOTE: Ce controller existe déjà - utiliser comme référence pour patterns auth
|
|
345
|
-
|
|
346
|
-
using Microsoft.AspNetCore.Authorization;
|
|
347
|
-
using Microsoft.AspNetCore.Mvc;
|
|
348
|
-
using Microsoft.EntityFrameworkCore;
|
|
349
|
-
using Microsoft.Extensions.Options;
|
|
350
|
-
using SmartStack.Application.Common.Interfaces;
|
|
351
|
-
using SmartStack.Application.Common.Settings;
|
|
352
|
-
using SmartStack.Domain.Platform.Administration.Users;
|
|
353
|
-
|
|
354
|
-
namespace SmartStack.Api.Controllers;
|
|
355
|
-
|
|
356
|
-
[ApiController]
|
|
357
|
-
[Route("api/[controller]")]
|
|
358
|
-
public class AuthController : ControllerBase
|
|
359
|
-
{
|
|
360
|
-
private readonly IApplicationDbContext _context;
|
|
361
|
-
private readonly IPasswordService _passwordService;
|
|
362
|
-
private readonly IJwtService _jwtService;
|
|
363
|
-
private readonly IUserSessionService _sessionService;
|
|
364
|
-
private readonly ISessionValidationService _sessionValidationService;
|
|
365
|
-
private readonly SessionSettings _sessionSettings;
|
|
366
|
-
private readonly ILogger<AuthController> _logger;
|
|
367
|
-
|
|
368
|
-
// ... Constructor avec tous les services auth
|
|
369
|
-
|
|
370
|
-
#region Login - LOGS CRITIQUES OBLIGATOIRES
|
|
371
|
-
|
|
372
|
-
[HttpPost("login")]
|
|
373
|
-
[AllowAnonymous]
|
|
374
|
-
[ProducesResponseType(typeof(LoginResponse), StatusCodes.Status200OK)]
|
|
375
|
-
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status401Unauthorized)]
|
|
376
|
-
public async Task<ActionResult<LoginResponse>> Login(
|
|
377
|
-
[FromBody] LoginRequest request,
|
|
378
|
-
CancellationToken cancellationToken)
|
|
379
|
-
{
|
|
380
|
-
var ipAddress = GetClientIpAddress();
|
|
381
|
-
var userAgent = Request.Headers.UserAgent.ToString();
|
|
382
|
-
|
|
383
|
-
var user = await _context.Users
|
|
384
|
-
.Include(u => u.UserRoles)
|
|
385
|
-
.ThenInclude(ur => ur.Role)
|
|
386
|
-
.ThenInclude(r => r!.RolePermissions)
|
|
387
|
-
.ThenInclude(rp => rp.Permission)
|
|
388
|
-
.FirstOrDefaultAsync(u => u.Email == request.Email, cancellationToken);
|
|
389
|
-
|
|
390
|
-
// ============================================
|
|
391
|
-
// LOGS CRITIQUES - NE JAMAIS OMETTRE
|
|
392
|
-
// ============================================
|
|
393
|
-
|
|
394
|
-
if (user == null)
|
|
395
|
-
{
|
|
396
|
-
// WARNING: User not found (potential enumeration)
|
|
397
|
-
_logger.LogWarning(
|
|
398
|
-
"Login failed: User not found - {Email} from {IpAddress}",
|
|
399
|
-
request.Email, ipAddress);
|
|
400
|
-
return Unauthorized(new ErrorResponse("Identifiants invalides", "INVALID_CREDENTIALS"));
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
if (!user.IsActive)
|
|
404
|
-
{
|
|
405
|
-
// WARNING: Disabled account
|
|
406
|
-
_logger.LogWarning(
|
|
407
|
-
"Login failed: Account disabled - {Email} from {IpAddress}",
|
|
408
|
-
request.Email, ipAddress);
|
|
409
|
-
return Unauthorized(new ErrorResponse("Compte désactivé", "ACCOUNT_DISABLED"));
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
if (user.IsLocked)
|
|
413
|
-
{
|
|
414
|
-
// CRITICAL: Locked account attempt
|
|
415
|
-
_logger.LogCritical(
|
|
416
|
-
"SECURITY: Login attempt on locked account - User: {Email} (ID: {UserId}) from {IpAddress}",
|
|
417
|
-
request.Email, user.Id, ipAddress);
|
|
418
|
-
return Unauthorized(new ErrorResponse("Compte verrouillé", "ACCOUNT_LOCKED_BY_ADMIN"));
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
// Check brute force protection
|
|
422
|
-
var recentFailedAttempts = await _context.UserSessions
|
|
423
|
-
.Where(s => s.UserId == user.Id && !s.IsSuccessful && s.LoginAt > DateTime.UtcNow.AddMinutes(-15))
|
|
424
|
-
.CountAsync(cancellationToken);
|
|
425
|
-
|
|
426
|
-
if (recentFailedAttempts >= 5)
|
|
427
|
-
{
|
|
428
|
-
// CRITICAL: Too many failed attempts
|
|
429
|
-
_logger.LogCritical(
|
|
430
|
-
"SECURITY: Account temporarily locked due to brute force - User: {Email} (ID: {UserId}) from {IpAddress}",
|
|
431
|
-
request.Email, user.Id, ipAddress);
|
|
432
|
-
return Unauthorized(new ErrorResponse("Compte temporairement verrouillé", "ACCOUNT_LOCKED"));
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
|
|
436
|
-
{
|
|
437
|
-
// WARNING: Invalid password with remaining attempts
|
|
438
|
-
var remainingAttempts = 5 - recentFailedAttempts - 1;
|
|
439
|
-
_logger.LogWarning(
|
|
440
|
-
"Login failed: Invalid password - {Email} from {IpAddress}, remaining attempts: {Remaining}",
|
|
441
|
-
request.Email, ipAddress, remainingAttempts);
|
|
442
|
-
|
|
443
|
-
// Log failed attempt to session
|
|
444
|
-
await _sessionService.LogFailedLoginAsync(user.Id, ipAddress, userAgent, cancellationToken);
|
|
445
|
-
|
|
446
|
-
return Unauthorized(new ErrorResponse("Mot de passe incorrect", "INVALID_PASSWORD"));
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
// ============================================
|
|
450
|
-
// SUCCESS - Generate tokens
|
|
451
|
-
// ============================================
|
|
452
|
-
|
|
453
|
-
var roles = user.UserRoles.Select(ur => ur.Role!.Name).ToList();
|
|
454
|
-
var permissions = user.UserRoles
|
|
455
|
-
.SelectMany(ur => ur.Role!.RolePermissions)
|
|
456
|
-
.Select(rp => rp.Permission!.Path)
|
|
457
|
-
.Distinct()
|
|
458
|
-
.ToList();
|
|
459
|
-
|
|
460
|
-
var accessToken = _jwtService.GenerateAccessToken(user, roles, permissions);
|
|
461
|
-
var refreshToken = _jwtService.GenerateRefreshToken();
|
|
462
|
-
|
|
463
|
-
await _sessionService.LogLoginAsync(user.Id, accessToken, ipAddress, userAgent, cancellationToken: cancellationToken);
|
|
464
|
-
|
|
465
|
-
// INFO: Successful login
|
|
466
|
-
_logger.LogInformation(
|
|
467
|
-
"User logged in successfully: {Email} from {IpAddress}",
|
|
468
|
-
user.Email, ipAddress);
|
|
469
|
-
|
|
470
|
-
return Ok(new LoginResponse(accessToken, refreshToken, /* UserInfo */));
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
#endregion
|
|
474
|
-
|
|
475
|
-
#region Logout
|
|
476
|
-
|
|
477
|
-
[HttpPost("logout")]
|
|
478
|
-
[Authorize]
|
|
479
|
-
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
|
480
|
-
public async Task<IActionResult> Logout(CancellationToken cancellationToken)
|
|
481
|
-
{
|
|
482
|
-
var token = Request.Headers.Authorization.ToString().Replace("Bearer ", "");
|
|
483
|
-
await _sessionService.LogLogoutAsync(token, cancellationToken);
|
|
484
|
-
|
|
485
|
-
_logger.LogInformation("User logged out: {UserId}", User.FindFirst("sub")?.Value);
|
|
486
|
-
|
|
487
|
-
return NoContent();
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
#endregion
|
|
491
|
-
|
|
492
|
-
#region Change Password - LOG WARNING
|
|
493
|
-
|
|
494
|
-
[HttpPost("change-password")]
|
|
495
|
-
[Authorize]
|
|
496
|
-
[ProducesResponseType(typeof(ChangePasswordResponse), StatusCodes.Status200OK)]
|
|
497
|
-
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)]
|
|
498
|
-
public async Task<ActionResult<ChangePasswordResponse>> ChangePassword(
|
|
499
|
-
[FromBody] ChangePasswordRequest request,
|
|
500
|
-
CancellationToken cancellationToken)
|
|
501
|
-
{
|
|
502
|
-
// ... validation logic
|
|
503
|
-
|
|
504
|
-
user.UpdatePassword(newPasswordHash);
|
|
505
|
-
await _context.SaveChangesAsync(cancellationToken);
|
|
506
|
-
|
|
507
|
-
// Invalidate ALL sessions after password change
|
|
508
|
-
await _sessionService.InvalidateAllUserSessionsAsync(userId, "Password changed", cancellationToken);
|
|
509
|
-
|
|
510
|
-
// WARNING: Sensitive operation
|
|
511
|
-
_logger.LogWarning(
|
|
512
|
-
"Password changed for user {Email} - All sessions invalidated",
|
|
513
|
-
user.Email);
|
|
514
|
-
|
|
515
|
-
return Ok(new ChangePasswordResponse("Mot de passe modifié", true));
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
#endregion
|
|
519
|
-
|
|
520
|
-
private string GetClientIpAddress()
|
|
521
|
-
{
|
|
522
|
-
var forwardedFor = Request.Headers["X-Forwarded-For"].FirstOrDefault();
|
|
523
|
-
if (!string.IsNullOrEmpty(forwardedFor))
|
|
524
|
-
return forwardedFor.Split(',')[0].Trim();
|
|
525
|
-
return HttpContext.Connection.RemoteIpAddress?.ToString() ?? "Unknown";
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
```
|
|
529
|
-
|
|
530
|
-
---
|
|
531
|
-
|
|
532
|
-
## Template Permissions Constants
|
|
533
|
-
|
|
534
|
-
```csharp
|
|
535
|
-
// src/SmartStack.Application/Common/Authorization/Permissions.cs
|
|
536
|
-
// AJOUTER dans la classe existante
|
|
537
|
-
|
|
538
|
-
public static class Permissions
|
|
539
|
-
{
|
|
540
|
-
// ... existing permissions ...
|
|
541
|
-
|
|
542
|
-
public static class {PermissionClass}
|
|
543
|
-
{
|
|
544
|
-
public const string Access = "{permission.path}";
|
|
545
|
-
public const string View = "{permission.path}.read";
|
|
546
|
-
public const string Create = "{permission.path}.create";
|
|
547
|
-
public const string Update = "{permission.path}.update";
|
|
548
|
-
public const string Delete = "{permission.path}.delete";
|
|
549
|
-
// Optionnel selon module
|
|
550
|
-
public const string Assign = "{permission.path}.assign";
|
|
551
|
-
public const string Execute = "{permission.path}.execute";
|
|
552
|
-
public const string Export = "{permission.path}.export";
|
|
553
|
-
}
|
|
554
|
-
}
|
|
555
|
-
```
|
|
556
|
-
|
|
557
|
-
---
|
|
558
|
-
|
|
559
|
-
## Template PermissionConfiguration Seed
|
|
560
|
-
|
|
561
|
-
> **CRITIQUE:** Ce template est OBLIGATOIRE. Sans ces entrées, tous les appels API retourneront 403 Forbidden.
|
|
562
|
-
|
|
563
|
-
```csharp
|
|
564
|
-
// src/SmartStack.Infrastructure/Persistence/Configurations/Navigation/PermissionConfiguration.cs
|
|
565
|
-
// AJOUTER dans la méthode Configure(), section HasData
|
|
566
|
-
|
|
567
|
-
// ============================================
|
|
568
|
-
// ÉTAPE 1: Déclarer le ModuleId
|
|
569
|
-
// ============================================
|
|
570
|
-
// Vérifier dans ModuleConfiguration.cs si le module existe déjà
|
|
571
|
-
// Sinon, créer le module d'abord via /application skill
|
|
572
|
-
|
|
573
|
-
var {module}ModuleId = Guid.Parse("{GUID-DU-MODULE}"); // Récupérer depuis ModuleConfiguration.cs
|
|
574
|
-
|
|
575
|
-
// ============================================
|
|
576
|
-
// ÉTAPE 2: Ajouter les permissions (HasData)
|
|
577
|
-
// ============================================
|
|
578
|
-
|
|
579
|
-
// Pattern: {context}.{application}.{module}.{action}
|
|
580
|
-
// Exemple: platform.administration.users.read
|
|
581
|
-
|
|
582
|
-
var seedDate = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
|
583
|
-
|
|
584
|
-
builder.HasData(
|
|
585
|
-
// Wildcard permission (accès complet au module)
|
|
586
|
-
new
|
|
587
|
-
{
|
|
588
|
-
Id = Guid.Parse("{NOUVEAU-GUID-1}"),
|
|
589
|
-
Path = "{context}.{application}.{module}.*",
|
|
590
|
-
Level = PermissionLevel.Module,
|
|
591
|
-
Action = (PermissionAction?)null,
|
|
592
|
-
IsWildcard = true,
|
|
593
|
-
ModuleId = {module}ModuleId,
|
|
594
|
-
Description = "Full {module} management",
|
|
595
|
-
CreatedAt = seedDate
|
|
596
|
-
},
|
|
597
|
-
|
|
598
|
-
// Read permission
|
|
599
|
-
new
|
|
600
|
-
{
|
|
601
|
-
Id = Guid.Parse("{NOUVEAU-GUID-2}"),
|
|
602
|
-
Path = "{context}.{application}.{module}.read",
|
|
603
|
-
Level = PermissionLevel.Module,
|
|
604
|
-
Action = PermissionAction.Read,
|
|
605
|
-
IsWildcard = false,
|
|
606
|
-
ModuleId = {module}ModuleId,
|
|
607
|
-
Description = "View {module}",
|
|
608
|
-
CreatedAt = seedDate
|
|
609
|
-
},
|
|
610
|
-
|
|
611
|
-
// Create permission
|
|
612
|
-
new
|
|
613
|
-
{
|
|
614
|
-
Id = Guid.Parse("{NOUVEAU-GUID-3}"),
|
|
615
|
-
Path = "{context}.{application}.{module}.create",
|
|
616
|
-
Level = PermissionLevel.Module,
|
|
617
|
-
Action = PermissionAction.Create,
|
|
618
|
-
IsWildcard = false,
|
|
619
|
-
ModuleId = {module}ModuleId,
|
|
620
|
-
Description = "Create {module}",
|
|
621
|
-
CreatedAt = seedDate
|
|
622
|
-
},
|
|
623
|
-
|
|
624
|
-
// Update permission
|
|
625
|
-
new
|
|
626
|
-
{
|
|
627
|
-
Id = Guid.Parse("{NOUVEAU-GUID-4}"),
|
|
628
|
-
Path = "{context}.{application}.{module}.update",
|
|
629
|
-
Level = PermissionLevel.Module,
|
|
630
|
-
Action = PermissionAction.Update,
|
|
631
|
-
IsWildcard = false,
|
|
632
|
-
ModuleId = {module}ModuleId,
|
|
633
|
-
Description = "Update {module}",
|
|
634
|
-
CreatedAt = seedDate
|
|
635
|
-
},
|
|
636
|
-
|
|
637
|
-
// Delete permission
|
|
638
|
-
new
|
|
639
|
-
{
|
|
640
|
-
Id = Guid.Parse("{NOUVEAU-GUID-5}"),
|
|
641
|
-
Path = "{context}.{application}.{module}.delete",
|
|
642
|
-
Level = PermissionLevel.Module,
|
|
643
|
-
Action = PermissionAction.Delete,
|
|
644
|
-
IsWildcard = false,
|
|
645
|
-
ModuleId = {module}ModuleId,
|
|
646
|
-
Description = "Delete {module}",
|
|
647
|
-
CreatedAt = seedDate
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
// Actions optionnelles selon le module:
|
|
651
|
-
// - PermissionAction.Assign → Pour assigner des ressources/rôles
|
|
652
|
-
// - PermissionAction.Execute → Pour exécuter des actions (export, etc.)
|
|
653
|
-
);
|
|
654
|
-
```
|
|
655
|
-
|
|
656
|
-
### Génération de GUIDs
|
|
657
|
-
|
|
658
|
-
```bash
|
|
659
|
-
# PowerShell (Windows)
|
|
660
|
-
[guid]::NewGuid().ToString()
|
|
661
|
-
|
|
662
|
-
# Bash (Linux/Mac)
|
|
663
|
-
uuidgen | tr '[:upper:]' '[:lower:]'
|
|
664
|
-
```
|
|
665
|
-
|
|
666
|
-
### Validation Cohérence Permissions.cs ↔ PermissionConfiguration.cs
|
|
667
|
-
|
|
668
|
-
> **RÈGLE:** Chaque constante dans `Permissions.cs` DOIT avoir une entrée correspondante dans `PermissionConfiguration.cs`
|
|
669
|
-
|
|
670
|
-
```
|
|
671
|
-
┌─────────────────────────────────────────────────────────────────────────────┐
|
|
672
|
-
│ VALIDATION DE COHÉRENCE │
|
|
673
|
-
├─────────────────────────────────────────────────────────────────────────────┤
|
|
674
|
-
│ │
|
|
675
|
-
│ Permissions.cs PermissionConfiguration.cs │
|
|
676
|
-
│ ────────────────────────────── ────────────────────────────────────── │
|
|
677
|
-
│ Permissions.Support.Tickets.View → Path = "platform.support.tickets.read"│
|
|
678
|
-
│ Permissions.Support.Tickets.Create→ Path = "platform.support.tickets.create"│
|
|
679
|
-
│ Permissions.Support.Tickets.Update→ Path = "platform.support.tickets.update"│
|
|
680
|
-
│ Permissions.Support.Tickets.Delete→ Path = "platform.support.tickets.delete"│
|
|
681
|
-
│ │
|
|
682
|
-
│ ⚠️ ERREUR FRÉQUENTE: │
|
|
683
|
-
│ - Permissions.cs: "platform.support.tickets.read" │
|
|
684
|
-
│ - PermissionConfiguration.cs: MANQUANT │
|
|
685
|
-
│ → Résultat: 403 Forbidden pour TOUS les utilisateurs │
|
|
686
|
-
│ │
|
|
687
|
-
└─────────────────────────────────────────────────────────────────────────────┘
|
|
688
|
-
```
|
|
689
|
-
|
|
690
|
-
### Commande post-génération
|
|
691
|
-
|
|
692
|
-
Après avoir ajouté les entrées dans les deux fichiers:
|
|
693
|
-
|
|
694
|
-
```bash
|
|
695
|
-
# 1. Créer la migration
|
|
696
|
-
/efcore:migration Add{Module}Permissions
|
|
697
|
-
|
|
698
|
-
# 2. Appliquer la migration
|
|
699
|
-
/efcore:db-deploy
|
|
700
|
-
|
|
701
|
-
# 3. Vérifier (optionnel)
|
|
702
|
-
/efcore:db-status
|
|
703
|
-
```
|
|
704
|
-
|
|
705
|
-
---
|
|
706
|
-
|
|
707
|
-
## Template Controller avec Relations
|
|
708
|
-
|
|
709
|
-
```csharp
|
|
710
|
-
// Pour les controllers avec entités liées (ex: Tickets avec Comments)
|
|
711
|
-
|
|
712
|
-
#region GET with Includes
|
|
713
|
-
|
|
714
|
-
[HttpGet("{id:guid}")]
|
|
715
|
-
[RequirePermission(Permissions.{PermissionClass}.View)]
|
|
716
|
-
[ProducesResponseType(typeof({Entity}DetailDto), StatusCodes.Status200OK)]
|
|
717
|
-
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
718
|
-
public async Task<ActionResult<{Entity}DetailDto>> Get{Entity}(
|
|
719
|
-
Guid id,
|
|
720
|
-
CancellationToken cancellationToken)
|
|
721
|
-
{
|
|
722
|
-
var entity = await _context.{DbSet}
|
|
723
|
-
.Include(x => x.CreatedByUser)
|
|
724
|
-
.Include(x => x.AssignedToUser)
|
|
725
|
-
.Include(x => x.Comments)
|
|
726
|
-
.ThenInclude(c => c.Author)
|
|
727
|
-
.Include(x => x.Attachments)
|
|
728
|
-
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
|
729
|
-
|
|
730
|
-
if (entity == null)
|
|
731
|
-
return NotFound(new { message = "{Entity} not found" });
|
|
732
|
-
|
|
733
|
-
return Ok(MapToDetailDto(entity));
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
#endregion
|
|
737
|
-
|
|
738
|
-
#region Nested Resources
|
|
739
|
-
|
|
740
|
-
[HttpGet("{parentId:guid}/children")]
|
|
741
|
-
[RequirePermission(Permissions.{PermissionClass}.View)]
|
|
742
|
-
[ProducesResponseType(typeof(List<ChildDto>), StatusCodes.Status200OK)]
|
|
743
|
-
public async Task<ActionResult<List<ChildDto>>> GetChildren(
|
|
744
|
-
Guid parentId,
|
|
745
|
-
CancellationToken cancellationToken)
|
|
746
|
-
{
|
|
747
|
-
var children = await _context.Children
|
|
748
|
-
.Where(x => x.ParentId == parentId)
|
|
749
|
-
.OrderByDescending(x => x.CreatedAt)
|
|
750
|
-
.Select(x => new ChildDto(x.Id, x.Name, x.CreatedAt))
|
|
751
|
-
.ToListAsync(cancellationToken);
|
|
752
|
-
|
|
753
|
-
return Ok(children);
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
[HttpPost("{parentId:guid}/children")]
|
|
757
|
-
[RequirePermission(Permissions.{PermissionClass}.Create)]
|
|
758
|
-
[ProducesResponseType(typeof(ChildDto), StatusCodes.Status201Created)]
|
|
759
|
-
public async Task<ActionResult<ChildDto>> AddChild(
|
|
760
|
-
Guid parentId,
|
|
761
|
-
[FromBody] CreateChildRequest request,
|
|
762
|
-
CancellationToken cancellationToken)
|
|
763
|
-
{
|
|
764
|
-
var parent = await _context.{DbSet}
|
|
765
|
-
.FirstOrDefaultAsync(x => x.Id == parentId, cancellationToken);
|
|
766
|
-
|
|
767
|
-
if (parent == null)
|
|
768
|
-
return NotFound(new { message = "Parent not found" });
|
|
769
|
-
|
|
770
|
-
var child = Child.Create(parentId, request.Name, _currentUser.UserId!.Value);
|
|
771
|
-
|
|
772
|
-
_context.Children.Add(child);
|
|
773
|
-
await _context.SaveChangesAsync(cancellationToken);
|
|
774
|
-
|
|
775
|
-
_logger.LogInformation("User {User} added child to {Entity} {ParentId}",
|
|
776
|
-
_currentUser.Email, parentId);
|
|
777
|
-
|
|
778
|
-
return CreatedAtAction(
|
|
779
|
-
nameof(GetChildren),
|
|
780
|
-
new { parentId },
|
|
781
|
-
new ChildDto(child.Id, child.Name, child.CreatedAt));
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
#endregion
|
|
785
|
-
```
|
|
786
|
-
|
|
787
|
-
---
|
|
788
|
-
|
|
789
|
-
## Patterns Réutilisables
|
|
790
|
-
|
|
791
|
-
### Error Response Standard
|
|
792
|
-
|
|
793
|
-
```csharp
|
|
794
|
-
public record ErrorResponse(string Message, string? Code = null);
|
|
795
|
-
|
|
796
|
-
// Usage:
|
|
797
|
-
return BadRequest(new ErrorResponse("Validation failed", "VALIDATION_ERROR"));
|
|
798
|
-
return Conflict(new ErrorResponse("Already exists", "DUPLICATE"));
|
|
799
|
-
return NotFound(new { message = "Resource not found" });
|
|
800
|
-
```
|
|
801
|
-
|
|
802
|
-
### Pagination Query Extension
|
|
803
|
-
|
|
804
|
-
```csharp
|
|
805
|
-
public static class QueryableExtensions
|
|
806
|
-
{
|
|
807
|
-
public static async Task<PagedResult<T>> ToPagedResultAsync<T>(
|
|
808
|
-
this IQueryable<T> query,
|
|
809
|
-
int page,
|
|
810
|
-
int pageSize,
|
|
811
|
-
CancellationToken ct = default)
|
|
812
|
-
{
|
|
813
|
-
var totalCount = await query.CountAsync(ct);
|
|
814
|
-
var items = await query
|
|
815
|
-
.Skip((page - 1) * pageSize)
|
|
816
|
-
.Take(pageSize)
|
|
817
|
-
.ToListAsync(ct);
|
|
818
|
-
|
|
819
|
-
return new PagedResult<T>(items, totalCount, page, pageSize);
|
|
820
|
-
}
|
|
821
|
-
}
|
|
822
|
-
```
|
|
823
|
-
|
|
824
|
-
### Log Context Pattern
|
|
825
|
-
|
|
826
|
-
```csharp
|
|
827
|
-
// Toujours inclure le contexte utilisateur dans les logs
|
|
828
|
-
_logger.LogInformation(
|
|
829
|
-
"User {User} ({UserId}) performed {Action} on {Entity} {EntityId}",
|
|
830
|
-
_currentUser.Email,
|
|
831
|
-
_currentUser.UserId,
|
|
832
|
-
"Create",
|
|
833
|
-
"{Entity}",
|
|
834
|
-
entity.Id);
|
|
835
|
-
```
|
|
836
|
-
|
|
837
|
-
---
|
|
838
|
-
|
|
839
|
-
## Template Section-Level Permissions (Level 4)
|
|
840
|
-
|
|
841
|
-
> **Usage:** Quand un Module a plusieurs sous-pages/onglets avec permissions différentes (ex: AI → Dashboard, Settings, Prompts)
|
|
842
|
-
|
|
843
|
-
### Permissions.cs - Section
|
|
844
|
-
|
|
845
|
-
```csharp
|
|
846
|
-
// src/SmartStack.Application/Common/Authorization/Permissions.cs
|
|
847
|
-
|
|
848
|
-
public static class Admin
|
|
849
|
-
{
|
|
850
|
-
public static class {Module}
|
|
851
|
-
{
|
|
852
|
-
// Section permissions (Level 4)
|
|
853
|
-
public static class {Section}
|
|
854
|
-
{
|
|
855
|
-
public const string View = "{context}.{application}.{module}.{section}.read";
|
|
856
|
-
public const string Create = "{context}.{application}.{module}.{section}.create";
|
|
857
|
-
public const string Update = "{context}.{application}.{module}.{section}.update";
|
|
858
|
-
public const string Delete = "{context}.{application}.{module}.{section}.delete";
|
|
859
|
-
public const string Execute = "{context}.{application}.{module}.{section}.execute";
|
|
860
|
-
}
|
|
861
|
-
}
|
|
862
|
-
}
|
|
863
|
-
```
|
|
864
|
-
|
|
865
|
-
### PermissionConfiguration.cs - Section Seed
|
|
866
|
-
|
|
867
|
-
```csharp
|
|
868
|
-
// src/SmartStack.Infrastructure/Persistence/Configurations/Navigation/PermissionConfiguration.cs
|
|
869
|
-
// AJOUTER dans la méthode Configure(), section HasData
|
|
870
|
-
|
|
871
|
-
// ============================================
|
|
872
|
-
// ÉTAPE 1: Déclarer le SectionId
|
|
873
|
-
// ============================================
|
|
874
|
-
// Récupérer depuis NavigationSectionConfiguration.cs
|
|
875
|
-
|
|
876
|
-
var {section}SectionId = Guid.Parse("{GUID-DE-LA-SECTION}");
|
|
877
|
-
|
|
878
|
-
// ============================================
|
|
879
|
-
// ÉTAPE 2: Ajouter les permissions Section (Level 4)
|
|
880
|
-
// ============================================
|
|
881
|
-
|
|
882
|
-
// Pattern: {context}.{application}.{module}.{section}.{action}
|
|
883
|
-
// Exemple: platform.administration.ai.settings.read
|
|
884
|
-
|
|
885
|
-
builder.HasData(
|
|
886
|
-
// Wildcard permission (accès complet à la section)
|
|
887
|
-
new
|
|
888
|
-
{
|
|
889
|
-
Id = Guid.Parse("{NOUVEAU-GUID-1}"),
|
|
890
|
-
Path = "{context}.{application}.{module}.{section}.*",
|
|
891
|
-
Level = PermissionLevel.Section,
|
|
892
|
-
Action = (PermissionAction?)null,
|
|
893
|
-
IsWildcard = true,
|
|
894
|
-
SectionId = {section}SectionId,
|
|
895
|
-
Description = "Full {section} access",
|
|
896
|
-
CreatedAt = seedDate
|
|
897
|
-
},
|
|
898
|
-
|
|
899
|
-
// Read permission
|
|
900
|
-
new
|
|
901
|
-
{
|
|
902
|
-
Id = Guid.Parse("{NOUVEAU-GUID-2}"),
|
|
903
|
-
Path = "{context}.{application}.{module}.{section}.read",
|
|
904
|
-
Level = PermissionLevel.Section,
|
|
905
|
-
Action = PermissionAction.Read,
|
|
906
|
-
IsWildcard = false,
|
|
907
|
-
SectionId = {section}SectionId,
|
|
908
|
-
Description = "View {section}",
|
|
909
|
-
CreatedAt = seedDate
|
|
910
|
-
},
|
|
911
|
-
|
|
912
|
-
// Create permission
|
|
913
|
-
new
|
|
914
|
-
{
|
|
915
|
-
Id = Guid.Parse("{NOUVEAU-GUID-3}"),
|
|
916
|
-
Path = "{context}.{application}.{module}.{section}.create",
|
|
917
|
-
Level = PermissionLevel.Section,
|
|
918
|
-
Action = PermissionAction.Create,
|
|
919
|
-
IsWildcard = false,
|
|
920
|
-
SectionId = {section}SectionId,
|
|
921
|
-
Description = "Create in {section}",
|
|
922
|
-
CreatedAt = seedDate
|
|
923
|
-
},
|
|
924
|
-
|
|
925
|
-
// Update permission
|
|
926
|
-
new
|
|
927
|
-
{
|
|
928
|
-
Id = Guid.Parse("{NOUVEAU-GUID-4}"),
|
|
929
|
-
Path = "{context}.{application}.{module}.{section}.update",
|
|
930
|
-
Level = PermissionLevel.Section,
|
|
931
|
-
Action = PermissionAction.Update,
|
|
932
|
-
IsWildcard = false,
|
|
933
|
-
SectionId = {section}SectionId,
|
|
934
|
-
Description = "Update in {section}",
|
|
935
|
-
CreatedAt = seedDate
|
|
936
|
-
},
|
|
937
|
-
|
|
938
|
-
// Delete permission
|
|
939
|
-
new
|
|
940
|
-
{
|
|
941
|
-
Id = Guid.Parse("{NOUVEAU-GUID-5}"),
|
|
942
|
-
Path = "{context}.{application}.{module}.{section}.delete",
|
|
943
|
-
Level = PermissionLevel.Section,
|
|
944
|
-
Action = PermissionAction.Delete,
|
|
945
|
-
IsWildcard = false,
|
|
946
|
-
SectionId = {section}SectionId,
|
|
947
|
-
Description = "Delete in {section}",
|
|
948
|
-
CreatedAt = seedDate
|
|
949
|
-
},
|
|
950
|
-
|
|
951
|
-
// Execute permission (optionnel)
|
|
952
|
-
new
|
|
953
|
-
{
|
|
954
|
-
Id = Guid.Parse("{NOUVEAU-GUID-6}"),
|
|
955
|
-
Path = "{context}.{application}.{module}.{section}.execute",
|
|
956
|
-
Level = PermissionLevel.Section,
|
|
957
|
-
Action = PermissionAction.Execute,
|
|
958
|
-
IsWildcard = false,
|
|
959
|
-
SectionId = {section}SectionId,
|
|
960
|
-
Description = "Execute actions in {section}",
|
|
961
|
-
CreatedAt = seedDate
|
|
962
|
-
}
|
|
963
|
-
);
|
|
964
|
-
```
|
|
965
|
-
|
|
966
|
-
---
|
|
967
|
-
|
|
968
|
-
## Template Resource-Level Permissions (Level 5)
|
|
969
|
-
|
|
970
|
-
> **Usage:** Pour le niveau de granularité le plus fin (ex: Prompts → Blocks, Users → Profiles)
|
|
971
|
-
> **CRITIQUE:** Utilisé quand une Section contient des sous-ressources avec permissions distinctes
|
|
972
|
-
|
|
973
|
-
### Permissions.cs - Resource
|
|
974
|
-
|
|
975
|
-
```csharp
|
|
976
|
-
// src/SmartStack.Application/Common/Authorization/Permissions.cs
|
|
977
|
-
|
|
978
|
-
public static class Admin
|
|
979
|
-
{
|
|
980
|
-
public static class {Module}
|
|
981
|
-
{
|
|
982
|
-
public static class {Section}
|
|
983
|
-
{
|
|
984
|
-
// Section-level permissions...
|
|
985
|
-
|
|
986
|
-
// Resource permissions (Level 5 - finest granularity)
|
|
987
|
-
public static class {Resource}
|
|
988
|
-
{
|
|
989
|
-
public const string View = "{context}.{application}.{module}.{section}.{resource}.read";
|
|
990
|
-
public const string Create = "{context}.{application}.{module}.{section}.{resource}.create";
|
|
991
|
-
public const string Update = "{context}.{application}.{module}.{section}.{resource}.update";
|
|
992
|
-
public const string Delete = "{context}.{application}.{module}.{section}.{resource}.delete";
|
|
993
|
-
}
|
|
994
|
-
}
|
|
995
|
-
}
|
|
996
|
-
}
|
|
997
|
-
```
|
|
998
|
-
|
|
999
|
-
### PermissionConfiguration.cs - Resource Seed
|
|
1000
|
-
|
|
1001
|
-
```csharp
|
|
1002
|
-
// src/SmartStack.Infrastructure/Persistence/Configurations/Navigation/PermissionConfiguration.cs
|
|
1003
|
-
// AJOUTER dans la méthode Configure(), section HasData
|
|
1004
|
-
|
|
1005
|
-
// ============================================
|
|
1006
|
-
// ÉTAPE 1: Déclarer le ResourceId
|
|
1007
|
-
// ============================================
|
|
1008
|
-
// Récupérer depuis NavigationResourceConfiguration.cs
|
|
1009
|
-
|
|
1010
|
-
var {resource}ResourceId = Guid.Parse("{GUID-DE-LA-RESOURCE}");
|
|
1011
|
-
|
|
1012
|
-
// ============================================
|
|
1013
|
-
// ÉTAPE 2: Ajouter les permissions Resource (Level 5)
|
|
1014
|
-
// ============================================
|
|
1015
|
-
|
|
1016
|
-
// Pattern: {context}.{application}.{module}.{section}.{resource}.{action}
|
|
1017
|
-
// Exemple: platform.administration.ai.prompts.blocks.read
|
|
1018
|
-
|
|
1019
|
-
builder.HasData(
|
|
1020
|
-
// Wildcard permission (accès complet à la resource)
|
|
1021
|
-
new
|
|
1022
|
-
{
|
|
1023
|
-
Id = Guid.Parse("{NOUVEAU-GUID-1}"),
|
|
1024
|
-
Path = "{context}.{application}.{module}.{section}.{resource}.*",
|
|
1025
|
-
Level = PermissionLevel.Resource,
|
|
1026
|
-
Action = (PermissionAction?)null,
|
|
1027
|
-
IsWildcard = true,
|
|
1028
|
-
ResourceId = {resource}ResourceId,
|
|
1029
|
-
Description = "Full {resource} access",
|
|
1030
|
-
CreatedAt = seedDate
|
|
1031
|
-
},
|
|
1032
|
-
|
|
1033
|
-
// Read permission
|
|
1034
|
-
new
|
|
1035
|
-
{
|
|
1036
|
-
Id = Guid.Parse("{NOUVEAU-GUID-2}"),
|
|
1037
|
-
Path = "{context}.{application}.{module}.{section}.{resource}.read",
|
|
1038
|
-
Level = PermissionLevel.Resource,
|
|
1039
|
-
Action = PermissionAction.Read,
|
|
1040
|
-
IsWildcard = false,
|
|
1041
|
-
ResourceId = {resource}ResourceId,
|
|
1042
|
-
Description = "View {resource}",
|
|
1043
|
-
CreatedAt = seedDate
|
|
1044
|
-
},
|
|
1045
|
-
|
|
1046
|
-
// Create permission
|
|
1047
|
-
new
|
|
1048
|
-
{
|
|
1049
|
-
Id = Guid.Parse("{NOUVEAU-GUID-3}"),
|
|
1050
|
-
Path = "{context}.{application}.{module}.{section}.{resource}.create",
|
|
1051
|
-
Level = PermissionLevel.Resource,
|
|
1052
|
-
Action = PermissionAction.Create,
|
|
1053
|
-
IsWildcard = false,
|
|
1054
|
-
ResourceId = {resource}ResourceId,
|
|
1055
|
-
Description = "Create {resource}",
|
|
1056
|
-
CreatedAt = seedDate
|
|
1057
|
-
},
|
|
1058
|
-
|
|
1059
|
-
// Update permission
|
|
1060
|
-
new
|
|
1061
|
-
{
|
|
1062
|
-
Id = Guid.Parse("{NOUVEAU-GUID-4}"),
|
|
1063
|
-
Path = "{context}.{application}.{module}.{section}.{resource}.update",
|
|
1064
|
-
Level = PermissionLevel.Resource,
|
|
1065
|
-
Action = PermissionAction.Update,
|
|
1066
|
-
IsWildcard = false,
|
|
1067
|
-
ResourceId = {resource}ResourceId,
|
|
1068
|
-
Description = "Update {resource}",
|
|
1069
|
-
CreatedAt = seedDate
|
|
1070
|
-
},
|
|
1071
|
-
|
|
1072
|
-
// Delete permission
|
|
1073
|
-
new
|
|
1074
|
-
{
|
|
1075
|
-
Id = Guid.Parse("{NOUVEAU-GUID-5}"),
|
|
1076
|
-
Path = "{context}.{application}.{module}.{section}.{resource}.delete",
|
|
1077
|
-
Level = PermissionLevel.Resource,
|
|
1078
|
-
Action = PermissionAction.Delete,
|
|
1079
|
-
IsWildcard = false,
|
|
1080
|
-
ResourceId = {resource}ResourceId,
|
|
1081
|
-
Description = "Delete {resource}",
|
|
1082
|
-
CreatedAt = seedDate
|
|
1083
|
-
}
|
|
1084
|
-
);
|
|
1085
|
-
```
|
|
1086
|
-
|
|
1087
|
-
---
|
|
1088
|
-
|
|
1089
|
-
## Template Bulk Operations (Insertion en Masse)
|
|
1090
|
-
|
|
1091
|
-
> **OBLIGATOIRE:** Toujours prévoir les endpoints bulk lors de la création d'un controller CRUD
|
|
1092
|
-
|
|
1093
|
-
### Permissions.cs - Bulk Operations
|
|
1094
|
-
|
|
1095
|
-
```csharp
|
|
1096
|
-
// src/SmartStack.Application/Common/Authorization/Permissions.cs
|
|
1097
|
-
|
|
1098
|
-
public static class {Module}
|
|
1099
|
-
{
|
|
1100
|
-
// CRUD standard
|
|
1101
|
-
public const string View = "{path}.read";
|
|
1102
|
-
public const string Create = "{path}.create";
|
|
1103
|
-
public const string Update = "{path}.update";
|
|
1104
|
-
public const string Delete = "{path}.delete";
|
|
1105
|
-
|
|
1106
|
-
// Bulk operations (OBLIGATOIRE pour tout module CRUD)
|
|
1107
|
-
public const string BulkCreate = "{path}.bulk-create";
|
|
1108
|
-
public const string BulkUpdate = "{path}.bulk-update";
|
|
1109
|
-
public const string BulkDelete = "{path}.bulk-delete";
|
|
1110
|
-
public const string Export = "{path}.export";
|
|
1111
|
-
public const string Import = "{path}.import";
|
|
1112
|
-
}
|
|
1113
|
-
```
|
|
1114
|
-
|
|
1115
|
-
### PermissionConfiguration.cs - Bulk Permissions Seed
|
|
1116
|
-
|
|
1117
|
-
```csharp
|
|
1118
|
-
// Ajouter après les permissions CRUD standard
|
|
1119
|
-
|
|
1120
|
-
// Bulk Create permission
|
|
1121
|
-
new
|
|
1122
|
-
{
|
|
1123
|
-
Id = Guid.Parse("{NOUVEAU-GUID-BULK-1}"),
|
|
1124
|
-
Path = "{context}.{application}.{module}.bulk-create",
|
|
1125
|
-
Level = PermissionLevel.Module,
|
|
1126
|
-
Action = PermissionAction.Create,
|
|
1127
|
-
IsWildcard = false,
|
|
1128
|
-
ModuleId = {module}ModuleId,
|
|
1129
|
-
Description = "Bulk create {module}",
|
|
1130
|
-
CreatedAt = seedDate
|
|
1131
|
-
},
|
|
1132
|
-
|
|
1133
|
-
// Bulk Update permission
|
|
1134
|
-
new
|
|
1135
|
-
{
|
|
1136
|
-
Id = Guid.Parse("{NOUVEAU-GUID-BULK-2}"),
|
|
1137
|
-
Path = "{context}.{application}.{module}.bulk-update",
|
|
1138
|
-
Level = PermissionLevel.Module,
|
|
1139
|
-
Action = PermissionAction.Update,
|
|
1140
|
-
IsWildcard = false,
|
|
1141
|
-
ModuleId = {module}ModuleId,
|
|
1142
|
-
Description = "Bulk update {module}",
|
|
1143
|
-
CreatedAt = seedDate
|
|
1144
|
-
},
|
|
1145
|
-
|
|
1146
|
-
// Bulk Delete permission
|
|
1147
|
-
new
|
|
1148
|
-
{
|
|
1149
|
-
Id = Guid.Parse("{NOUVEAU-GUID-BULK-3}"),
|
|
1150
|
-
Path = "{context}.{application}.{module}.bulk-delete",
|
|
1151
|
-
Level = PermissionLevel.Module,
|
|
1152
|
-
Action = PermissionAction.Delete,
|
|
1153
|
-
IsWildcard = false,
|
|
1154
|
-
ModuleId = {module}ModuleId,
|
|
1155
|
-
Description = "Bulk delete {module}",
|
|
1156
|
-
CreatedAt = seedDate
|
|
1157
|
-
},
|
|
1158
|
-
|
|
1159
|
-
// Export permission
|
|
1160
|
-
new
|
|
1161
|
-
{
|
|
1162
|
-
Id = Guid.Parse("{NOUVEAU-GUID-EXPORT}"),
|
|
1163
|
-
Path = "{context}.{application}.{module}.export",
|
|
1164
|
-
Level = PermissionLevel.Module,
|
|
1165
|
-
Action = PermissionAction.Execute,
|
|
1166
|
-
IsWildcard = false,
|
|
1167
|
-
ModuleId = {module}ModuleId,
|
|
1168
|
-
Description = "Export {module} data",
|
|
1169
|
-
CreatedAt = seedDate
|
|
1170
|
-
},
|
|
1171
|
-
|
|
1172
|
-
// Import permission
|
|
1173
|
-
new
|
|
1174
|
-
{
|
|
1175
|
-
Id = Guid.Parse("{NOUVEAU-GUID-IMPORT}"),
|
|
1176
|
-
Path = "{context}.{application}.{module}.import",
|
|
1177
|
-
Level = PermissionLevel.Module,
|
|
1178
|
-
Action = PermissionAction.Create,
|
|
1179
|
-
IsWildcard = false,
|
|
1180
|
-
ModuleId = {module}ModuleId,
|
|
1181
|
-
Description = "Import {module} data",
|
|
1182
|
-
CreatedAt = seedDate
|
|
1183
|
-
}
|
|
1184
|
-
```
|
|
1185
|
-
|
|
1186
|
-
### Controller Endpoints - Bulk Operations
|
|
1187
|
-
|
|
1188
|
-
```csharp
|
|
1189
|
-
// src/SmartStack.Api/Controllers/{Area}/{Module}Controller.cs
|
|
1190
|
-
// AJOUTER après les endpoints CRUD standard
|
|
1191
|
-
|
|
1192
|
-
#region BULK OPERATIONS
|
|
1193
|
-
|
|
1194
|
-
/// <summary>
|
|
1195
|
-
/// Bulk create multiple entities
|
|
1196
|
-
/// </summary>
|
|
1197
|
-
[HttpPost("bulk")]
|
|
1198
|
-
[RequirePermission(Permissions.{PermissionClass}.BulkCreate)]
|
|
1199
|
-
[ProducesResponseType(typeof(BulkOperationResult<{Entity}Dto>), StatusCodes.Status201Created)]
|
|
1200
|
-
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
1201
|
-
public async Task<ActionResult<BulkOperationResult<{Entity}Dto>>> BulkCreate{Entity}(
|
|
1202
|
-
[FromBody] List<Create{Entity}Request> requests,
|
|
1203
|
-
CancellationToken cancellationToken)
|
|
1204
|
-
{
|
|
1205
|
-
if (requests == null || requests.Count == 0)
|
|
1206
|
-
return BadRequest(new { message = "No items provided" });
|
|
1207
|
-
|
|
1208
|
-
if (requests.Count > 100)
|
|
1209
|
-
return BadRequest(new { message = "Maximum 100 items per bulk operation" });
|
|
1210
|
-
|
|
1211
|
-
var results = new List<{Entity}Dto>();
|
|
1212
|
-
var errors = new List<BulkOperationError>();
|
|
1213
|
-
|
|
1214
|
-
for (int i = 0; i < requests.Count; i++)
|
|
1215
|
-
{
|
|
1216
|
-
try
|
|
1217
|
-
{
|
|
1218
|
-
var entity = {Entity}.Create(
|
|
1219
|
-
requests[i].Name,
|
|
1220
|
-
requests[i].Description
|
|
1221
|
-
);
|
|
1222
|
-
|
|
1223
|
-
_context.{DbSet}.Add(entity);
|
|
1224
|
-
results.Add(new {Entity}Dto(entity.Id, entity.Name));
|
|
1225
|
-
}
|
|
1226
|
-
catch (Exception ex)
|
|
1227
|
-
{
|
|
1228
|
-
errors.Add(new BulkOperationError(i, requests[i].Name, ex.Message));
|
|
1229
|
-
}
|
|
1230
|
-
}
|
|
1231
|
-
|
|
1232
|
-
await _context.SaveChangesAsync(cancellationToken);
|
|
1233
|
-
|
|
1234
|
-
_logger.LogInformation("User {User} bulk created {Count} {Entity}(s), {Errors} error(s)",
|
|
1235
|
-
_currentUser.Email, results.Count, errors.Count);
|
|
1236
|
-
|
|
1237
|
-
return CreatedAtAction(
|
|
1238
|
-
nameof(Get{Module}),
|
|
1239
|
-
new BulkOperationResult<{Entity}Dto>(results, errors, results.Count, errors.Count));
|
|
1240
|
-
}
|
|
1241
|
-
|
|
1242
|
-
/// <summary>
|
|
1243
|
-
/// Bulk update multiple entities
|
|
1244
|
-
/// </summary>
|
|
1245
|
-
[HttpPut("bulk")]
|
|
1246
|
-
[RequirePermission(Permissions.{PermissionClass}.BulkUpdate)]
|
|
1247
|
-
[ProducesResponseType(typeof(BulkOperationResult), StatusCodes.Status200OK)]
|
|
1248
|
-
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
1249
|
-
public async Task<ActionResult<BulkOperationResult>> BulkUpdate{Entity}(
|
|
1250
|
-
[FromBody] List<BulkUpdate{Entity}Request> requests,
|
|
1251
|
-
CancellationToken cancellationToken)
|
|
1252
|
-
{
|
|
1253
|
-
if (requests == null || requests.Count == 0)
|
|
1254
|
-
return BadRequest(new { message = "No items provided" });
|
|
1255
|
-
|
|
1256
|
-
if (requests.Count > 100)
|
|
1257
|
-
return BadRequest(new { message = "Maximum 100 items per bulk operation" });
|
|
1258
|
-
|
|
1259
|
-
var ids = requests.Select(r => r.Id).ToList();
|
|
1260
|
-
var entities = await _context.{DbSet}
|
|
1261
|
-
.Where(x => ids.Contains(x.Id))
|
|
1262
|
-
.ToDictionaryAsync(x => x.Id, cancellationToken);
|
|
1263
|
-
|
|
1264
|
-
var updated = 0;
|
|
1265
|
-
var errors = new List<BulkOperationError>();
|
|
1266
|
-
|
|
1267
|
-
for (int i = 0; i < requests.Count; i++)
|
|
1268
|
-
{
|
|
1269
|
-
if (!entities.TryGetValue(requests[i].Id, out var entity))
|
|
1270
|
-
{
|
|
1271
|
-
errors.Add(new BulkOperationError(i, requests[i].Id.ToString(), "Entity not found"));
|
|
1272
|
-
continue;
|
|
1273
|
-
}
|
|
1274
|
-
|
|
1275
|
-
try
|
|
1276
|
-
{
|
|
1277
|
-
entity.Update(
|
|
1278
|
-
requests[i].Name ?? entity.Name,
|
|
1279
|
-
requests[i].Description ?? entity.Description
|
|
1280
|
-
);
|
|
1281
|
-
updated++;
|
|
1282
|
-
}
|
|
1283
|
-
catch (Exception ex)
|
|
1284
|
-
{
|
|
1285
|
-
errors.Add(new BulkOperationError(i, requests[i].Id.ToString(), ex.Message));
|
|
1286
|
-
}
|
|
1287
|
-
}
|
|
1288
|
-
|
|
1289
|
-
await _context.SaveChangesAsync(cancellationToken);
|
|
1290
|
-
|
|
1291
|
-
_logger.LogInformation("User {User} bulk updated {Count} {Entity}(s), {Errors} error(s)",
|
|
1292
|
-
_currentUser.Email, updated, errors.Count);
|
|
1293
|
-
|
|
1294
|
-
return Ok(new BulkOperationResult(updated, errors.Count, errors));
|
|
1295
|
-
}
|
|
1296
|
-
|
|
1297
|
-
/// <summary>
|
|
1298
|
-
/// Bulk delete multiple entities by IDs
|
|
1299
|
-
/// </summary>
|
|
1300
|
-
[HttpDelete("bulk")]
|
|
1301
|
-
[RequirePermission(Permissions.{PermissionClass}.BulkDelete)]
|
|
1302
|
-
[ProducesResponseType(typeof(BulkOperationResult), StatusCodes.Status200OK)]
|
|
1303
|
-
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
1304
|
-
public async Task<ActionResult<BulkOperationResult>> BulkDelete{Entity}(
|
|
1305
|
-
[FromBody] List<Guid> ids,
|
|
1306
|
-
CancellationToken cancellationToken)
|
|
1307
|
-
{
|
|
1308
|
-
if (ids == null || ids.Count == 0)
|
|
1309
|
-
return BadRequest(new { message = "No IDs provided" });
|
|
1310
|
-
|
|
1311
|
-
if (ids.Count > 100)
|
|
1312
|
-
return BadRequest(new { message = "Maximum 100 items per bulk operation" });
|
|
1313
|
-
|
|
1314
|
-
var entities = await _context.{DbSet}
|
|
1315
|
-
.Where(x => ids.Contains(x.Id))
|
|
1316
|
-
.ToListAsync(cancellationToken);
|
|
1317
|
-
|
|
1318
|
-
var deleted = entities.Count;
|
|
1319
|
-
var notFound = ids.Count - deleted;
|
|
1320
|
-
|
|
1321
|
-
_context.{DbSet}.RemoveRange(entities);
|
|
1322
|
-
await _context.SaveChangesAsync(cancellationToken);
|
|
1323
|
-
|
|
1324
|
-
_logger.LogWarning("User {User} bulk deleted {Count} {Entity}(s), {NotFound} not found",
|
|
1325
|
-
_currentUser.Email, deleted, notFound);
|
|
1326
|
-
|
|
1327
|
-
var errors = notFound > 0
|
|
1328
|
-
? new List<BulkOperationError> { new(-1, "N/A", $"{notFound} entities not found") }
|
|
1329
|
-
: new List<BulkOperationError>();
|
|
1330
|
-
|
|
1331
|
-
return Ok(new BulkOperationResult(deleted, errors.Count, errors));
|
|
1332
|
-
}
|
|
1333
|
-
|
|
1334
|
-
/// <summary>
|
|
1335
|
-
/// Export entities to CSV/Excel
|
|
1336
|
-
/// </summary>
|
|
1337
|
-
[HttpGet("export")]
|
|
1338
|
-
[RequirePermission(Permissions.{PermissionClass}.Export)]
|
|
1339
|
-
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
|
1340
|
-
public async Task<IActionResult> Export{Module}(
|
|
1341
|
-
[FromQuery] string format = "csv",
|
|
1342
|
-
[FromQuery] string? search = null,
|
|
1343
|
-
CancellationToken cancellationToken = default)
|
|
1344
|
-
{
|
|
1345
|
-
var query = _context.{DbSet}.AsQueryable();
|
|
1346
|
-
|
|
1347
|
-
if (!string.IsNullOrWhiteSpace(search))
|
|
1348
|
-
{
|
|
1349
|
-
var searchLower = search.ToLower();
|
|
1350
|
-
query = query.Where(x => x.Name.ToLower().Contains(searchLower));
|
|
1351
|
-
}
|
|
1352
|
-
|
|
1353
|
-
var entities = await query.ToListAsync(cancellationToken);
|
|
1354
|
-
|
|
1355
|
-
_logger.LogInformation("User {User} exported {Count} {Entity}(s) to {Format}",
|
|
1356
|
-
_currentUser.Email, entities.Count, format);
|
|
1357
|
-
|
|
1358
|
-
// Implement CSV/Excel export logic here
|
|
1359
|
-
// Using libraries like CsvHelper or ClosedXML
|
|
1360
|
-
|
|
1361
|
-
var content = format.ToLower() switch
|
|
1362
|
-
{
|
|
1363
|
-
"xlsx" => GenerateExcel(entities),
|
|
1364
|
-
_ => GenerateCsv(entities)
|
|
1365
|
-
};
|
|
1366
|
-
|
|
1367
|
-
var contentType = format.ToLower() == "xlsx"
|
|
1368
|
-
? "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
1369
|
-
: "text/csv";
|
|
1370
|
-
|
|
1371
|
-
var fileName = $"{module}-export-{DateTime.UtcNow:yyyyMMdd-HHmmss}.{format}";
|
|
1372
|
-
|
|
1373
|
-
return File(content, contentType, fileName);
|
|
1374
|
-
}
|
|
1375
|
-
|
|
1376
|
-
#endregion
|
|
1377
|
-
|
|
1378
|
-
#region Bulk DTOs
|
|
1379
|
-
|
|
1380
|
-
public record BulkOperationResult(
|
|
1381
|
-
int SuccessCount,
|
|
1382
|
-
int ErrorCount,
|
|
1383
|
-
List<BulkOperationError> Errors);
|
|
1384
|
-
|
|
1385
|
-
public record BulkOperationResult<T>(
|
|
1386
|
-
List<T> Created,
|
|
1387
|
-
List<BulkOperationError> Errors,
|
|
1388
|
-
int SuccessCount,
|
|
1389
|
-
int ErrorCount);
|
|
1390
|
-
|
|
1391
|
-
public record BulkOperationError(
|
|
1392
|
-
int Index,
|
|
1393
|
-
string Identifier,
|
|
1394
|
-
string Message);
|
|
1395
|
-
|
|
1396
|
-
public record BulkUpdate{Entity}Request(
|
|
1397
|
-
Guid Id,
|
|
1398
|
-
string? Name,
|
|
1399
|
-
string? Description);
|
|
1400
|
-
|
|
1401
|
-
#endregion
|
|
1402
|
-
```
|
|
1403
|
-
|
|
1404
|
-
---
|
|
1405
|
-
|
|
1406
|
-
## Hiérarchie Complète des Permissions
|
|
1407
|
-
|
|
1408
|
-
```
|
|
1409
|
-
┌─────────────────────────────────────────────────────────────────────────────────┐
|
|
1410
|
-
│ HIÉRARCHIE COMPLÈTE DES PERMISSIONS │
|
|
1411
|
-
├─────────────────────────────────────────────────────────────────────────────────┤
|
|
1412
|
-
│ │
|
|
1413
|
-
│ Level 1: CONTEXT │
|
|
1414
|
-
│ └─ Path: {context}.* │
|
|
1415
|
-
│ └─ Ex: platform.* → Accès complet au context platform │
|
|
1416
|
-
│ │
|
|
1417
|
-
│ Level 2: APPLICATION │
|
|
1418
|
-
│ └─ Path: {context}.{application}.* │
|
|
1419
|
-
│ └─ Ex: platform.administration.* → Accès complet à l'administration │
|
|
1420
|
-
│ │
|
|
1421
|
-
│ Level 3: MODULE │
|
|
1422
|
-
│ └─ Path: {context}.{application}.{module}.{action} │
|
|
1423
|
-
│ └─ Ex: platform.administration.users.read → Lecture des users │
|
|
1424
|
-
│ └─ BULK: platform.administration.users.bulk-create → Création en masse │
|
|
1425
|
-
│ │
|
|
1426
|
-
│ Level 4: SECTION │
|
|
1427
|
-
│ └─ Path: {context}.{application}.{module}.{section}.{action} │
|
|
1428
|
-
│ └─ Ex: platform.administration.ai.settings.update → MAJ paramètres IA │
|
|
1429
|
-
│ │
|
|
1430
|
-
│ Level 5: RESOURCE (finest granularity) │
|
|
1431
|
-
│ └─ Path: {context}.{application}.{module}.{section}.{resource}.{action} │
|
|
1432
|
-
│ └─ Ex: platform.administration.ai.prompts.blocks.delete → Suppr. blocs │
|
|
1433
|
-
│ │
|
|
1434
|
-
└─────────────────────────────────────────────────────────────────────────────────┘
|
|
1435
|
-
```
|
|
1436
|
-
|
|
1437
|
-
---
|
|
1438
|
-
|
|
1439
|
-
## Checklist Controller avec Permissions Complètes
|
|
1440
|
-
|
|
1441
|
-
```
|
|
1442
|
-
□ CRUD Standard
|
|
1443
|
-
□ GET /api/.../ → {path}.read
|
|
1444
|
-
□ GET /api/.../{id} → {path}.read
|
|
1445
|
-
□ POST /api/.../ → {path}.create
|
|
1446
|
-
□ PUT /api/.../{id} → {path}.update
|
|
1447
|
-
□ DELETE /api/.../{id} → {path}.delete
|
|
1448
|
-
|
|
1449
|
-
□ Bulk Operations
|
|
1450
|
-
□ POST /api/.../bulk → {path}.bulk-create
|
|
1451
|
-
□ PUT /api/.../bulk → {path}.bulk-update
|
|
1452
|
-
□ DELETE /api/.../bulk → {path}.bulk-delete
|
|
1453
|
-
|
|
1454
|
-
□ Export/Import
|
|
1455
|
-
□ GET /api/.../export → {path}.export
|
|
1456
|
-
□ POST /api/.../import → {path}.import
|
|
1457
|
-
|
|
1458
|
-
□ Permissions Configurées
|
|
1459
|
-
□ Permissions.cs - Constantes définies
|
|
1460
|
-
□ PermissionConfiguration.cs - Seed HasData
|
|
1461
|
-
□ Migration EF Core créée
|
|
1462
|
-
□ Migration appliquée
|
|
1463
|
-
|
|
1464
|
-
□ Niveau Permission Correct
|
|
1465
|
-
□ Module (Level 3) - Pour CRUD principal
|
|
1466
|
-
□ Section (Level 4) - Si sous-pages avec perms différentes
|
|
1467
|
-
□ Resource (Level 5) - Si sous-ressources avec perms distinctes
|
|
1468
|
-
```
|
|
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 Auth Controller (Login/Logout)
|
|
341
|
+
|
|
342
|
+
```csharp
|
|
343
|
+
// src/SmartStack.Api/Controllers/AuthController.cs
|
|
344
|
+
// NOTE: Ce controller existe déjà - utiliser comme référence pour patterns auth
|
|
345
|
+
|
|
346
|
+
using Microsoft.AspNetCore.Authorization;
|
|
347
|
+
using Microsoft.AspNetCore.Mvc;
|
|
348
|
+
using Microsoft.EntityFrameworkCore;
|
|
349
|
+
using Microsoft.Extensions.Options;
|
|
350
|
+
using SmartStack.Application.Common.Interfaces;
|
|
351
|
+
using SmartStack.Application.Common.Settings;
|
|
352
|
+
using SmartStack.Domain.Platform.Administration.Users;
|
|
353
|
+
|
|
354
|
+
namespace SmartStack.Api.Controllers;
|
|
355
|
+
|
|
356
|
+
[ApiController]
|
|
357
|
+
[Route("api/[controller]")]
|
|
358
|
+
public class AuthController : ControllerBase
|
|
359
|
+
{
|
|
360
|
+
private readonly IApplicationDbContext _context;
|
|
361
|
+
private readonly IPasswordService _passwordService;
|
|
362
|
+
private readonly IJwtService _jwtService;
|
|
363
|
+
private readonly IUserSessionService _sessionService;
|
|
364
|
+
private readonly ISessionValidationService _sessionValidationService;
|
|
365
|
+
private readonly SessionSettings _sessionSettings;
|
|
366
|
+
private readonly ILogger<AuthController> _logger;
|
|
367
|
+
|
|
368
|
+
// ... Constructor avec tous les services auth
|
|
369
|
+
|
|
370
|
+
#region Login - LOGS CRITIQUES OBLIGATOIRES
|
|
371
|
+
|
|
372
|
+
[HttpPost("login")]
|
|
373
|
+
[AllowAnonymous]
|
|
374
|
+
[ProducesResponseType(typeof(LoginResponse), StatusCodes.Status200OK)]
|
|
375
|
+
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status401Unauthorized)]
|
|
376
|
+
public async Task<ActionResult<LoginResponse>> Login(
|
|
377
|
+
[FromBody] LoginRequest request,
|
|
378
|
+
CancellationToken cancellationToken)
|
|
379
|
+
{
|
|
380
|
+
var ipAddress = GetClientIpAddress();
|
|
381
|
+
var userAgent = Request.Headers.UserAgent.ToString();
|
|
382
|
+
|
|
383
|
+
var user = await _context.Users
|
|
384
|
+
.Include(u => u.UserRoles)
|
|
385
|
+
.ThenInclude(ur => ur.Role)
|
|
386
|
+
.ThenInclude(r => r!.RolePermissions)
|
|
387
|
+
.ThenInclude(rp => rp.Permission)
|
|
388
|
+
.FirstOrDefaultAsync(u => u.Email == request.Email, cancellationToken);
|
|
389
|
+
|
|
390
|
+
// ============================================
|
|
391
|
+
// LOGS CRITIQUES - NE JAMAIS OMETTRE
|
|
392
|
+
// ============================================
|
|
393
|
+
|
|
394
|
+
if (user == null)
|
|
395
|
+
{
|
|
396
|
+
// WARNING: User not found (potential enumeration)
|
|
397
|
+
_logger.LogWarning(
|
|
398
|
+
"Login failed: User not found - {Email} from {IpAddress}",
|
|
399
|
+
request.Email, ipAddress);
|
|
400
|
+
return Unauthorized(new ErrorResponse("Identifiants invalides", "INVALID_CREDENTIALS"));
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (!user.IsActive)
|
|
404
|
+
{
|
|
405
|
+
// WARNING: Disabled account
|
|
406
|
+
_logger.LogWarning(
|
|
407
|
+
"Login failed: Account disabled - {Email} from {IpAddress}",
|
|
408
|
+
request.Email, ipAddress);
|
|
409
|
+
return Unauthorized(new ErrorResponse("Compte désactivé", "ACCOUNT_DISABLED"));
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (user.IsLocked)
|
|
413
|
+
{
|
|
414
|
+
// CRITICAL: Locked account attempt
|
|
415
|
+
_logger.LogCritical(
|
|
416
|
+
"SECURITY: Login attempt on locked account - User: {Email} (ID: {UserId}) from {IpAddress}",
|
|
417
|
+
request.Email, user.Id, ipAddress);
|
|
418
|
+
return Unauthorized(new ErrorResponse("Compte verrouillé", "ACCOUNT_LOCKED_BY_ADMIN"));
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Check brute force protection
|
|
422
|
+
var recentFailedAttempts = await _context.UserSessions
|
|
423
|
+
.Where(s => s.UserId == user.Id && !s.IsSuccessful && s.LoginAt > DateTime.UtcNow.AddMinutes(-15))
|
|
424
|
+
.CountAsync(cancellationToken);
|
|
425
|
+
|
|
426
|
+
if (recentFailedAttempts >= 5)
|
|
427
|
+
{
|
|
428
|
+
// CRITICAL: Too many failed attempts
|
|
429
|
+
_logger.LogCritical(
|
|
430
|
+
"SECURITY: Account temporarily locked due to brute force - User: {Email} (ID: {UserId}) from {IpAddress}",
|
|
431
|
+
request.Email, user.Id, ipAddress);
|
|
432
|
+
return Unauthorized(new ErrorResponse("Compte temporairement verrouillé", "ACCOUNT_LOCKED"));
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
|
|
436
|
+
{
|
|
437
|
+
// WARNING: Invalid password with remaining attempts
|
|
438
|
+
var remainingAttempts = 5 - recentFailedAttempts - 1;
|
|
439
|
+
_logger.LogWarning(
|
|
440
|
+
"Login failed: Invalid password - {Email} from {IpAddress}, remaining attempts: {Remaining}",
|
|
441
|
+
request.Email, ipAddress, remainingAttempts);
|
|
442
|
+
|
|
443
|
+
// Log failed attempt to session
|
|
444
|
+
await _sessionService.LogFailedLoginAsync(user.Id, ipAddress, userAgent, cancellationToken);
|
|
445
|
+
|
|
446
|
+
return Unauthorized(new ErrorResponse("Mot de passe incorrect", "INVALID_PASSWORD"));
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// ============================================
|
|
450
|
+
// SUCCESS - Generate tokens
|
|
451
|
+
// ============================================
|
|
452
|
+
|
|
453
|
+
var roles = user.UserRoles.Select(ur => ur.Role!.Name).ToList();
|
|
454
|
+
var permissions = user.UserRoles
|
|
455
|
+
.SelectMany(ur => ur.Role!.RolePermissions)
|
|
456
|
+
.Select(rp => rp.Permission!.Path)
|
|
457
|
+
.Distinct()
|
|
458
|
+
.ToList();
|
|
459
|
+
|
|
460
|
+
var accessToken = _jwtService.GenerateAccessToken(user, roles, permissions);
|
|
461
|
+
var refreshToken = _jwtService.GenerateRefreshToken();
|
|
462
|
+
|
|
463
|
+
await _sessionService.LogLoginAsync(user.Id, accessToken, ipAddress, userAgent, cancellationToken: cancellationToken);
|
|
464
|
+
|
|
465
|
+
// INFO: Successful login
|
|
466
|
+
_logger.LogInformation(
|
|
467
|
+
"User logged in successfully: {Email} from {IpAddress}",
|
|
468
|
+
user.Email, ipAddress);
|
|
469
|
+
|
|
470
|
+
return Ok(new LoginResponse(accessToken, refreshToken, /* UserInfo */));
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
#endregion
|
|
474
|
+
|
|
475
|
+
#region Logout
|
|
476
|
+
|
|
477
|
+
[HttpPost("logout")]
|
|
478
|
+
[Authorize]
|
|
479
|
+
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
|
480
|
+
public async Task<IActionResult> Logout(CancellationToken cancellationToken)
|
|
481
|
+
{
|
|
482
|
+
var token = Request.Headers.Authorization.ToString().Replace("Bearer ", "");
|
|
483
|
+
await _sessionService.LogLogoutAsync(token, cancellationToken);
|
|
484
|
+
|
|
485
|
+
_logger.LogInformation("User logged out: {UserId}", User.FindFirst("sub")?.Value);
|
|
486
|
+
|
|
487
|
+
return NoContent();
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
#endregion
|
|
491
|
+
|
|
492
|
+
#region Change Password - LOG WARNING
|
|
493
|
+
|
|
494
|
+
[HttpPost("change-password")]
|
|
495
|
+
[Authorize]
|
|
496
|
+
[ProducesResponseType(typeof(ChangePasswordResponse), StatusCodes.Status200OK)]
|
|
497
|
+
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)]
|
|
498
|
+
public async Task<ActionResult<ChangePasswordResponse>> ChangePassword(
|
|
499
|
+
[FromBody] ChangePasswordRequest request,
|
|
500
|
+
CancellationToken cancellationToken)
|
|
501
|
+
{
|
|
502
|
+
// ... validation logic
|
|
503
|
+
|
|
504
|
+
user.UpdatePassword(newPasswordHash);
|
|
505
|
+
await _context.SaveChangesAsync(cancellationToken);
|
|
506
|
+
|
|
507
|
+
// Invalidate ALL sessions after password change
|
|
508
|
+
await _sessionService.InvalidateAllUserSessionsAsync(userId, "Password changed", cancellationToken);
|
|
509
|
+
|
|
510
|
+
// WARNING: Sensitive operation
|
|
511
|
+
_logger.LogWarning(
|
|
512
|
+
"Password changed for user {Email} - All sessions invalidated",
|
|
513
|
+
user.Email);
|
|
514
|
+
|
|
515
|
+
return Ok(new ChangePasswordResponse("Mot de passe modifié", true));
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
#endregion
|
|
519
|
+
|
|
520
|
+
private string GetClientIpAddress()
|
|
521
|
+
{
|
|
522
|
+
var forwardedFor = Request.Headers["X-Forwarded-For"].FirstOrDefault();
|
|
523
|
+
if (!string.IsNullOrEmpty(forwardedFor))
|
|
524
|
+
return forwardedFor.Split(',')[0].Trim();
|
|
525
|
+
return HttpContext.Connection.RemoteIpAddress?.ToString() ?? "Unknown";
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
---
|
|
531
|
+
|
|
532
|
+
## Template Permissions Constants
|
|
533
|
+
|
|
534
|
+
```csharp
|
|
535
|
+
// src/SmartStack.Application/Common/Authorization/Permissions.cs
|
|
536
|
+
// AJOUTER dans la classe existante
|
|
537
|
+
|
|
538
|
+
public static class Permissions
|
|
539
|
+
{
|
|
540
|
+
// ... existing permissions ...
|
|
541
|
+
|
|
542
|
+
public static class {PermissionClass}
|
|
543
|
+
{
|
|
544
|
+
public const string Access = "{permission.path}";
|
|
545
|
+
public const string View = "{permission.path}.read";
|
|
546
|
+
public const string Create = "{permission.path}.create";
|
|
547
|
+
public const string Update = "{permission.path}.update";
|
|
548
|
+
public const string Delete = "{permission.path}.delete";
|
|
549
|
+
// Optionnel selon module
|
|
550
|
+
public const string Assign = "{permission.path}.assign";
|
|
551
|
+
public const string Execute = "{permission.path}.execute";
|
|
552
|
+
public const string Export = "{permission.path}.export";
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
---
|
|
558
|
+
|
|
559
|
+
## Template PermissionConfiguration Seed
|
|
560
|
+
|
|
561
|
+
> **CRITIQUE:** Ce template est OBLIGATOIRE. Sans ces entrées, tous les appels API retourneront 403 Forbidden.
|
|
562
|
+
|
|
563
|
+
```csharp
|
|
564
|
+
// src/SmartStack.Infrastructure/Persistence/Configurations/Navigation/PermissionConfiguration.cs
|
|
565
|
+
// AJOUTER dans la méthode Configure(), section HasData
|
|
566
|
+
|
|
567
|
+
// ============================================
|
|
568
|
+
// ÉTAPE 1: Déclarer le ModuleId
|
|
569
|
+
// ============================================
|
|
570
|
+
// Vérifier dans ModuleConfiguration.cs si le module existe déjà
|
|
571
|
+
// Sinon, créer le module d'abord via /application skill
|
|
572
|
+
|
|
573
|
+
var {module}ModuleId = Guid.Parse("{GUID-DU-MODULE}"); // Récupérer depuis ModuleConfiguration.cs
|
|
574
|
+
|
|
575
|
+
// ============================================
|
|
576
|
+
// ÉTAPE 2: Ajouter les permissions (HasData)
|
|
577
|
+
// ============================================
|
|
578
|
+
|
|
579
|
+
// Pattern: {context}.{application}.{module}.{action}
|
|
580
|
+
// Exemple: platform.administration.users.read
|
|
581
|
+
|
|
582
|
+
var seedDate = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
|
583
|
+
|
|
584
|
+
builder.HasData(
|
|
585
|
+
// Wildcard permission (accès complet au module)
|
|
586
|
+
new
|
|
587
|
+
{
|
|
588
|
+
Id = Guid.Parse("{NOUVEAU-GUID-1}"),
|
|
589
|
+
Path = "{context}.{application}.{module}.*",
|
|
590
|
+
Level = PermissionLevel.Module,
|
|
591
|
+
Action = (PermissionAction?)null,
|
|
592
|
+
IsWildcard = true,
|
|
593
|
+
ModuleId = {module}ModuleId,
|
|
594
|
+
Description = "Full {module} management",
|
|
595
|
+
CreatedAt = seedDate
|
|
596
|
+
},
|
|
597
|
+
|
|
598
|
+
// Read permission
|
|
599
|
+
new
|
|
600
|
+
{
|
|
601
|
+
Id = Guid.Parse("{NOUVEAU-GUID-2}"),
|
|
602
|
+
Path = "{context}.{application}.{module}.read",
|
|
603
|
+
Level = PermissionLevel.Module,
|
|
604
|
+
Action = PermissionAction.Read,
|
|
605
|
+
IsWildcard = false,
|
|
606
|
+
ModuleId = {module}ModuleId,
|
|
607
|
+
Description = "View {module}",
|
|
608
|
+
CreatedAt = seedDate
|
|
609
|
+
},
|
|
610
|
+
|
|
611
|
+
// Create permission
|
|
612
|
+
new
|
|
613
|
+
{
|
|
614
|
+
Id = Guid.Parse("{NOUVEAU-GUID-3}"),
|
|
615
|
+
Path = "{context}.{application}.{module}.create",
|
|
616
|
+
Level = PermissionLevel.Module,
|
|
617
|
+
Action = PermissionAction.Create,
|
|
618
|
+
IsWildcard = false,
|
|
619
|
+
ModuleId = {module}ModuleId,
|
|
620
|
+
Description = "Create {module}",
|
|
621
|
+
CreatedAt = seedDate
|
|
622
|
+
},
|
|
623
|
+
|
|
624
|
+
// Update permission
|
|
625
|
+
new
|
|
626
|
+
{
|
|
627
|
+
Id = Guid.Parse("{NOUVEAU-GUID-4}"),
|
|
628
|
+
Path = "{context}.{application}.{module}.update",
|
|
629
|
+
Level = PermissionLevel.Module,
|
|
630
|
+
Action = PermissionAction.Update,
|
|
631
|
+
IsWildcard = false,
|
|
632
|
+
ModuleId = {module}ModuleId,
|
|
633
|
+
Description = "Update {module}",
|
|
634
|
+
CreatedAt = seedDate
|
|
635
|
+
},
|
|
636
|
+
|
|
637
|
+
// Delete permission
|
|
638
|
+
new
|
|
639
|
+
{
|
|
640
|
+
Id = Guid.Parse("{NOUVEAU-GUID-5}"),
|
|
641
|
+
Path = "{context}.{application}.{module}.delete",
|
|
642
|
+
Level = PermissionLevel.Module,
|
|
643
|
+
Action = PermissionAction.Delete,
|
|
644
|
+
IsWildcard = false,
|
|
645
|
+
ModuleId = {module}ModuleId,
|
|
646
|
+
Description = "Delete {module}",
|
|
647
|
+
CreatedAt = seedDate
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// Actions optionnelles selon le module:
|
|
651
|
+
// - PermissionAction.Assign → Pour assigner des ressources/rôles
|
|
652
|
+
// - PermissionAction.Execute → Pour exécuter des actions (export, etc.)
|
|
653
|
+
);
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
### Génération de GUIDs
|
|
657
|
+
|
|
658
|
+
```bash
|
|
659
|
+
# PowerShell (Windows)
|
|
660
|
+
[guid]::NewGuid().ToString()
|
|
661
|
+
|
|
662
|
+
# Bash (Linux/Mac)
|
|
663
|
+
uuidgen | tr '[:upper:]' '[:lower:]'
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
### Validation Cohérence Permissions.cs ↔ PermissionConfiguration.cs
|
|
667
|
+
|
|
668
|
+
> **RÈGLE:** Chaque constante dans `Permissions.cs` DOIT avoir une entrée correspondante dans `PermissionConfiguration.cs`
|
|
669
|
+
|
|
670
|
+
```
|
|
671
|
+
┌─────────────────────────────────────────────────────────────────────────────┐
|
|
672
|
+
│ VALIDATION DE COHÉRENCE │
|
|
673
|
+
├─────────────────────────────────────────────────────────────────────────────┤
|
|
674
|
+
│ │
|
|
675
|
+
│ Permissions.cs PermissionConfiguration.cs │
|
|
676
|
+
│ ────────────────────────────── ────────────────────────────────────── │
|
|
677
|
+
│ Permissions.Support.Tickets.View → Path = "platform.support.tickets.read"│
|
|
678
|
+
│ Permissions.Support.Tickets.Create→ Path = "platform.support.tickets.create"│
|
|
679
|
+
│ Permissions.Support.Tickets.Update→ Path = "platform.support.tickets.update"│
|
|
680
|
+
│ Permissions.Support.Tickets.Delete→ Path = "platform.support.tickets.delete"│
|
|
681
|
+
│ │
|
|
682
|
+
│ ⚠️ ERREUR FRÉQUENTE: │
|
|
683
|
+
│ - Permissions.cs: "platform.support.tickets.read" │
|
|
684
|
+
│ - PermissionConfiguration.cs: MANQUANT │
|
|
685
|
+
│ → Résultat: 403 Forbidden pour TOUS les utilisateurs │
|
|
686
|
+
│ │
|
|
687
|
+
└─────────────────────────────────────────────────────────────────────────────┘
|
|
688
|
+
```
|
|
689
|
+
|
|
690
|
+
### Commande post-génération
|
|
691
|
+
|
|
692
|
+
Après avoir ajouté les entrées dans les deux fichiers:
|
|
693
|
+
|
|
694
|
+
```bash
|
|
695
|
+
# 1. Créer la migration
|
|
696
|
+
/efcore:migration Add{Module}Permissions
|
|
697
|
+
|
|
698
|
+
# 2. Appliquer la migration
|
|
699
|
+
/efcore:db-deploy
|
|
700
|
+
|
|
701
|
+
# 3. Vérifier (optionnel)
|
|
702
|
+
/efcore:db-status
|
|
703
|
+
```
|
|
704
|
+
|
|
705
|
+
---
|
|
706
|
+
|
|
707
|
+
## Template Controller avec Relations
|
|
708
|
+
|
|
709
|
+
```csharp
|
|
710
|
+
// Pour les controllers avec entités liées (ex: Tickets avec Comments)
|
|
711
|
+
|
|
712
|
+
#region GET with Includes
|
|
713
|
+
|
|
714
|
+
[HttpGet("{id:guid}")]
|
|
715
|
+
[RequirePermission(Permissions.{PermissionClass}.View)]
|
|
716
|
+
[ProducesResponseType(typeof({Entity}DetailDto), StatusCodes.Status200OK)]
|
|
717
|
+
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
718
|
+
public async Task<ActionResult<{Entity}DetailDto>> Get{Entity}(
|
|
719
|
+
Guid id,
|
|
720
|
+
CancellationToken cancellationToken)
|
|
721
|
+
{
|
|
722
|
+
var entity = await _context.{DbSet}
|
|
723
|
+
.Include(x => x.CreatedByUser)
|
|
724
|
+
.Include(x => x.AssignedToUser)
|
|
725
|
+
.Include(x => x.Comments)
|
|
726
|
+
.ThenInclude(c => c.Author)
|
|
727
|
+
.Include(x => x.Attachments)
|
|
728
|
+
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
|
729
|
+
|
|
730
|
+
if (entity == null)
|
|
731
|
+
return NotFound(new { message = "{Entity} not found" });
|
|
732
|
+
|
|
733
|
+
return Ok(MapToDetailDto(entity));
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
#endregion
|
|
737
|
+
|
|
738
|
+
#region Nested Resources
|
|
739
|
+
|
|
740
|
+
[HttpGet("{parentId:guid}/children")]
|
|
741
|
+
[RequirePermission(Permissions.{PermissionClass}.View)]
|
|
742
|
+
[ProducesResponseType(typeof(List<ChildDto>), StatusCodes.Status200OK)]
|
|
743
|
+
public async Task<ActionResult<List<ChildDto>>> GetChildren(
|
|
744
|
+
Guid parentId,
|
|
745
|
+
CancellationToken cancellationToken)
|
|
746
|
+
{
|
|
747
|
+
var children = await _context.Children
|
|
748
|
+
.Where(x => x.ParentId == parentId)
|
|
749
|
+
.OrderByDescending(x => x.CreatedAt)
|
|
750
|
+
.Select(x => new ChildDto(x.Id, x.Name, x.CreatedAt))
|
|
751
|
+
.ToListAsync(cancellationToken);
|
|
752
|
+
|
|
753
|
+
return Ok(children);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
[HttpPost("{parentId:guid}/children")]
|
|
757
|
+
[RequirePermission(Permissions.{PermissionClass}.Create)]
|
|
758
|
+
[ProducesResponseType(typeof(ChildDto), StatusCodes.Status201Created)]
|
|
759
|
+
public async Task<ActionResult<ChildDto>> AddChild(
|
|
760
|
+
Guid parentId,
|
|
761
|
+
[FromBody] CreateChildRequest request,
|
|
762
|
+
CancellationToken cancellationToken)
|
|
763
|
+
{
|
|
764
|
+
var parent = await _context.{DbSet}
|
|
765
|
+
.FirstOrDefaultAsync(x => x.Id == parentId, cancellationToken);
|
|
766
|
+
|
|
767
|
+
if (parent == null)
|
|
768
|
+
return NotFound(new { message = "Parent not found" });
|
|
769
|
+
|
|
770
|
+
var child = Child.Create(parentId, request.Name, _currentUser.UserId!.Value);
|
|
771
|
+
|
|
772
|
+
_context.Children.Add(child);
|
|
773
|
+
await _context.SaveChangesAsync(cancellationToken);
|
|
774
|
+
|
|
775
|
+
_logger.LogInformation("User {User} added child to {Entity} {ParentId}",
|
|
776
|
+
_currentUser.Email, parentId);
|
|
777
|
+
|
|
778
|
+
return CreatedAtAction(
|
|
779
|
+
nameof(GetChildren),
|
|
780
|
+
new { parentId },
|
|
781
|
+
new ChildDto(child.Id, child.Name, child.CreatedAt));
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
#endregion
|
|
785
|
+
```
|
|
786
|
+
|
|
787
|
+
---
|
|
788
|
+
|
|
789
|
+
## Patterns Réutilisables
|
|
790
|
+
|
|
791
|
+
### Error Response Standard
|
|
792
|
+
|
|
793
|
+
```csharp
|
|
794
|
+
public record ErrorResponse(string Message, string? Code = null);
|
|
795
|
+
|
|
796
|
+
// Usage:
|
|
797
|
+
return BadRequest(new ErrorResponse("Validation failed", "VALIDATION_ERROR"));
|
|
798
|
+
return Conflict(new ErrorResponse("Already exists", "DUPLICATE"));
|
|
799
|
+
return NotFound(new { message = "Resource not found" });
|
|
800
|
+
```
|
|
801
|
+
|
|
802
|
+
### Pagination Query Extension
|
|
803
|
+
|
|
804
|
+
```csharp
|
|
805
|
+
public static class QueryableExtensions
|
|
806
|
+
{
|
|
807
|
+
public static async Task<PagedResult<T>> ToPagedResultAsync<T>(
|
|
808
|
+
this IQueryable<T> query,
|
|
809
|
+
int page,
|
|
810
|
+
int pageSize,
|
|
811
|
+
CancellationToken ct = default)
|
|
812
|
+
{
|
|
813
|
+
var totalCount = await query.CountAsync(ct);
|
|
814
|
+
var items = await query
|
|
815
|
+
.Skip((page - 1) * pageSize)
|
|
816
|
+
.Take(pageSize)
|
|
817
|
+
.ToListAsync(ct);
|
|
818
|
+
|
|
819
|
+
return new PagedResult<T>(items, totalCount, page, pageSize);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
```
|
|
823
|
+
|
|
824
|
+
### Log Context Pattern
|
|
825
|
+
|
|
826
|
+
```csharp
|
|
827
|
+
// Toujours inclure le contexte utilisateur dans les logs
|
|
828
|
+
_logger.LogInformation(
|
|
829
|
+
"User {User} ({UserId}) performed {Action} on {Entity} {EntityId}",
|
|
830
|
+
_currentUser.Email,
|
|
831
|
+
_currentUser.UserId,
|
|
832
|
+
"Create",
|
|
833
|
+
"{Entity}",
|
|
834
|
+
entity.Id);
|
|
835
|
+
```
|
|
836
|
+
|
|
837
|
+
---
|
|
838
|
+
|
|
839
|
+
## Template Section-Level Permissions (Level 4)
|
|
840
|
+
|
|
841
|
+
> **Usage:** Quand un Module a plusieurs sous-pages/onglets avec permissions différentes (ex: AI → Dashboard, Settings, Prompts)
|
|
842
|
+
|
|
843
|
+
### Permissions.cs - Section
|
|
844
|
+
|
|
845
|
+
```csharp
|
|
846
|
+
// src/SmartStack.Application/Common/Authorization/Permissions.cs
|
|
847
|
+
|
|
848
|
+
public static class Admin
|
|
849
|
+
{
|
|
850
|
+
public static class {Module}
|
|
851
|
+
{
|
|
852
|
+
// Section permissions (Level 4)
|
|
853
|
+
public static class {Section}
|
|
854
|
+
{
|
|
855
|
+
public const string View = "{context}.{application}.{module}.{section}.read";
|
|
856
|
+
public const string Create = "{context}.{application}.{module}.{section}.create";
|
|
857
|
+
public const string Update = "{context}.{application}.{module}.{section}.update";
|
|
858
|
+
public const string Delete = "{context}.{application}.{module}.{section}.delete";
|
|
859
|
+
public const string Execute = "{context}.{application}.{module}.{section}.execute";
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
```
|
|
864
|
+
|
|
865
|
+
### PermissionConfiguration.cs - Section Seed
|
|
866
|
+
|
|
867
|
+
```csharp
|
|
868
|
+
// src/SmartStack.Infrastructure/Persistence/Configurations/Navigation/PermissionConfiguration.cs
|
|
869
|
+
// AJOUTER dans la méthode Configure(), section HasData
|
|
870
|
+
|
|
871
|
+
// ============================================
|
|
872
|
+
// ÉTAPE 1: Déclarer le SectionId
|
|
873
|
+
// ============================================
|
|
874
|
+
// Récupérer depuis NavigationSectionConfiguration.cs
|
|
875
|
+
|
|
876
|
+
var {section}SectionId = Guid.Parse("{GUID-DE-LA-SECTION}");
|
|
877
|
+
|
|
878
|
+
// ============================================
|
|
879
|
+
// ÉTAPE 2: Ajouter les permissions Section (Level 4)
|
|
880
|
+
// ============================================
|
|
881
|
+
|
|
882
|
+
// Pattern: {context}.{application}.{module}.{section}.{action}
|
|
883
|
+
// Exemple: platform.administration.ai.settings.read
|
|
884
|
+
|
|
885
|
+
builder.HasData(
|
|
886
|
+
// Wildcard permission (accès complet à la section)
|
|
887
|
+
new
|
|
888
|
+
{
|
|
889
|
+
Id = Guid.Parse("{NOUVEAU-GUID-1}"),
|
|
890
|
+
Path = "{context}.{application}.{module}.{section}.*",
|
|
891
|
+
Level = PermissionLevel.Section,
|
|
892
|
+
Action = (PermissionAction?)null,
|
|
893
|
+
IsWildcard = true,
|
|
894
|
+
SectionId = {section}SectionId,
|
|
895
|
+
Description = "Full {section} access",
|
|
896
|
+
CreatedAt = seedDate
|
|
897
|
+
},
|
|
898
|
+
|
|
899
|
+
// Read permission
|
|
900
|
+
new
|
|
901
|
+
{
|
|
902
|
+
Id = Guid.Parse("{NOUVEAU-GUID-2}"),
|
|
903
|
+
Path = "{context}.{application}.{module}.{section}.read",
|
|
904
|
+
Level = PermissionLevel.Section,
|
|
905
|
+
Action = PermissionAction.Read,
|
|
906
|
+
IsWildcard = false,
|
|
907
|
+
SectionId = {section}SectionId,
|
|
908
|
+
Description = "View {section}",
|
|
909
|
+
CreatedAt = seedDate
|
|
910
|
+
},
|
|
911
|
+
|
|
912
|
+
// Create permission
|
|
913
|
+
new
|
|
914
|
+
{
|
|
915
|
+
Id = Guid.Parse("{NOUVEAU-GUID-3}"),
|
|
916
|
+
Path = "{context}.{application}.{module}.{section}.create",
|
|
917
|
+
Level = PermissionLevel.Section,
|
|
918
|
+
Action = PermissionAction.Create,
|
|
919
|
+
IsWildcard = false,
|
|
920
|
+
SectionId = {section}SectionId,
|
|
921
|
+
Description = "Create in {section}",
|
|
922
|
+
CreatedAt = seedDate
|
|
923
|
+
},
|
|
924
|
+
|
|
925
|
+
// Update permission
|
|
926
|
+
new
|
|
927
|
+
{
|
|
928
|
+
Id = Guid.Parse("{NOUVEAU-GUID-4}"),
|
|
929
|
+
Path = "{context}.{application}.{module}.{section}.update",
|
|
930
|
+
Level = PermissionLevel.Section,
|
|
931
|
+
Action = PermissionAction.Update,
|
|
932
|
+
IsWildcard = false,
|
|
933
|
+
SectionId = {section}SectionId,
|
|
934
|
+
Description = "Update in {section}",
|
|
935
|
+
CreatedAt = seedDate
|
|
936
|
+
},
|
|
937
|
+
|
|
938
|
+
// Delete permission
|
|
939
|
+
new
|
|
940
|
+
{
|
|
941
|
+
Id = Guid.Parse("{NOUVEAU-GUID-5}"),
|
|
942
|
+
Path = "{context}.{application}.{module}.{section}.delete",
|
|
943
|
+
Level = PermissionLevel.Section,
|
|
944
|
+
Action = PermissionAction.Delete,
|
|
945
|
+
IsWildcard = false,
|
|
946
|
+
SectionId = {section}SectionId,
|
|
947
|
+
Description = "Delete in {section}",
|
|
948
|
+
CreatedAt = seedDate
|
|
949
|
+
},
|
|
950
|
+
|
|
951
|
+
// Execute permission (optionnel)
|
|
952
|
+
new
|
|
953
|
+
{
|
|
954
|
+
Id = Guid.Parse("{NOUVEAU-GUID-6}"),
|
|
955
|
+
Path = "{context}.{application}.{module}.{section}.execute",
|
|
956
|
+
Level = PermissionLevel.Section,
|
|
957
|
+
Action = PermissionAction.Execute,
|
|
958
|
+
IsWildcard = false,
|
|
959
|
+
SectionId = {section}SectionId,
|
|
960
|
+
Description = "Execute actions in {section}",
|
|
961
|
+
CreatedAt = seedDate
|
|
962
|
+
}
|
|
963
|
+
);
|
|
964
|
+
```
|
|
965
|
+
|
|
966
|
+
---
|
|
967
|
+
|
|
968
|
+
## Template Resource-Level Permissions (Level 5)
|
|
969
|
+
|
|
970
|
+
> **Usage:** Pour le niveau de granularité le plus fin (ex: Prompts → Blocks, Users → Profiles)
|
|
971
|
+
> **CRITIQUE:** Utilisé quand une Section contient des sous-ressources avec permissions distinctes
|
|
972
|
+
|
|
973
|
+
### Permissions.cs - Resource
|
|
974
|
+
|
|
975
|
+
```csharp
|
|
976
|
+
// src/SmartStack.Application/Common/Authorization/Permissions.cs
|
|
977
|
+
|
|
978
|
+
public static class Admin
|
|
979
|
+
{
|
|
980
|
+
public static class {Module}
|
|
981
|
+
{
|
|
982
|
+
public static class {Section}
|
|
983
|
+
{
|
|
984
|
+
// Section-level permissions...
|
|
985
|
+
|
|
986
|
+
// Resource permissions (Level 5 - finest granularity)
|
|
987
|
+
public static class {Resource}
|
|
988
|
+
{
|
|
989
|
+
public const string View = "{context}.{application}.{module}.{section}.{resource}.read";
|
|
990
|
+
public const string Create = "{context}.{application}.{module}.{section}.{resource}.create";
|
|
991
|
+
public const string Update = "{context}.{application}.{module}.{section}.{resource}.update";
|
|
992
|
+
public const string Delete = "{context}.{application}.{module}.{section}.{resource}.delete";
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
```
|
|
998
|
+
|
|
999
|
+
### PermissionConfiguration.cs - Resource Seed
|
|
1000
|
+
|
|
1001
|
+
```csharp
|
|
1002
|
+
// src/SmartStack.Infrastructure/Persistence/Configurations/Navigation/PermissionConfiguration.cs
|
|
1003
|
+
// AJOUTER dans la méthode Configure(), section HasData
|
|
1004
|
+
|
|
1005
|
+
// ============================================
|
|
1006
|
+
// ÉTAPE 1: Déclarer le ResourceId
|
|
1007
|
+
// ============================================
|
|
1008
|
+
// Récupérer depuis NavigationResourceConfiguration.cs
|
|
1009
|
+
|
|
1010
|
+
var {resource}ResourceId = Guid.Parse("{GUID-DE-LA-RESOURCE}");
|
|
1011
|
+
|
|
1012
|
+
// ============================================
|
|
1013
|
+
// ÉTAPE 2: Ajouter les permissions Resource (Level 5)
|
|
1014
|
+
// ============================================
|
|
1015
|
+
|
|
1016
|
+
// Pattern: {context}.{application}.{module}.{section}.{resource}.{action}
|
|
1017
|
+
// Exemple: platform.administration.ai.prompts.blocks.read
|
|
1018
|
+
|
|
1019
|
+
builder.HasData(
|
|
1020
|
+
// Wildcard permission (accès complet à la resource)
|
|
1021
|
+
new
|
|
1022
|
+
{
|
|
1023
|
+
Id = Guid.Parse("{NOUVEAU-GUID-1}"),
|
|
1024
|
+
Path = "{context}.{application}.{module}.{section}.{resource}.*",
|
|
1025
|
+
Level = PermissionLevel.Resource,
|
|
1026
|
+
Action = (PermissionAction?)null,
|
|
1027
|
+
IsWildcard = true,
|
|
1028
|
+
ResourceId = {resource}ResourceId,
|
|
1029
|
+
Description = "Full {resource} access",
|
|
1030
|
+
CreatedAt = seedDate
|
|
1031
|
+
},
|
|
1032
|
+
|
|
1033
|
+
// Read permission
|
|
1034
|
+
new
|
|
1035
|
+
{
|
|
1036
|
+
Id = Guid.Parse("{NOUVEAU-GUID-2}"),
|
|
1037
|
+
Path = "{context}.{application}.{module}.{section}.{resource}.read",
|
|
1038
|
+
Level = PermissionLevel.Resource,
|
|
1039
|
+
Action = PermissionAction.Read,
|
|
1040
|
+
IsWildcard = false,
|
|
1041
|
+
ResourceId = {resource}ResourceId,
|
|
1042
|
+
Description = "View {resource}",
|
|
1043
|
+
CreatedAt = seedDate
|
|
1044
|
+
},
|
|
1045
|
+
|
|
1046
|
+
// Create permission
|
|
1047
|
+
new
|
|
1048
|
+
{
|
|
1049
|
+
Id = Guid.Parse("{NOUVEAU-GUID-3}"),
|
|
1050
|
+
Path = "{context}.{application}.{module}.{section}.{resource}.create",
|
|
1051
|
+
Level = PermissionLevel.Resource,
|
|
1052
|
+
Action = PermissionAction.Create,
|
|
1053
|
+
IsWildcard = false,
|
|
1054
|
+
ResourceId = {resource}ResourceId,
|
|
1055
|
+
Description = "Create {resource}",
|
|
1056
|
+
CreatedAt = seedDate
|
|
1057
|
+
},
|
|
1058
|
+
|
|
1059
|
+
// Update permission
|
|
1060
|
+
new
|
|
1061
|
+
{
|
|
1062
|
+
Id = Guid.Parse("{NOUVEAU-GUID-4}"),
|
|
1063
|
+
Path = "{context}.{application}.{module}.{section}.{resource}.update",
|
|
1064
|
+
Level = PermissionLevel.Resource,
|
|
1065
|
+
Action = PermissionAction.Update,
|
|
1066
|
+
IsWildcard = false,
|
|
1067
|
+
ResourceId = {resource}ResourceId,
|
|
1068
|
+
Description = "Update {resource}",
|
|
1069
|
+
CreatedAt = seedDate
|
|
1070
|
+
},
|
|
1071
|
+
|
|
1072
|
+
// Delete permission
|
|
1073
|
+
new
|
|
1074
|
+
{
|
|
1075
|
+
Id = Guid.Parse("{NOUVEAU-GUID-5}"),
|
|
1076
|
+
Path = "{context}.{application}.{module}.{section}.{resource}.delete",
|
|
1077
|
+
Level = PermissionLevel.Resource,
|
|
1078
|
+
Action = PermissionAction.Delete,
|
|
1079
|
+
IsWildcard = false,
|
|
1080
|
+
ResourceId = {resource}ResourceId,
|
|
1081
|
+
Description = "Delete {resource}",
|
|
1082
|
+
CreatedAt = seedDate
|
|
1083
|
+
}
|
|
1084
|
+
);
|
|
1085
|
+
```
|
|
1086
|
+
|
|
1087
|
+
---
|
|
1088
|
+
|
|
1089
|
+
## Template Bulk Operations (Insertion en Masse)
|
|
1090
|
+
|
|
1091
|
+
> **OBLIGATOIRE:** Toujours prévoir les endpoints bulk lors de la création d'un controller CRUD
|
|
1092
|
+
|
|
1093
|
+
### Permissions.cs - Bulk Operations
|
|
1094
|
+
|
|
1095
|
+
```csharp
|
|
1096
|
+
// src/SmartStack.Application/Common/Authorization/Permissions.cs
|
|
1097
|
+
|
|
1098
|
+
public static class {Module}
|
|
1099
|
+
{
|
|
1100
|
+
// CRUD standard
|
|
1101
|
+
public const string View = "{path}.read";
|
|
1102
|
+
public const string Create = "{path}.create";
|
|
1103
|
+
public const string Update = "{path}.update";
|
|
1104
|
+
public const string Delete = "{path}.delete";
|
|
1105
|
+
|
|
1106
|
+
// Bulk operations (OBLIGATOIRE pour tout module CRUD)
|
|
1107
|
+
public const string BulkCreate = "{path}.bulk-create";
|
|
1108
|
+
public const string BulkUpdate = "{path}.bulk-update";
|
|
1109
|
+
public const string BulkDelete = "{path}.bulk-delete";
|
|
1110
|
+
public const string Export = "{path}.export";
|
|
1111
|
+
public const string Import = "{path}.import";
|
|
1112
|
+
}
|
|
1113
|
+
```
|
|
1114
|
+
|
|
1115
|
+
### PermissionConfiguration.cs - Bulk Permissions Seed
|
|
1116
|
+
|
|
1117
|
+
```csharp
|
|
1118
|
+
// Ajouter après les permissions CRUD standard
|
|
1119
|
+
|
|
1120
|
+
// Bulk Create permission
|
|
1121
|
+
new
|
|
1122
|
+
{
|
|
1123
|
+
Id = Guid.Parse("{NOUVEAU-GUID-BULK-1}"),
|
|
1124
|
+
Path = "{context}.{application}.{module}.bulk-create",
|
|
1125
|
+
Level = PermissionLevel.Module,
|
|
1126
|
+
Action = PermissionAction.Create,
|
|
1127
|
+
IsWildcard = false,
|
|
1128
|
+
ModuleId = {module}ModuleId,
|
|
1129
|
+
Description = "Bulk create {module}",
|
|
1130
|
+
CreatedAt = seedDate
|
|
1131
|
+
},
|
|
1132
|
+
|
|
1133
|
+
// Bulk Update permission
|
|
1134
|
+
new
|
|
1135
|
+
{
|
|
1136
|
+
Id = Guid.Parse("{NOUVEAU-GUID-BULK-2}"),
|
|
1137
|
+
Path = "{context}.{application}.{module}.bulk-update",
|
|
1138
|
+
Level = PermissionLevel.Module,
|
|
1139
|
+
Action = PermissionAction.Update,
|
|
1140
|
+
IsWildcard = false,
|
|
1141
|
+
ModuleId = {module}ModuleId,
|
|
1142
|
+
Description = "Bulk update {module}",
|
|
1143
|
+
CreatedAt = seedDate
|
|
1144
|
+
},
|
|
1145
|
+
|
|
1146
|
+
// Bulk Delete permission
|
|
1147
|
+
new
|
|
1148
|
+
{
|
|
1149
|
+
Id = Guid.Parse("{NOUVEAU-GUID-BULK-3}"),
|
|
1150
|
+
Path = "{context}.{application}.{module}.bulk-delete",
|
|
1151
|
+
Level = PermissionLevel.Module,
|
|
1152
|
+
Action = PermissionAction.Delete,
|
|
1153
|
+
IsWildcard = false,
|
|
1154
|
+
ModuleId = {module}ModuleId,
|
|
1155
|
+
Description = "Bulk delete {module}",
|
|
1156
|
+
CreatedAt = seedDate
|
|
1157
|
+
},
|
|
1158
|
+
|
|
1159
|
+
// Export permission
|
|
1160
|
+
new
|
|
1161
|
+
{
|
|
1162
|
+
Id = Guid.Parse("{NOUVEAU-GUID-EXPORT}"),
|
|
1163
|
+
Path = "{context}.{application}.{module}.export",
|
|
1164
|
+
Level = PermissionLevel.Module,
|
|
1165
|
+
Action = PermissionAction.Execute,
|
|
1166
|
+
IsWildcard = false,
|
|
1167
|
+
ModuleId = {module}ModuleId,
|
|
1168
|
+
Description = "Export {module} data",
|
|
1169
|
+
CreatedAt = seedDate
|
|
1170
|
+
},
|
|
1171
|
+
|
|
1172
|
+
// Import permission
|
|
1173
|
+
new
|
|
1174
|
+
{
|
|
1175
|
+
Id = Guid.Parse("{NOUVEAU-GUID-IMPORT}"),
|
|
1176
|
+
Path = "{context}.{application}.{module}.import",
|
|
1177
|
+
Level = PermissionLevel.Module,
|
|
1178
|
+
Action = PermissionAction.Create,
|
|
1179
|
+
IsWildcard = false,
|
|
1180
|
+
ModuleId = {module}ModuleId,
|
|
1181
|
+
Description = "Import {module} data",
|
|
1182
|
+
CreatedAt = seedDate
|
|
1183
|
+
}
|
|
1184
|
+
```
|
|
1185
|
+
|
|
1186
|
+
### Controller Endpoints - Bulk Operations
|
|
1187
|
+
|
|
1188
|
+
```csharp
|
|
1189
|
+
// src/SmartStack.Api/Controllers/{Area}/{Module}Controller.cs
|
|
1190
|
+
// AJOUTER après les endpoints CRUD standard
|
|
1191
|
+
|
|
1192
|
+
#region BULK OPERATIONS
|
|
1193
|
+
|
|
1194
|
+
/// <summary>
|
|
1195
|
+
/// Bulk create multiple entities
|
|
1196
|
+
/// </summary>
|
|
1197
|
+
[HttpPost("bulk")]
|
|
1198
|
+
[RequirePermission(Permissions.{PermissionClass}.BulkCreate)]
|
|
1199
|
+
[ProducesResponseType(typeof(BulkOperationResult<{Entity}Dto>), StatusCodes.Status201Created)]
|
|
1200
|
+
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
1201
|
+
public async Task<ActionResult<BulkOperationResult<{Entity}Dto>>> BulkCreate{Entity}(
|
|
1202
|
+
[FromBody] List<Create{Entity}Request> requests,
|
|
1203
|
+
CancellationToken cancellationToken)
|
|
1204
|
+
{
|
|
1205
|
+
if (requests == null || requests.Count == 0)
|
|
1206
|
+
return BadRequest(new { message = "No items provided" });
|
|
1207
|
+
|
|
1208
|
+
if (requests.Count > 100)
|
|
1209
|
+
return BadRequest(new { message = "Maximum 100 items per bulk operation" });
|
|
1210
|
+
|
|
1211
|
+
var results = new List<{Entity}Dto>();
|
|
1212
|
+
var errors = new List<BulkOperationError>();
|
|
1213
|
+
|
|
1214
|
+
for (int i = 0; i < requests.Count; i++)
|
|
1215
|
+
{
|
|
1216
|
+
try
|
|
1217
|
+
{
|
|
1218
|
+
var entity = {Entity}.Create(
|
|
1219
|
+
requests[i].Name,
|
|
1220
|
+
requests[i].Description
|
|
1221
|
+
);
|
|
1222
|
+
|
|
1223
|
+
_context.{DbSet}.Add(entity);
|
|
1224
|
+
results.Add(new {Entity}Dto(entity.Id, entity.Name));
|
|
1225
|
+
}
|
|
1226
|
+
catch (Exception ex)
|
|
1227
|
+
{
|
|
1228
|
+
errors.Add(new BulkOperationError(i, requests[i].Name, ex.Message));
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
await _context.SaveChangesAsync(cancellationToken);
|
|
1233
|
+
|
|
1234
|
+
_logger.LogInformation("User {User} bulk created {Count} {Entity}(s), {Errors} error(s)",
|
|
1235
|
+
_currentUser.Email, results.Count, errors.Count);
|
|
1236
|
+
|
|
1237
|
+
return CreatedAtAction(
|
|
1238
|
+
nameof(Get{Module}),
|
|
1239
|
+
new BulkOperationResult<{Entity}Dto>(results, errors, results.Count, errors.Count));
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
/// <summary>
|
|
1243
|
+
/// Bulk update multiple entities
|
|
1244
|
+
/// </summary>
|
|
1245
|
+
[HttpPut("bulk")]
|
|
1246
|
+
[RequirePermission(Permissions.{PermissionClass}.BulkUpdate)]
|
|
1247
|
+
[ProducesResponseType(typeof(BulkOperationResult), StatusCodes.Status200OK)]
|
|
1248
|
+
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
1249
|
+
public async Task<ActionResult<BulkOperationResult>> BulkUpdate{Entity}(
|
|
1250
|
+
[FromBody] List<BulkUpdate{Entity}Request> requests,
|
|
1251
|
+
CancellationToken cancellationToken)
|
|
1252
|
+
{
|
|
1253
|
+
if (requests == null || requests.Count == 0)
|
|
1254
|
+
return BadRequest(new { message = "No items provided" });
|
|
1255
|
+
|
|
1256
|
+
if (requests.Count > 100)
|
|
1257
|
+
return BadRequest(new { message = "Maximum 100 items per bulk operation" });
|
|
1258
|
+
|
|
1259
|
+
var ids = requests.Select(r => r.Id).ToList();
|
|
1260
|
+
var entities = await _context.{DbSet}
|
|
1261
|
+
.Where(x => ids.Contains(x.Id))
|
|
1262
|
+
.ToDictionaryAsync(x => x.Id, cancellationToken);
|
|
1263
|
+
|
|
1264
|
+
var updated = 0;
|
|
1265
|
+
var errors = new List<BulkOperationError>();
|
|
1266
|
+
|
|
1267
|
+
for (int i = 0; i < requests.Count; i++)
|
|
1268
|
+
{
|
|
1269
|
+
if (!entities.TryGetValue(requests[i].Id, out var entity))
|
|
1270
|
+
{
|
|
1271
|
+
errors.Add(new BulkOperationError(i, requests[i].Id.ToString(), "Entity not found"));
|
|
1272
|
+
continue;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
try
|
|
1276
|
+
{
|
|
1277
|
+
entity.Update(
|
|
1278
|
+
requests[i].Name ?? entity.Name,
|
|
1279
|
+
requests[i].Description ?? entity.Description
|
|
1280
|
+
);
|
|
1281
|
+
updated++;
|
|
1282
|
+
}
|
|
1283
|
+
catch (Exception ex)
|
|
1284
|
+
{
|
|
1285
|
+
errors.Add(new BulkOperationError(i, requests[i].Id.ToString(), ex.Message));
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
await _context.SaveChangesAsync(cancellationToken);
|
|
1290
|
+
|
|
1291
|
+
_logger.LogInformation("User {User} bulk updated {Count} {Entity}(s), {Errors} error(s)",
|
|
1292
|
+
_currentUser.Email, updated, errors.Count);
|
|
1293
|
+
|
|
1294
|
+
return Ok(new BulkOperationResult(updated, errors.Count, errors));
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
/// <summary>
|
|
1298
|
+
/// Bulk delete multiple entities by IDs
|
|
1299
|
+
/// </summary>
|
|
1300
|
+
[HttpDelete("bulk")]
|
|
1301
|
+
[RequirePermission(Permissions.{PermissionClass}.BulkDelete)]
|
|
1302
|
+
[ProducesResponseType(typeof(BulkOperationResult), StatusCodes.Status200OK)]
|
|
1303
|
+
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
1304
|
+
public async Task<ActionResult<BulkOperationResult>> BulkDelete{Entity}(
|
|
1305
|
+
[FromBody] List<Guid> ids,
|
|
1306
|
+
CancellationToken cancellationToken)
|
|
1307
|
+
{
|
|
1308
|
+
if (ids == null || ids.Count == 0)
|
|
1309
|
+
return BadRequest(new { message = "No IDs provided" });
|
|
1310
|
+
|
|
1311
|
+
if (ids.Count > 100)
|
|
1312
|
+
return BadRequest(new { message = "Maximum 100 items per bulk operation" });
|
|
1313
|
+
|
|
1314
|
+
var entities = await _context.{DbSet}
|
|
1315
|
+
.Where(x => ids.Contains(x.Id))
|
|
1316
|
+
.ToListAsync(cancellationToken);
|
|
1317
|
+
|
|
1318
|
+
var deleted = entities.Count;
|
|
1319
|
+
var notFound = ids.Count - deleted;
|
|
1320
|
+
|
|
1321
|
+
_context.{DbSet}.RemoveRange(entities);
|
|
1322
|
+
await _context.SaveChangesAsync(cancellationToken);
|
|
1323
|
+
|
|
1324
|
+
_logger.LogWarning("User {User} bulk deleted {Count} {Entity}(s), {NotFound} not found",
|
|
1325
|
+
_currentUser.Email, deleted, notFound);
|
|
1326
|
+
|
|
1327
|
+
var errors = notFound > 0
|
|
1328
|
+
? new List<BulkOperationError> { new(-1, "N/A", $"{notFound} entities not found") }
|
|
1329
|
+
: new List<BulkOperationError>();
|
|
1330
|
+
|
|
1331
|
+
return Ok(new BulkOperationResult(deleted, errors.Count, errors));
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
/// <summary>
|
|
1335
|
+
/// Export entities to CSV/Excel
|
|
1336
|
+
/// </summary>
|
|
1337
|
+
[HttpGet("export")]
|
|
1338
|
+
[RequirePermission(Permissions.{PermissionClass}.Export)]
|
|
1339
|
+
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
|
1340
|
+
public async Task<IActionResult> Export{Module}(
|
|
1341
|
+
[FromQuery] string format = "csv",
|
|
1342
|
+
[FromQuery] string? search = null,
|
|
1343
|
+
CancellationToken cancellationToken = default)
|
|
1344
|
+
{
|
|
1345
|
+
var query = _context.{DbSet}.AsQueryable();
|
|
1346
|
+
|
|
1347
|
+
if (!string.IsNullOrWhiteSpace(search))
|
|
1348
|
+
{
|
|
1349
|
+
var searchLower = search.ToLower();
|
|
1350
|
+
query = query.Where(x => x.Name.ToLower().Contains(searchLower));
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
var entities = await query.ToListAsync(cancellationToken);
|
|
1354
|
+
|
|
1355
|
+
_logger.LogInformation("User {User} exported {Count} {Entity}(s) to {Format}",
|
|
1356
|
+
_currentUser.Email, entities.Count, format);
|
|
1357
|
+
|
|
1358
|
+
// Implement CSV/Excel export logic here
|
|
1359
|
+
// Using libraries like CsvHelper or ClosedXML
|
|
1360
|
+
|
|
1361
|
+
var content = format.ToLower() switch
|
|
1362
|
+
{
|
|
1363
|
+
"xlsx" => GenerateExcel(entities),
|
|
1364
|
+
_ => GenerateCsv(entities)
|
|
1365
|
+
};
|
|
1366
|
+
|
|
1367
|
+
var contentType = format.ToLower() == "xlsx"
|
|
1368
|
+
? "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
1369
|
+
: "text/csv";
|
|
1370
|
+
|
|
1371
|
+
var fileName = $"{module}-export-{DateTime.UtcNow:yyyyMMdd-HHmmss}.{format}";
|
|
1372
|
+
|
|
1373
|
+
return File(content, contentType, fileName);
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
#endregion
|
|
1377
|
+
|
|
1378
|
+
#region Bulk DTOs
|
|
1379
|
+
|
|
1380
|
+
public record BulkOperationResult(
|
|
1381
|
+
int SuccessCount,
|
|
1382
|
+
int ErrorCount,
|
|
1383
|
+
List<BulkOperationError> Errors);
|
|
1384
|
+
|
|
1385
|
+
public record BulkOperationResult<T>(
|
|
1386
|
+
List<T> Created,
|
|
1387
|
+
List<BulkOperationError> Errors,
|
|
1388
|
+
int SuccessCount,
|
|
1389
|
+
int ErrorCount);
|
|
1390
|
+
|
|
1391
|
+
public record BulkOperationError(
|
|
1392
|
+
int Index,
|
|
1393
|
+
string Identifier,
|
|
1394
|
+
string Message);
|
|
1395
|
+
|
|
1396
|
+
public record BulkUpdate{Entity}Request(
|
|
1397
|
+
Guid Id,
|
|
1398
|
+
string? Name,
|
|
1399
|
+
string? Description);
|
|
1400
|
+
|
|
1401
|
+
#endregion
|
|
1402
|
+
```
|
|
1403
|
+
|
|
1404
|
+
---
|
|
1405
|
+
|
|
1406
|
+
## Hiérarchie Complète des Permissions
|
|
1407
|
+
|
|
1408
|
+
```
|
|
1409
|
+
┌─────────────────────────────────────────────────────────────────────────────────┐
|
|
1410
|
+
│ HIÉRARCHIE COMPLÈTE DES PERMISSIONS │
|
|
1411
|
+
├─────────────────────────────────────────────────────────────────────────────────┤
|
|
1412
|
+
│ │
|
|
1413
|
+
│ Level 1: CONTEXT │
|
|
1414
|
+
│ └─ Path: {context}.* │
|
|
1415
|
+
│ └─ Ex: platform.* → Accès complet au context platform │
|
|
1416
|
+
│ │
|
|
1417
|
+
│ Level 2: APPLICATION │
|
|
1418
|
+
│ └─ Path: {context}.{application}.* │
|
|
1419
|
+
│ └─ Ex: platform.administration.* → Accès complet à l'administration │
|
|
1420
|
+
│ │
|
|
1421
|
+
│ Level 3: MODULE │
|
|
1422
|
+
│ └─ Path: {context}.{application}.{module}.{action} │
|
|
1423
|
+
│ └─ Ex: platform.administration.users.read → Lecture des users │
|
|
1424
|
+
│ └─ BULK: platform.administration.users.bulk-create → Création en masse │
|
|
1425
|
+
│ │
|
|
1426
|
+
│ Level 4: SECTION │
|
|
1427
|
+
│ └─ Path: {context}.{application}.{module}.{section}.{action} │
|
|
1428
|
+
│ └─ Ex: platform.administration.ai.settings.update → MAJ paramètres IA │
|
|
1429
|
+
│ │
|
|
1430
|
+
│ Level 5: RESOURCE (finest granularity) │
|
|
1431
|
+
│ └─ Path: {context}.{application}.{module}.{section}.{resource}.{action} │
|
|
1432
|
+
│ └─ Ex: platform.administration.ai.prompts.blocks.delete → Suppr. blocs │
|
|
1433
|
+
│ │
|
|
1434
|
+
└─────────────────────────────────────────────────────────────────────────────────┘
|
|
1435
|
+
```
|
|
1436
|
+
|
|
1437
|
+
---
|
|
1438
|
+
|
|
1439
|
+
## Checklist Controller avec Permissions Complètes
|
|
1440
|
+
|
|
1441
|
+
```
|
|
1442
|
+
□ CRUD Standard
|
|
1443
|
+
□ GET /api/.../ → {path}.read
|
|
1444
|
+
□ GET /api/.../{id} → {path}.read
|
|
1445
|
+
□ POST /api/.../ → {path}.create
|
|
1446
|
+
□ PUT /api/.../{id} → {path}.update
|
|
1447
|
+
□ DELETE /api/.../{id} → {path}.delete
|
|
1448
|
+
|
|
1449
|
+
□ Bulk Operations
|
|
1450
|
+
□ POST /api/.../bulk → {path}.bulk-create
|
|
1451
|
+
□ PUT /api/.../bulk → {path}.bulk-update
|
|
1452
|
+
□ DELETE /api/.../bulk → {path}.bulk-delete
|
|
1453
|
+
|
|
1454
|
+
□ Export/Import
|
|
1455
|
+
□ GET /api/.../export → {path}.export
|
|
1456
|
+
□ POST /api/.../import → {path}.import
|
|
1457
|
+
|
|
1458
|
+
□ Permissions Configurées
|
|
1459
|
+
□ Permissions.cs - Constantes définies
|
|
1460
|
+
□ PermissionConfiguration.cs - Seed HasData
|
|
1461
|
+
□ Migration EF Core créée
|
|
1462
|
+
□ Migration appliquée
|
|
1463
|
+
|
|
1464
|
+
□ Niveau Permission Correct
|
|
1465
|
+
□ Module (Level 3) - Pour CRUD principal
|
|
1466
|
+
□ Section (Level 4) - Si sous-pages avec perms différentes
|
|
1467
|
+
□ Resource (Level 5) - Si sous-ressources avec perms distinctes
|
|
1468
|
+
```
|