@atlashub/smartstack-cli 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/.documentation/agents.html +8 -4
  2. package/.documentation/apex.html +8 -4
  3. package/.documentation/business-analyse.html +833 -406
  4. package/.documentation/commands.html +8 -4
  5. package/.documentation/css/styles.css +153 -15
  6. package/.documentation/efcore.html +8 -4
  7. package/.documentation/gitflow.html +795 -230
  8. package/.documentation/hooks.html +8 -4
  9. package/.documentation/index.html +13 -9
  10. package/.documentation/installation.html +23 -19
  11. package/.documentation/ralph-loop.html +530 -0
  12. package/.documentation/test-web.html +8 -4
  13. package/README.md +52 -10
  14. package/dist/index.js +813 -283
  15. package/dist/index.js.map +1 -1
  16. package/package.json +1 -1
  17. package/templates/agents/efcore/conflicts.md +44 -17
  18. package/templates/agents/efcore/db-status.md +27 -6
  19. package/templates/agents/efcore/scan.md +43 -13
  20. package/templates/commands/ai-prompt.md +315 -315
  21. package/templates/commands/application/create.md +362 -362
  22. package/templates/commands/controller/create.md +216 -216
  23. package/templates/commands/controller.md +59 -0
  24. package/templates/commands/create/agent.md +138 -0
  25. package/templates/commands/create/command.md +166 -0
  26. package/templates/commands/create/hook.md +234 -0
  27. package/templates/commands/create/plugin.md +329 -0
  28. package/templates/commands/create/project.md +507 -0
  29. package/templates/commands/create/skill.md +199 -0
  30. package/templates/commands/create.md +220 -0
  31. package/templates/commands/documentation/module.md +202 -202
  32. package/templates/commands/efcore/_env-check.md +153 -153
  33. package/templates/commands/efcore/conflicts.md +109 -192
  34. package/templates/commands/efcore/db-status.md +101 -89
  35. package/templates/commands/efcore/migration.md +23 -11
  36. package/templates/commands/efcore/scan.md +115 -119
  37. package/templates/commands/efcore.md +54 -6
  38. package/templates/commands/feature-full.md +267 -267
  39. package/templates/commands/gitflow/11-finish.md +145 -11
  40. package/templates/commands/gitflow/13-sync.md +216 -216
  41. package/templates/commands/gitflow/14-rebase.md +251 -251
  42. package/templates/commands/gitflow/2-status.md +120 -10
  43. package/templates/commands/gitflow/3-commit.md +150 -0
  44. package/templates/commands/gitflow/7-pull-request.md +134 -5
  45. package/templates/commands/gitflow/9-merge.md +142 -1
  46. package/templates/commands/implement.md +663 -663
  47. package/templates/commands/init.md +562 -0
  48. package/templates/commands/mcp-integration.md +330 -0
  49. package/templates/commands/notification.md +129 -129
  50. package/templates/commands/validate.md +233 -0
  51. package/templates/commands/workflow.md +193 -193
  52. package/templates/skills/ai-prompt/SKILL.md +778 -778
  53. package/templates/skills/application/SKILL.md +563 -563
  54. package/templates/skills/application/templates-backend.md +450 -450
  55. package/templates/skills/application/templates-frontend.md +531 -531
  56. package/templates/skills/application/templates-i18n.md +520 -520
  57. package/templates/skills/application/templates-seed.md +647 -647
  58. package/templates/skills/controller/SKILL.md +240 -240
  59. package/templates/skills/controller/postman-templates.md +614 -614
  60. package/templates/skills/controller/templates.md +1468 -1468
  61. package/templates/skills/documentation/SKILL.md +133 -133
  62. package/templates/skills/documentation/templates.md +476 -476
  63. package/templates/skills/feature-full/SKILL.md +838 -838
  64. package/templates/skills/notification/SKILL.md +555 -555
  65. package/templates/skills/ui-components/SKILL.md +870 -870
  66. package/templates/skills/workflow/SKILL.md +582 -582
@@ -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
+ ```