@atlashub/smartstack-cli 4.32.0 → 4.33.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 (38) hide show
  1. package/.documentation/index.html +2 -2
  2. package/.documentation/init.html +358 -174
  3. package/dist/mcp-entry.mjs +271 -44
  4. package/dist/mcp-entry.mjs.map +1 -1
  5. package/package.json +1 -1
  6. package/templates/mcp-scaffolding/controller.cs.hbs +54 -128
  7. package/templates/project/README.md +19 -0
  8. package/templates/skills/apex/SKILL.md +16 -10
  9. package/templates/skills/apex/_shared.md +1 -1
  10. package/templates/skills/apex/references/checks/architecture-checks.sh +154 -0
  11. package/templates/skills/apex/references/checks/backend-checks.sh +194 -0
  12. package/templates/skills/apex/references/checks/frontend-checks.sh +448 -0
  13. package/templates/skills/apex/references/checks/infrastructure-checks.sh +255 -0
  14. package/templates/skills/apex/references/checks/security-checks.sh +153 -0
  15. package/templates/skills/apex/references/checks/seed-checks.sh +536 -0
  16. package/templates/skills/apex/references/frontend-route-wiring-app-tsx.md +49 -192
  17. package/templates/skills/apex/references/post-checks.md +124 -2156
  18. package/templates/skills/apex/references/smartstack-api.md +160 -957
  19. package/templates/skills/apex/references/smartstack-frontend.md +134 -1022
  20. package/templates/skills/apex/references/smartstack-layers.md +12 -6
  21. package/templates/skills/apex/steps/step-00-init.md +81 -238
  22. package/templates/skills/apex/steps/step-03-execute.md +25 -752
  23. package/templates/skills/apex/steps/step-03a-layer0-domain.md +118 -0
  24. package/templates/skills/apex/steps/step-03b-layer1-seed.md +91 -0
  25. package/templates/skills/apex/steps/step-03c-layer2-backend.md +240 -0
  26. package/templates/skills/apex/steps/step-03d-layer3-frontend.md +300 -0
  27. package/templates/skills/apex/steps/step-03e-layer4-devdata.md +44 -0
  28. package/templates/skills/apex/steps/step-04-examine.md +70 -150
  29. package/templates/skills/application/references/frontend-i18n-and-output.md +2 -2
  30. package/templates/skills/application/references/frontend-route-naming.md +5 -1
  31. package/templates/skills/application/references/frontend-route-wiring-app-tsx.md +49 -198
  32. package/templates/skills/application/references/frontend-verification.md +11 -11
  33. package/templates/skills/application/steps/step-05-frontend.md +26 -15
  34. package/templates/skills/application/templates-frontend.md +4 -0
  35. package/templates/skills/cli-app-sync/SKILL.md +2 -2
  36. package/templates/skills/cli-app-sync/references/comparison-map.md +1 -1
  37. package/templates/skills/controller/references/controller-code-templates.md +70 -67
  38. package/templates/skills/controller/references/mcp-scaffold-workflow.md +5 -1
@@ -1,7 +1,8 @@
1
1
  # SmartStack Domain API Reference
2
2
 
3
3
  > **Source of truth:** `SmartStack.app/src/SmartStack.Domain/Common/`
4
- > **Loaded by:** step-01 (analyze), step-03 (execute)
4
+ > **Loaded by:** step-01 (analyze), step-03a/03b/03c (execute). Released after Layer 2.
5
+ > **MCP generates complete implementations** — this file provides guard rails and conventions only.
5
6
 
6
7
  ---
7
8
 
@@ -43,7 +44,7 @@ public interface IAuditableEntity
43
44
  }
44
45
  ```
45
46
 
46
- ### IOptionalTenantEntity (nullable tenant)
47
+ ### IOptionalTenantEntity (nullable tenant — cross-tenant)
47
48
 
48
49
  ```csharp
49
50
  public interface IOptionalTenantEntity
@@ -89,23 +90,18 @@ Verify in `Program.cs`: `JsonSerializerOptions.Converters.Add(new JsonStringEnum
89
90
 
90
91
  ---
91
92
 
92
- ## Entity Pattern (tenant-scoped, most common)
93
+ ## Entity Patterns
93
94
 
94
- ```csharp
95
- using SmartStack.Domain.Common;
96
-
97
- namespace {ProjectName}.Domain.Entities.{App}.{Module};
95
+ ### Tenant-scoped (most common)
98
96
 
97
+ ```csharp
99
98
  public class {Name} : BaseEntity, ITenantEntity, IAuditableEntity
100
99
  {
101
- // ITenantEntity
102
100
  public Guid TenantId { get; private set; }
103
-
104
- // IAuditableEntity
105
101
  public string? CreatedBy { get; set; }
106
102
  public string? UpdatedBy { get; set; }
107
103
 
108
- // Business properties (add your own)
104
+ // Business properties
109
105
  public string Code { get; private set; } = null!;
110
106
  public string Name { get; private set; } = null!;
111
107
  public string? Description { get; private set; }
@@ -137,184 +133,60 @@ public class {Name} : BaseEntity, ITenantEntity, IAuditableEntity
137
133
  }
138
134
  ```
139
135
 
140
- ---
141
-
142
- ## Entity Pattern (platform-level, no tenant)
136
+ ### Platform-level (no tenant)
143
137
 
144
138
  ```csharp
145
139
  public class {Name} : BaseEntity, IAuditableEntity
146
140
  {
141
+ // No TenantId — platform-wide entity
147
142
  public string? CreatedBy { get; set; }
148
143
  public string? UpdatedBy { get; set; }
149
-
150
- // Business properties
151
144
  public string Code { get; private set; } = null!;
152
145
  public string Name { get; private set; } = null!;
153
-
154
146
  private {Name}() { }
155
-
156
- public static {Name} Create(string code, string name)
157
- {
158
- return new {Name}
159
- {
160
- Id = Guid.NewGuid(),
161
- Code = code.ToLowerInvariant(),
162
- Name = name,
163
- CreatedAt = DateTime.UtcNow
164
- };
165
- }
147
+ // Create(): no tenantId parameter, no Guid.Empty guard
166
148
  }
167
149
  ```
168
150
 
169
- ### Entity Pattern — Cross-Tenant (IOptionalTenantEntity)
170
-
171
- For entities that can be shared across tenants (e.g., Department, Currency). TenantId is nullable — null means shared, Guid means tenant-specific. The user decides the scope at creation time.
151
+ ### Cross-Tenant (IOptionalTenantEntity) — key differences
172
152
 
173
153
  ```csharp
174
154
  public class {Name} : BaseEntity, IOptionalTenantEntity, IAuditableEntity
175
155
  {
176
- // TenantId nullable — null = shared across all tenants
177
- public Guid? TenantId { get; private set; }
178
-
179
- public string? CreatedBy { get; set; }
180
- public string? UpdatedBy { get; set; }
181
-
182
- // Business properties
183
- public string Code { get; private set; } = string.Empty;
184
- public string Name { get; private set; } = string.Empty;
185
-
186
- private {Name}() { }
187
-
188
- /// <param name="tenantId">null = shared (cross-tenant), Guid = tenant-specific</param>
189
- public static {Name} Create(Guid? tenantId = null, string code, string name)
190
- {
191
- return new {Name}
192
- {
193
- Id = Guid.NewGuid(),
194
- TenantId = tenantId,
195
- Code = code.ToLowerInvariant(),
196
- Name = name,
197
- CreatedAt = DateTime.UtcNow
198
- };
199
- }
156
+ public Guid? TenantId { get; private set; } // nullable — null = shared
157
+ // Create(Guid? tenantId = null, ...) tenantId nullable
200
158
  }
201
159
  ```
202
160
 
203
- **EF Core global query filter (already in SmartStack.app CoreDbContext):**
204
- ```csharp
205
- builder.HasQueryFilter(e => !ShouldFilterByTenant || e.TenantId == null || e.TenantId == CurrentTenantId);
206
- ```
207
- This automatically includes shared (null) + current tenant data in all queries.
208
-
209
- **Service pattern for optional tenant:**
210
- ```csharp
211
- // No guard clause — tenantId is nullable
212
- var tenantId = _currentTenant.TenantId; // null = creating shared data
213
- var entity = Department.Create(tenantId, dto.Code, dto.Name);
214
- ```
215
-
216
- ### Entity Pattern — Scoped (IScopedTenantEntity)
161
+ EF query filter (in CoreDbContext): `builder.HasQueryFilter(e => !ShouldFilterByTenant || e.TenantId == null || e.TenantId == CurrentTenantId);`
162
+ Service: no guard clause — `var tenantId = _currentTenant.TenantId;` (null = creating shared data).
217
163
 
218
- For entities with explicit visibility control via EntityScope enum (Tenant, Shared, Platform).
164
+ ### Scoped (IScopedTenantEntity) key differences
219
165
 
220
166
  ```csharp
221
167
  public class {Name} : BaseEntity, IScopedTenantEntity, IAuditableEntity
222
168
  {
223
169
  public Guid? TenantId { get; private set; }
224
170
  public EntityScope Scope { get; private set; }
225
-
226
- public string? CreatedBy { get; set; }
227
- public string? UpdatedBy { get; set; }
228
-
229
- private {Name}() { }
230
-
231
- public static {Name} Create(Guid? tenantId = null, EntityScope scope = EntityScope.Tenant)
232
- {
233
- if (scope == EntityScope.Tenant && tenantId == null)
234
- throw new ArgumentException("TenantId is required when scope is Tenant");
235
-
236
- return new {Name}
237
- {
238
- Id = Guid.NewGuid(),
239
- TenantId = tenantId,
240
- Scope = scope,
241
- CreatedAt = DateTime.UtcNow
242
- };
243
- }
171
+ // Create: if scope == Tenant && tenantId == null → throw
244
172
  }
245
173
  ```
246
174
 
247
- ### Entity Pattern — Person Extension (User-linked roles)
248
-
249
- For entities representing a "person role" of a User (Employee, Customer, Contact). Links entity to User instead of duplicating personal data.
250
-
251
- > **Full reference:** See `references/person-extension-pattern.md` for complete entity, config, service, DTO, and frontend patterns with C# templates, decision guide, and examples.
175
+ ### Person Extension (User-linked)
252
176
 
253
- **Two variants:**
177
+ > **Full reference:** See `references/person-extension-pattern.md` for complete patterns.
254
178
 
255
179
  | Variant | UserId | Use case |
256
180
  |---------|--------|----------|
257
181
  | **Mandatory** | `Guid UserId` | Employee, Manager — always a User |
258
182
  | **Optional** | `Guid? UserId` | Customer, Contact — may or may not have account |
259
183
 
260
- **IPersonExtension interface:**
261
- ```csharp
262
- public interface IPersonExtension
263
- {
264
- Guid? UserId { get; }
265
- User? User { get; }
266
- }
267
- ```
268
-
269
- **Mandatory variant (entity):**
270
- ```csharp
271
- public class Employee : BaseEntity, ITenantEntity, IAuditableEntity, IPersonExtension
272
- {
273
- public Guid TenantId { get; private set; }
274
- public string? CreatedBy { get; set; }
275
- public string? UpdatedBy { get; set; }
276
-
277
- // Person Extension — mandatory UserId
278
- public Guid UserId { get; private set; }
279
- public User? User { get; private set; }
280
-
281
- // Business properties ONLY — no duplicate person fields
282
- public string Code { get; private set; } = null!;
283
- public DateOnly HireDate { get; private set; }
284
- }
285
- ```
286
-
287
- **Optional variant (entity):**
288
- ```csharp
289
- public class Customer : BaseEntity, ITenantEntity, IAuditableEntity, IPersonExtension
290
- {
291
- public Guid TenantId { get; private set; }
292
- public string? CreatedBy { get; set; }
293
- public string? UpdatedBy { get; set; }
294
-
295
- // Person Extension — optional UserId
296
- public Guid? UserId { get; private set; }
297
- public User? User { get; private set; }
298
-
299
- // Person fields (used only when UserId is null)
300
- public string FirstName { get; private set; } = null!;
301
- public string LastName { get; private set; } = null!;
302
- public string? Email { get; private set; }
303
-
304
- // Computed — resolve from User if linked, else own fields
305
- public string DisplayFirstName => User?.FirstName ?? FirstName;
306
- public string DisplayLastName => User?.LastName ?? LastName;
307
- public string? DisplayEmail => User?.Email ?? Email;
308
- }
309
- ```
310
-
311
- **Key rules:**
312
- - **EF Config (mandatory):** Unique index on `(TenantId, UserId)` without filter
313
- - **EF Config (optional):** Unique index on `(TenantId, UserId)` with `.HasFilter("[UserId] IS NOT NULL")`
314
- - **Service:** Include User on all queries. Verify User exists and no duplicate on CreateAsync.
315
- - **DTO:** Mandatory CreateDto requires `UserId`. Optional CreateDto requires person fields when `UserId` is null.
316
-
317
- ---
184
+ Key rules:
185
+ - **Mandatory:** Unique index on `(TenantId, UserId)` without filter. No person fields on entity.
186
+ - **Optional:** Unique index on `(TenantId, UserId)` with `.HasFilter("[UserId] IS NOT NULL")`. Add own `FirstName`/`LastName`/`Email` + `Display*` computed properties.
187
+ - **Service:** Always Include User. Verify User exists + no duplicate on CreateAsync.
188
+ - **DTO (mandatory):** CreateDto requires `UserId`.
189
+ - **DTO (optional):** CreateDto requires person fields when `UserId` is null.
318
190
 
319
191
  ### MCP tenantMode Parameter
320
192
 
@@ -331,15 +203,11 @@ The old `isSystemEntity: true` still works and maps to `tenantMode: 'none'`.
331
203
  ## EF Configuration Pattern
332
204
 
333
205
  ```csharp
334
- using Microsoft.EntityFrameworkCore;
335
- using Microsoft.EntityFrameworkCore.Metadata.Builders;
336
-
337
206
  public class {Name}Configuration : IEntityTypeConfiguration<{Name}>
338
207
  {
339
208
  public void Configure(EntityTypeBuilder<{Name}> builder)
340
209
  {
341
210
  builder.ToTable("{prefix}{Name}s", "{schema}");
342
-
343
211
  builder.HasKey(x => x.Id);
344
212
 
345
213
  // Tenant (if ITenantEntity)
@@ -361,223 +229,20 @@ public class {Name}Configuration : IEntityTypeConfiguration<{Name}>
361
229
  .IsUnique()
362
230
  .HasDatabaseName("IX_{prefix}{Name}s_Tenant_Code");
363
231
 
364
- // Relationships
365
- // builder.HasMany(x => x.Children)
366
- // .WithOne(x => x.Parent)
367
- // .HasForeignKey(x => x.ParentId)
368
- // .OnDelete(DeleteBehavior.Restrict);
369
-
370
- // Seed data (if applicable)
371
- // builder.HasData({Name}SeedData.GetSeedData());
232
+ // Relationships — use .OnDelete(DeleteBehavior.Restrict)
372
233
  }
373
234
  }
374
235
  ```
375
236
 
376
237
  ---
377
238
 
378
- ## Service Pattern (tenant-scoped, required)
379
-
380
- > All services must inject `ICurrentUserService` + `ICurrentTenantService` and filter by `TenantId`. Missing TenantId is an OWASP A01 vulnerability.
381
-
382
- ```csharp
383
- using Microsoft.EntityFrameworkCore;
384
- using Microsoft.Extensions.Logging;
385
- using SmartStack.Application.Common.Interfaces.Identity;
386
- using SmartStack.Application.Common.Interfaces.Tenants;
387
- using SmartStack.Application.Common.Interfaces.Persistence;
388
-
389
- namespace {ProjectName}.Infrastructure.Services.{App}.{Module};
390
-
391
- public class {Name}Service : I{Name}Service
392
- {
393
- private readonly IExtensionsDbContext _db;
394
- private readonly ICurrentUserService _currentUser;
395
- private readonly ICurrentTenantService _currentTenant;
396
- private readonly ILogger<{Name}Service> _logger;
397
-
398
- public {Name}Service(
399
- IExtensionsDbContext db,
400
- ICurrentUserService currentUser,
401
- ICurrentTenantService currentTenant,
402
- ILogger<{Name}Service> logger)
403
- {
404
- _db = db;
405
- _currentUser = currentUser;
406
- _currentTenant = currentTenant;
407
- _logger = logger;
408
- }
409
-
410
- public async Task<PaginatedResult<{Name}ResponseDto>> GetAllAsync(
411
- string? search = null,
412
- int page = 1,
413
- int pageSize = 20,
414
- CancellationToken ct = default)
415
- {
416
- // Guard clause — throws 400 if no tenant context (e.g., missing X-Tenant-Slug header)
417
- var tenantId = _currentTenant.TenantId
418
- ?? throw new TenantContextRequiredException();
419
-
420
- var query = _db.{Name}s
421
- .Where(x => x.TenantId == tenantId) // Required tenant filter
422
- .AsNoTracking();
423
-
424
- // Search filter — enables EntityLookup on frontend
425
- if (!string.IsNullOrWhiteSpace(search))
426
- {
427
- query = query.Where(x =>
428
- x.Name.Contains(search) ||
429
- x.Code.Contains(search));
430
- }
431
-
432
- var totalCount = await query.CountAsync(ct);
433
- var items = await query
434
- .OrderBy(x => x.Name)
435
- .Skip((page - 1) * pageSize)
436
- .Take(pageSize)
437
- .Select(x => new {Name}ResponseDto(x.Id, x.Code, x.Name, x.CreatedAt))
438
- .ToListAsync(ct);
439
-
440
- return new PaginatedResult<{Name}ResponseDto>(items, totalCount, page, pageSize);
441
- }
442
-
443
- public async Task<{Name}ResponseDto?> GetByIdAsync(Guid id, CancellationToken ct)
444
- {
445
- var tenantId = _currentTenant.TenantId
446
- ?? throw new TenantContextRequiredException();
447
-
448
- return await _db.{Name}s
449
- .Where(x => x.Id == id && x.TenantId == tenantId) // Required
450
- .AsNoTracking()
451
- .Select(x => new {Name}ResponseDto(x.Id, x.Code, x.Name, x.CreatedAt))
452
- .FirstOrDefaultAsync(ct);
453
- }
454
-
455
- public async Task<{Name}ResponseDto> CreateAsync(Create{Name}Dto dto, CancellationToken ct)
456
- {
457
- var tenantId = _currentTenant.TenantId
458
- ?? throw new TenantContextRequiredException();
459
-
460
- var entity = {Name}.Create(
461
- tenantId: tenantId, // Required — never Guid.Empty
462
- code: dto.Code,
463
- name: dto.Name);
464
-
465
- entity.CreatedBy = _currentUser.UserId?.ToString();
466
-
467
- _db.{Name}s.Add(entity);
468
- await _db.SaveChangesAsync(ct);
469
-
470
- _logger.LogInformation("Created {Entity} {Id} for tenant {TenantId}",
471
- nameof({Name}), entity.Id, tenantId);
472
-
473
- return new {Name}ResponseDto(entity.Id, entity.Code, entity.Name, entity.CreatedAt);
474
- }
475
-
476
- public async Task DeleteAsync(Guid id, CancellationToken ct)
477
- {
478
- var tenantId = _currentTenant.TenantId
479
- ?? throw new TenantContextRequiredException();
480
-
481
- var entity = await _db.{Name}s
482
- .FirstOrDefaultAsync(x => x.Id == id && x.TenantId == tenantId, ct)
483
- ?? throw new KeyNotFoundException($"{Name} {id} not found");
484
-
485
- _db.{Name}s.Remove(entity);
486
- await _db.SaveChangesAsync(ct);
487
- }
488
- }
489
- ```
490
-
491
- ### Service Pattern — Entity with FK to another business entity
492
-
493
- When an entity has a FK to another business entity (e.g., `Absence.EmployeeId → Employee`), use navigation properties **directly inside `.Select()`**. EF Core translates navigation access to SQL JOINs automatically — `.Include()` is NOT needed when using `.Select()`.
494
-
495
- ```csharp
496
- // Example: Absence has FK EmployeeId → Employee
497
- public async Task<PaginatedResult<AbsenceResponseDto>> GetAllAsync(
498
- string? search = null, int page = 1, int pageSize = 20, CancellationToken ct = default)
499
- {
500
- var tenantId = _currentTenant.TenantId
501
- ?? throw new TenantContextRequiredException();
502
-
503
- var query = _db.Absences
504
- .Where(x => x.TenantId == tenantId)
505
- .AsNoTracking();
506
-
507
- if (!string.IsNullOrWhiteSpace(search))
508
- {
509
- query = query.Where(x =>
510
- x.Employee.Code.Contains(search) || // Navigate FK in Where — EF translates to JOIN
511
- x.Employee.User!.LastName.Contains(search));
512
- }
513
-
514
- // CORRECT: Inline construction — access FK navigation directly in Select()
515
- // EF Core generates the JOIN automatically. Do NOT use .Include() here.
516
- return await query
517
- .OrderByDescending(x => x.StartDate)
518
- .Select(x => new AbsenceResponseDto(
519
- x.Id, x.StartDate, x.EndDate, x.Type,
520
- x.EmployeeId, x.Employee.Code,
521
- x.Employee.User!.FirstName, x.Employee.User!.LastName))
522
- .ToPaginatedResultAsync(page, pageSize, ct);
523
- }
524
-
525
- // GetByIdAsync — same inline Select pattern
526
- public async Task<AbsenceResponseDto?> GetByIdAsync(Guid id, CancellationToken ct)
527
- {
528
- var tenantId = _currentTenant.TenantId
529
- ?? throw new TenantContextRequiredException();
530
-
531
- return await _db.Absences
532
- .Where(x => x.Id == id && x.TenantId == tenantId)
533
- .AsNoTracking()
534
- .Select(x => new AbsenceResponseDto(
535
- x.Id, x.StartDate, x.EndDate, x.Type,
536
- x.EmployeeId, x.Employee.Code,
537
- x.Employee.User!.FirstName, x.Employee.User!.LastName))
538
- .FirstOrDefaultAsync(ct);
539
- }
540
-
541
- // CreateAsync — use .Include() AFTER materialization (need full entity for response)
542
- public async Task<AbsenceResponseDto> CreateAsync(CreateAbsenceDto dto, CancellationToken ct)
543
- {
544
- var tenantId = _currentTenant.TenantId
545
- ?? throw new TenantContextRequiredException();
546
-
547
- // Verify FK target exists in same tenant
548
- var employeeExists = await _db.Employees
549
- .AnyAsync(e => e.Id == dto.EmployeeId && e.TenantId == tenantId, ct);
550
- if (!employeeExists)
551
- throw new KeyNotFoundException($"Employee {dto.EmployeeId} not found");
552
-
553
- var entity = Absence.Create(tenantId, dto.EmployeeId, dto.StartDate, dto.EndDate, dto.Type);
554
- entity.CreatedBy = _currentUser.UserId?.ToString();
555
-
556
- _db.Absences.Add(entity);
557
- await _db.SaveChangesAsync(ct);
558
-
559
- // Reload with FK navigation for response
560
- var created = await _db.Absences
561
- .Include(x => x.Employee).ThenInclude(e => e.User)
562
- .FirstAsync(x => x.Id == entity.Id, ct);
563
-
564
- return new AbsenceResponseDto(
565
- created.Id, created.StartDate, created.EndDate, created.Type,
566
- created.EmployeeId, created.Employee.Code,
567
- created.Employee.User!.FirstName, created.Employee.User!.LastName);
568
- }
569
- ```
239
+ ## Service Pattern (guard rails)
570
240
 
571
- > **Rules for FK inter-entity services:**
572
- > - **GetAllAsync / GetByIdAsync:** Use `.Select()` with inline navigation access (e.g., `x.Employee.Code`). Do NOT use `.Include()` — it is ignored when `.Select()` is present. EF Core generates the JOIN from the navigation path.
573
- > - **CreateAsync / UpdateAsync:** Use `.Include()` AFTER `SaveChangesAsync()` to reload the entity with its FK navigation for the response DTO.
574
- > - **FK validation:** Always verify the FK target exists in the same tenant before creating/updating.
575
- > - **Navigation depth:** Limit to 1-2 levels (e.g., `x.Employee.User.LastName`). Deeper navigation generates complex JOINs — consider denormalization if needed.
576
- > - **Search:** Include FK display fields in the search filter (e.g., `x.Employee.Code.Contains(search)`).
241
+ > MCP `scaffold_extension` generates complete services. These are the mandatory conventions.
577
242
 
578
- **Key interfaces (from SmartStack NuGet package):**
579
- - `ICurrentUserService` (from `SmartStack.Application.Common.Interfaces.Identity`): provides `UserId` (Guid?), `Email` (string?), `IsAuthenticated` (bool)
580
- - `ICurrentTenantService` (from `SmartStack.Application.Common.Interfaces.Tenants`): provides `TenantId` (Guid?), `HasTenant` (bool), `TenantSlug` (string?)
243
+ **Key interfaces:**
244
+ - `ICurrentUserService` (`SmartStack.Application.Common.Interfaces.Identity`): `UserId` (Guid?), `Email` (string?), `IsAuthenticated` (bool)
245
+ - `ICurrentTenantService` (`SmartStack.Application.Common.Interfaces.Tenants`): `TenantId` (Guid?), `HasTenant` (bool), `TenantSlug` (string?)
581
246
  - `IExtensionsDbContext` (for client extensions) or `ICoreDbContext` (for platform)
582
247
 
583
248
  **Guard clause (first line of every method):**
@@ -585,273 +250,136 @@ public async Task<AbsenceResponseDto> CreateAsync(CreateAbsenceDto dto, Cancella
585
250
  var tenantId = _currentTenant.TenantId
586
251
  ?? throw new TenantContextRequiredException();
587
252
  ```
588
- This converts a null TenantId into a clean 400 Bad Request response via `GlobalExceptionHandlerMiddleware`.
589
- **IMPORTANT:** Uses `TenantContextRequiredException` (400), NOT `UnauthorizedAccessException` (401). A missing tenant is a bad request, not an auth failure the JWT is valid, `[Authorize]` passed.
590
-
591
- Do not use in services:
592
- - `_currentTenant.TenantId!.Value` — throws `InvalidOperationException` (500) instead of clean 400
593
- - `UnauthorizedAccessException("Tenant context is required")` — throws 401, triggers frontend token clearing
594
- - `tenantId: Guid.Empty` — always use validated tenantId from guard clause
595
- - Queries WITHOUT `.Where(x => x.TenantId == tenantId)` — data leak
253
+ Returns clean 400 Bad Request via `GlobalExceptionHandlerMiddleware`.
254
+ Uses `TenantContextRequiredException` (400), NOT `UnauthorizedAccessException` (401 — clears frontend token).
255
+
256
+ **Do NOT use in services:**
257
+ - `_currentTenant.TenantId!.Value` — throws 500 instead of clean 400
258
+ - `UnauthorizedAccessException("Tenant context is required")` — returns 401, triggers frontend token clearing
259
+ - `tenantId: Guid.Empty` — always use validated `_currentTenant.TenantId`
260
+ - Queries WITHOUT `.Where(x => x.TenantId == tenantId)` — data leak (OWASP A01)
261
+ - `ICurrentUser` (does NOT exist) — use `ICurrentUserService` + `ICurrentTenantService`
596
262
  - Missing `ILogger<T>` — undiagnosable in production
597
- - Using `ICurrentUser` (does NOT exist) — use `ICurrentUserService` + `ICurrentTenantService`
263
+
264
+ ### FK Inter-Entity Service Rules
265
+
266
+ When an entity has a FK to another business entity (e.g., `Absence.EmployeeId → Employee`):
267
+
268
+ - **GetAllAsync / GetByIdAsync:** Use `.Select()` with inline navigation access (e.g., `x.Employee.Code`). Do NOT use `.Include()` — it is ignored when `.Select()` is present. EF Core generates JOINs from navigation paths.
269
+ - **CreateAsync / UpdateAsync:** Use `.Include()` AFTER `SaveChangesAsync()` to reload entity with FK navigation for response DTO.
270
+ - **FK validation:** Always verify FK target exists in same tenant before create/update.
271
+ - **Search:** Include FK display fields in search filter (e.g., `x.Employee.Code.Contains(search)`).
272
+ - **Navigation depth:** Limit to 1-2 levels (e.g., `x.Employee.User!.LastName`). Deeper = complex JOINs.
273
+
274
+ > **IQueryable .Select() constraint:** NEVER call helper methods (e.g., `MapToDto(x)`) inside `.Select()` on IQueryable — EF Core cannot translate to SQL → runtime `InvalidOperationException`. Use inline DTO construction only. Helpers are fine AFTER materialization (`ToListAsync`).
275
+
276
+ > **FK navigation in `.Select()` is safe:** `.Select(x => new Dto(x.Id, x.Employee.Code, x.Employee.User!.LastName))` — EF Core translates to SQL JOINs.
598
277
 
599
278
  ---
600
279
 
601
280
  ## Controller Pattern (NavRoute)
602
281
 
603
- ```csharp
604
- using Microsoft.AspNetCore.Authorization;
605
- using Microsoft.AspNetCore.Mvc;
606
- using SmartStack.Api.Routing;
607
- using SmartStack.Api.Authorization;
608
-
609
- namespace {ProjectName}.Api.Controllers.{App};
282
+ > `/controller` skill or MCP generates complete controllers. These are the mandatory conventions.
610
283
 
284
+ ```csharp
611
285
  [ApiController]
612
- [NavRoute("{app}.{module}")]
613
- [Authorize]
286
+ [NavRoute("{app}.{module}")] // ONLY route attribute — resolves from DB at startup
287
+ [Microsoft.AspNetCore.Authorization.Authorize]
288
+ [Produces("application/json")]
289
+ [Tags("{NamePlural}")]
614
290
  public class {Name}Controller : ControllerBase
615
291
  {
616
- private readonly I{Name}Service _service;
617
- private readonly ILogger<{Name}Controller> _logger;
292
+ private readonly ISender _mediator; // MediatR — NOT I{Name}Service
618
293
 
619
- public {Name}Controller(I{Name}Service service, ILogger<{Name}Controller> logger)
620
- {
621
- _service = service;
622
- _logger = logger;
623
- }
294
+ public {Name}Controller(ISender mediator) => _mediator = mediator;
624
295
 
625
296
  [HttpGet]
626
- [RequirePermission(Permissions.{Module}.Read)]
627
- public async Task<ActionResult<PaginatedResult<{Name}ResponseDto>>> GetAll(
628
- [FromQuery] string? search = null,
629
- [FromQuery] int page = 1,
630
- [FromQuery] int pageSize = 20,
631
- CancellationToken ct = default)
632
- => Ok(await _service.GetAllAsync(search, page, pageSize, ct));
633
-
634
- [HttpGet("{id:guid}")]
635
- [RequirePermission(Permissions.{Module}.Read)]
636
- public async Task<ActionResult<{Name}ResponseDto>> GetById(Guid id, CancellationToken ct)
637
- {
638
- var result = await _service.GetByIdAsync(id, ct);
639
- return result is null ? NotFound() : Ok(result);
640
- }
297
+ [RequirePermission(Permissions.{Module}.{NamePlural}.View)]
298
+ [ProducesResponseType(typeof(List<{Name}ListDto>), StatusCodes.Status200OK)]
299
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
300
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
301
+ public async Task<ActionResult<List<{Name}ListDto>>> GetAll(CancellationToken ct)
302
+ => Ok(await _mediator.Send(new Get{NamePlural}Query(), ct));
641
303
 
642
- [HttpPost]
643
- [RequirePermission(Permissions.{Module}.Create)]
644
- public async Task<ActionResult<{Name}ResponseDto>> Create([FromBody] Create{Name}Dto dto, CancellationToken ct)
645
- {
646
- var result = await _service.CreateAsync(dto, ct);
647
- return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
648
- }
649
-
650
- [HttpPut("{id:guid}")]
651
- [RequirePermission(Permissions.{Module}.Update)]
652
- public async Task<ActionResult<{Name}ResponseDto>> Update(Guid id, [FromBody] Update{Name}Dto dto, CancellationToken ct)
653
- {
654
- var result = await _service.UpdateAsync(id, dto, ct);
655
- return result is null ? NotFound() : Ok(result);
656
- }
657
-
658
- [HttpDelete("{id:guid}")]
659
- [RequirePermission(Permissions.{Module}.Delete)]
660
- public async Task<ActionResult> Delete(Guid id, CancellationToken ct)
661
- {
662
- await _service.DeleteAsync(id, ct);
663
- return NoContent();
664
- }
304
+ // GetById, Create (CreatedAtAction), Update, Delete (NoContent)
665
305
  }
666
306
  ```
667
307
 
668
- Route attribute rules:
669
- - `[NavRoute]` is the ONLY route attribute needed it resolves routes dynamically from Navigation entities at startup
670
- - Do not use `[Route("api/...")]` alongside `[NavRoute]` causes route conflicts and 404s at runtime
671
- - Do not use `[Route("api/[controller]")]` — this is standard ASP.NET Core, NOT SmartStack
672
- - If a controller has `[NavRoute]`, there must be NO `[Route]` attribute on the class
673
-
674
- Use `[RequirePermission(Permissions.{Module}.{Action})]` on every endpoint — do not use `[Authorize]` alone (no RBAC enforcement).
675
-
676
- Permission paths must use identical segments to NavRoute codes (kebab-case):
677
- - NavRoute: `human-resources.employees` → Permission: `human-resources.employees.read`
678
- - NavRoute: `human-resources.employees.leaves` → Permission: `human-resources.employees.leaves.read`
679
- - Do not use: `humanresources.employees.read` (no kebab-case — mismatches NavRoute)
680
- - SmartStack.app convention: `support-client.my-tickets.read` (always kebab-case)
681
-
682
- ### Section-Level Controller (NavRoute with 3 segments)
308
+ **Key conventions:**
309
+ - `ISender _mediator` (MediatR)NOT `I{Name}Service` or `IApplicationDbContext`
310
+ - `[Microsoft.AspNetCore.Authorization.Authorize]` fully qualified (not `[Authorize]` with using)
311
+ - `[Tags("...")]` and `[Produces("application/json")]` — always present
312
+ - `[RequirePermission(Permissions.{Module}.{Resource}.View)]` permission constants, not string literals
313
+ - `[ProducesResponseType]` with `StatusCodes.*` constants — always include 401/403
314
+ - Return types: `List<ListDto>` (GetAll), `DetailDto` (GetById/Create/Update), `IActionResult` (Delete)
683
315
 
684
- When a module has sections, each section gets its own controller with a 3-segment navRoute:
316
+ **NavRoute rules:**
317
+ - `[NavRoute]` is the ONLY route attribute — do NOT combine with `[Route("api/...")]` (causes 404s)
318
+ - `[RequirePermission]` on every endpoint — `[Authorize]` alone has no RBAC
319
+ - Permission paths must match NavRoute kebab-case: `human-resources.employees.read`
320
+ - Namespace: `SmartStack.Api.Routing` (NOT `SmartStack.Api.Core.Routing`)
321
+ - NavRoute resolves at startup from DB: `administration.users` → `api/administration/users`
322
+ - `CustomSegment` support: `[NavRoute("...", CustomSegment = "user/dashboard")]` for custom URL segments
685
323
 
686
- ```csharp
687
- // Section-level controller: navRoute has 3 segments
688
- [ApiController]
689
- [NavRoute("{app}.{module}.{section}")]
690
- [Authorize]
691
- public class {Section}Controller : ControllerBase
692
- {
693
- // Example: human-resources.employees.departments
694
- [HttpGet]
695
- [RequirePermission(Permissions.{Section}.Read)]
696
- public async Task<ActionResult<PaginatedResult<{Section}ResponseDto>>> GetAll(
697
- [FromQuery] string? search = null,
698
- [FromQuery] int page = 1,
699
- [FromQuery] int pageSize = 20,
700
- CancellationToken ct = default)
701
- => Ok(await _service.GetAllAsync(search, page, pageSize, ct));
702
- }
703
- ```
324
+ **NavRoute segment rules:**
704
325
 
705
- **NavRoute segment rules (minimum 2 segments):**
706
- | Level | NavRoute format | Example |
707
- |-------|----------------|---------|
326
+ | Level | Format | Example |
327
+ |-------|--------|---------|
708
328
  | Module | `{app}.{module}` (2 segments) | `administration.users` |
709
329
  | Section | `{app}.{module}.{section}` (3 segments) | `administration.users.groups` |
710
- | Sub-resource (Suffix) | `{app}.{module}` + Suffix | `administration.ai` + `Suffix = "prompts"` |
711
-
712
- > **POST-CONTEXT REMOVAL:** The old "context" level (1st segment) has been removed.
713
- > Before: `platform.administration.users` (3 segments). After: `administration.users` (2 segments).
714
- > The NavigationRouteRegistryBuilder builds 2-segment paths from DB (application.module).
715
- > Controllers with 3+ segments use the fallback route generator (dots → slashes).
716
-
717
- **Namespace:** `SmartStack.Api.Routing` (NOT `SmartStack.Api.Core.Routing`)
718
-
719
- **NavRoute resolves at startup from DB:** `administration.users` → `api/administration/users`
720
-
721
- ### Sub-Resource Pattern (NavRoute Suffix)
722
-
723
- When an entity is a child of another entity (e.g., AiPrompts under AI), use `[NavRoute(..., Suffix = "...")]`:
330
+ | Sub-resource | `{app}.{module}` + `Suffix` | `[NavRoute("administration.ai", Suffix = "prompts")]` |
724
331
 
725
- ```csharp
726
- // Sub-resource controller: prompts are nested under AI module
727
- [ApiController]
728
- [NavRoute("administration.ai", Suffix = "prompts")]
729
- [Authorize]
730
- public class AiPromptsController : ControllerBase
731
- {
732
- [HttpGet]
733
- [RequirePermission(Permissions.Ai.Prompts.Read)]
734
- public async Task<ActionResult<PaginatedResult<AiPromptResponseDto>>> GetAll(...)
735
- => Ok(await _service.GetAllAsync(search, page, pageSize, ct));
736
- }
737
- ```
332
+ > POST-CONTEXT REMOVAL: Old "context" level removed. 2-segment minimum. `NavigationRouteRegistryBuilder` builds 2-segment paths from DB. 3+ segments use fallback route generator.
738
333
 
739
- **Alternative pattern** (sub-resource endpoints within parent controller):
740
- ```csharp
741
- // LeaveTypes as endpoints within LeavesController
742
- [HttpGet("types")]
743
- [RequirePermission(Permissions.Leaves.Read)]
744
- public async Task<ActionResult<PaginatedResult<LeaveTypeResponseDto>>> GetAllLeaveTypes(...)
745
- ```
746
-
747
- > Sub-resource frontend completeness:
748
- > If a parent page has a button (e.g., "Manage Leave Types") that `navigate()`s to a sub-resource route,
749
- > the frontend must include a page component for that route. Otherwise → dead link → white screen.
750
- > - Either create a dedicated sub-resource ListPage (e.g., `LeaveTypesPage.tsx`)
751
- > - Or do not include the navigate() button if pages won't be created
752
- > - Prefer separate controllers (with Suffix) over sub-endpoints in parent controller — easier to route
334
+ **Sub-resource completeness:** If a parent page has a navigate() to a sub-resource route, the frontend MUST include a page for that route. Otherwise → dead link → white screen. Prefer separate controllers (with Suffix) over sub-endpoints in parent controller.
753
335
 
754
336
  ---
755
337
 
756
- ## Navigation Seed Data Pattern (routes must be full paths)
757
-
758
- > **The navigation seed data defines menu routes stored in DB. These routes MUST be full paths starting with `/`.**
759
- > Short routes (e.g., `humanresources`) cause 400 Bad Request on application-tracking.
760
-
761
- ### Route Convention
338
+ ## Navigation Seed Data (routes must be full paths)
762
339
 
763
340
  | Level | Route Format | Example |
764
341
  |-------|-------------|---------|
765
342
  | Application | `/{app-kebab}` | `/human-resources` |
766
343
  | Module | `/{app-kebab}/{module-kebab}` | `/human-resources/employees` |
767
344
  | Section | `/{app-kebab}/{module-kebab}/{section-kebab}` | `/human-resources/employees/departments` |
768
- | Resource | `/{app-kebab}/{module-kebab}/{section-kebab}/{resource-kebab}` | `/human-resources/employees/departments/export` |
769
345
 
770
- **Route special cases (list and detail sections):**
771
- > The `list` and `detail` sections are NOT functional sub-areas — they are view modes of the module itself.
772
- > Their navigation routes must NOT add extra segments:
773
- > - `list` section route = module route (e.g., `/human-resources/employees`)
774
- > - `detail` section route = module route + `/:id` (e.g., `/human-resources/employees/:id`)
775
- > - Do not use: `/employees/list`, `/employees/detail/:id`
776
- > - Other sections (dashboard, approve, import, etc.) = module route + `/{section-kebab}` (normal behavior)
346
+ **Route special cases:** `list` and `detail` sections are view modes, NOT sub-areas:
347
+ - `list` route = module route (e.g., `/human-resources/employees`)
348
+ - `detail` route = module route + `/:id`
349
+ - Do NOT use: `/employees/list`, `/employees/detail/:id`
777
350
 
778
351
  **Rules:**
779
- - Routes ALWAYS start with `/`
780
- - Routes ALWAYS include the full hierarchy from application to current level
781
- - Routes ALWAYS use kebab-case (NOT PascalCase, NOT camelCase)
782
- - Code identifiers stay PascalCase in C# (`HumanResources`) but routes are kebab-case (`human-resources`)
783
-
784
- ### ToKebabCase Helper (include in SeedConstants or SeedDataProvider)
352
+ - Routes ALWAYS start with `/`, include full hierarchy, use kebab-case
353
+ - Code identifiers: PascalCase in C# (`HumanResources`), kebab-case in routes (`human-resources`)
785
354
 
355
+ **ToKebabCase helper:**
786
356
  ```csharp
787
357
  private static string ToKebabCase(string value)
788
- => System.Text.RegularExpressions.Regex
789
- .Replace(value, "([a-z])([A-Z])", "$1-$2")
790
- .ToLowerInvariant();
358
+ => Regex.Replace(value, "([a-z])([A-Z])", "$1-$2").ToLowerInvariant();
791
359
  ```
792
360
 
793
- ### GUID Generation Rule
794
-
795
- ```csharp
796
- // ALWAYS use Guid.NewGuid() for ALL seed data IDs
797
- // Avoids conflicts between projects/tenants/environments
798
- // Idempotence is handled by Code-based lookups at runtime
799
- // Navigation entities are resolved by Code, not by fixed GUIDs
800
- ```
801
-
802
- ### Navigation Seed Data Example
803
-
804
- ```csharp
805
- // Application: /human-resources
806
- var app = NavigationApplication.Create(
807
- "human-resources", "Human Resources", "HR Management",
808
- "Users", IconType.Lucide,
809
- "/human-resources", // FULL PATH — starts with /, kebab-case
810
- 10);
811
-
812
- // Module: /human-resources/employees
813
- var module = NavigationModule.Create(
814
- app.Id, "employees", "Employees", "Employee management",
815
- "UserCheck", IconType.Lucide,
816
- "/human-resources/employees", // FULL PATH — includes parent
817
- 10);
818
-
819
- // Section: /human-resources/employees/departments
820
- var section = NavigationSection.Create(
821
- module.Id, "departments", "Departments", "Manage departments",
822
- "Building2", IconType.Lucide,
823
- "/human-resources/employees/departments", // FULL PATH
824
- 10);
825
- ```
826
-
827
- ### Avoid in Seed Data
361
+ **GUID rule:** Always `Guid.NewGuid()` for seed data IDs. Idempotence via Code-based lookups at runtime.
828
362
 
829
363
  | Mistake | Reality |
830
364
  |---------|---------|
831
365
  | `"humanresources"` as route | Must be `"/human-resources"` (full path, kebab-case) |
832
366
  | `"employees"` as route | Must be `"/human-resources/employees"` (includes parent) |
833
- | Deterministic/sequential/fixed GUIDs in seed data | Use `Guid.NewGuid()` instead |
367
+ | Fixed/sequential GUIDs | Use `Guid.NewGuid()` |
834
368
  | Missing translations | Must have 4 languages: fr, en, it, de |
835
369
  | Missing NavigationApplicationSeedData | Menu invisible without Application level |
836
370
 
837
371
  ---
838
372
 
839
- ## DbContext Pattern (extensions)
373
+ ## DbContext Pattern
840
374
 
841
375
  ```csharp
842
- // In IExtensionsDbContext.cs:
843
- public DbSet<{Name}> {Name}s => Set<{Name}>();
844
-
845
- // In ExtensionsDbContext.cs (same line):
376
+ // In IExtensionsDbContext.cs AND ExtensionsDbContext.cs:
846
377
  public DbSet<{Name}> {Name}s => Set<{Name}>();
847
378
  ```
848
379
 
849
- ---
850
-
851
- ## DI Registration Pattern
380
+ ## DI Registration
852
381
 
853
382
  ```csharp
854
- // In DependencyInjection.cs or ServiceCollectionExtensions.cs:
855
383
  services.AddScoped<I{Name}Service, {Name}Service>();
856
384
  services.AddValidatorsFromAssemblyContaining<Create{Name}DtoValidator>();
857
385
  ```
@@ -860,418 +388,93 @@ services.AddValidatorsFromAssemblyContaining<Create{Name}DtoValidator>();
860
388
 
861
389
  ## DTO Type Mapping
862
390
 
863
- > **Use the correct .NET type for each property.** Incorrect types cause runtime parsing errors.
864
-
865
391
  | Property Pattern | .NET Type | JSON Format | Example |
866
392
  |-----------------|-----------|-------------|---------|
867
- | `*Date`, `StartDate`, `EndDate`, `BirthDate` | `DateOnly` | `"2025-03-15"` | `public DateOnly Date { get; set; }` |
393
+ | `*Date`, `StartDate`, `BirthDate` | `DateOnly` | `"2025-03-15"` | `public DateOnly Date { get; set; }` |
868
394
  | `CreatedAt`, `UpdatedAt` | `DateTime` | `"2025-03-15T10:30:00Z"` | `public DateTime CreatedAt { get; set; }` |
869
395
  | `*Time`, `StartTime` | `TimeOnly` | `"14:30:00"` | `public TimeOnly StartTime { get; set; }` |
870
396
  | Duration, hours | `decimal` | `8.5` | `public decimal HoursWorked { get; set; }` |
871
397
  | FK reference | `Guid` | `"uuid-string"` | `public Guid EmployeeId { get; set; }` |
872
398
 
873
- Do not use in DTOs:
874
- - `string Date` / `string StartDate` — use `DateOnly`
875
- - `string Time` — use `TimeOnly`
876
- - `DateTime BirthDate` — use `DateOnly` (no time component needed)
877
- - `int` for hours/duration — use `decimal` for fractional values
878
-
879
- ---
880
-
881
- ## Common Mistakes to Avoid
882
-
883
- | Mistake | Reality |
884
- |---------|---------|
885
- | `entity.SoftDelete()` | Does NOT exist — no soft delete in BaseEntity |
886
- | `entity.Code` inherited | Code is a business property — add it yourself |
887
- | `e.RowVersion` in config | Does NOT exist in BaseEntity |
888
- | `e.IsDeleted` filter | Does NOT exist — no soft delete |
889
- | `SmartStack.Api.Core.Routing` | Wrong — use `SmartStack.Api.Routing` |
890
- | `SystemEntity` base class | Does NOT exist — use `BaseEntity` for all |
891
- | `[Route("api/...")] + [NavRoute]` | Do not combine — causes 404s. Only `[NavRoute]` needed (resolves route from DB at startup). Remove `[Route]` when `[NavRoute]` is present. |
892
- | `SmartStack.Domain.Common.Interfaces` | Wrong — interfaces are in `SmartStack.Domain.Common` directly |
893
- | `[Authorize]` without `[RequirePermission]` | No RBAC enforcement — always use `[RequirePermission]` |
894
- | `tenantId: Guid.Empty` in services | OWASP A01 — always use validated `_currentTenant.TenantId` |
895
- | Service without `ICurrentTenantService` | All tenant data leaks — inject `ICurrentTenantService` |
896
- | `ICurrentUser` in service code | Does NOT exist — use `ICurrentUserService` + `ICurrentTenantService` |
897
- | `_currentTenant.TenantId!.Value` | Crashes with 500 — use `?? throw new TenantContextRequiredException()` |
898
- | `UnauthorizedAccessException("Tenant context is required")` | Returns 401 → clears frontend token. Use `TenantContextRequiredException()` (400) |
899
- | Route `"humanresources"` in seed data | Must be full path `"/human-resources"` |
900
- | Route without leading `/` | All routes must start with `/` |
901
- | `humanresources.employees.read` in permissions | Permission segments MUST match NavRoute kebab-case: `human-resources.employees.read` |
902
- | `Permission.Create()` | Does NOT exist — use `CreateForModule()`, `CreateForSection()`, etc. |
903
- | `GetAllAsync()` without search param | ALL GetAll endpoints MUST support `?search=` for EntityLookup |
904
- | `string Date` in DTO | Date-only fields must use `DateOnly`, not `string` |
905
- | `DateTime` for date-only | Use `DateOnly` when no time component needed |
906
- | FK field as plain text input | Frontend MUST use `EntityLookup` component for Guid FK fields |
907
- | `PagedResult<T>` / `PaginatedResultDto<T>` | Use `PaginatedResult<T>` instead |
908
- | Controller injects `DbContext` | Create an Application service and inject it instead (Clean Architecture) |
909
- | Domain entity has `[Table]` attribute | Infrastructure concern in Domain — move to `IEntityTypeConfiguration<T>` in Infrastructure |
910
- | `using Microsoft.EntityFrameworkCore` in Domain | EF Core belongs in Infrastructure, not Domain — Domain must be persistence-ignorant |
911
- | Controller returns `Employee` entity | Domain leak in API response — return `EmployeeResponseDto` instead |
912
- | `IEmployeeService.cs` in Infrastructure | Interface belongs in Application — move to `Application/Interfaces/` |
913
- | Service implementation in Application layer | Implementations belong in Infrastructure — Application only contains interfaces |
914
- | Controller injects `IRepository<T>` directly | Controllers use Application services, not repositories — add a service layer |
915
- | `FirstName`/`LastName`/`Email` on mandatory person extension entity | These come from User — use `Display*` computed properties from the User join |
916
- | Missing `Include(x => x.User)` on person extension service | Person extension entities MUST always include User for display fields |
917
- | Missing unique index on `(TenantId, UserId)` for person extension | One person-role per User per Tenant — add unique constraint in EF config |
918
-
919
- ---
920
-
921
- ## External Application & Data Export Pattern
922
-
923
- ### Entities
924
-
925
- | Entity | Table | Description |
926
- |--------|-------|-------------|
927
- | `ExternalApplication` | `auth_ExternalApplications` | Machine-to-machine API account (ClientId, ClientSecret, IsActive, IP whitelist) |
928
- | `ExternalApplicationRole` | `auth_ExternalApplicationRoles` | Role assignment per app (AppId, RoleId, optional TenantId) |
929
- | `ExternalApplicationExportAccess` | `auth_ExternalApplicationExportAccesses` | Per-app access to specific export endpoint (IsEnabled, RateLimitPerMinute, MaxPageSize) |
930
- | `DataExportEndpoint` | `auth_DataExportEndpoints` | Registry of available export APIs (Code, RouteTemplate, RequiredPermission, EntityType) |
931
- | `ExternalAppAuditLog` | `auth_ExternalAppAuditLogs` | Audit trail for all API calls (Authentication, DataExport actions) |
932
-
933
- ### Architecture (3-layer security)
934
-
935
- 1. **Authentication** — JWT assertion signed with ClientSecret → ExternalApplicationAuthService validates → generates SmartStack JWT with permissions resolved via ExternalApplicationRole → Role → RolePermission chain
936
- 2. **Authorization** — RequirePermissionFilter checks JWT claims (e.g., `administration.users.export`)
937
- 3. **Access Control** — DataExportAccessMiddleware verifies per-app endpoint access in ExternalApplicationExportAccess table
938
-
939
- ### Rate Limiting
940
-
941
- ExternalAppRateLimitPolicy resolves limits: app override → endpoint default → 60/min fallback.
942
- Partition key: `{clientId}:{endpointCode}`.
943
-
944
- ### Seed Data
945
-
946
- DataExportEndpoints are seeded in DataExportEndpointConfiguration.cs with FK to NavigationApplication and NavigationModule. Each endpoint maps to a controller in `Controllers/DataExport/v1/`.
947
-
948
- ### Controller Pattern
949
-
950
- Export controllers: `[Route("api/v1/export")]` + `[RequirePermission]` + `[EnableRateLimiting]`
951
-
952
- **Query Parameters (all export endpoints except Navigation):**
953
- - `tenantId` (UUID, required) — Scopes the export to the specified tenant's data
954
-
955
- Example: `GET /api/v1/export/users?tenantId=550e8400-e29b-41d4-a716-446655440000&page=1&pageSize=50`
956
-
957
- Management controllers: `[NavRoute("api.accounts")]` with CustomSegment
399
+ Do NOT use: `string Date` (use `DateOnly`), `DateTime BirthDate` (use `DateOnly`), `int` for hours (use `decimal`).
958
400
 
959
401
  ---
960
402
 
961
- ## PaginatedResult Pattern
962
-
963
- > **Canonical type for ALL paginated responses.** One name, one contract, everywhere.
403
+ ## PaginatedResult
964
404
 
965
- ### Definition (Backend`SmartStack.Application.Common.Models`)
405
+ > **Canonical typeone name, one contract, everywhere.**
966
406
 
967
407
  ```csharp
968
- namespace SmartStack.Application.Common.Models;
969
-
970
- public record PaginatedResult<T>(
971
- List<T> Items,
972
- int TotalCount,
973
- int Page,
974
- int PageSize)
408
+ // SmartStack.Application.Common.Models
409
+ public record PaginatedResult<T>(List<T> Items, int TotalCount, int Page, int PageSize)
975
410
  {
976
- public int TotalPages => PageSize > 0
977
- ? (int)Math.Ceiling((double)TotalCount / PageSize) : 0;
411
+ public int TotalPages => PageSize > 0 ? (int)Math.Ceiling((double)TotalCount / PageSize) : 0;
978
412
  public bool HasPreviousPage => Page > 1;
979
413
  public bool HasNextPage => Page < TotalPages;
980
-
981
- public static PaginatedResult<T> Empty(int page = 1, int pageSize = 20)
982
- => new([], 0, page, pageSize);
414
+ public static PaginatedResult<T> Empty(int page = 1, int pageSize = 20) => new([], 0, page, pageSize);
983
415
  }
984
- ```
985
416
 
986
- ### Extension Method
987
-
988
- ```csharp
989
- namespace SmartStack.Application.Common.Extensions;
990
-
991
- public static class QueryableExtensions
992
- {
993
- public const int MaxPageSize = 100;
994
-
995
- public static async Task<PaginatedResult<T>> ToPaginatedResultAsync<T>(
996
- this IQueryable<T> query,
997
- int page = 1,
998
- int pageSize = 20,
999
- CancellationToken ct = default)
1000
- {
1001
- page = Math.Max(1, page);
1002
- pageSize = Math.Clamp(pageSize, 1, MaxPageSize);
1003
-
1004
- var totalCount = await query.CountAsync(ct);
1005
- var items = await query
1006
- .Skip((page - 1) * pageSize)
1007
- .Take(pageSize)
1008
- .ToListAsync(ct);
1009
-
1010
- return new PaginatedResult<T>(items, totalCount, page, pageSize);
1011
- }
1012
- }
417
+ // Extension method — SmartStack.Application.Common.Extensions
418
+ public static Task<PaginatedResult<T>> ToPaginatedResultAsync<T>(
419
+ this IQueryable<T> query, int page = 1, int pageSize = 20, CancellationToken ct = default)
420
+ // Implementation: Math.Max(1, page), Math.Clamp(pageSize, 1, 100), CountAsync, Skip/Take, ToListAsync
1013
421
  ```
1014
422
 
1015
- ### Usage in Service (with search + extension method)
1016
-
1017
- ```csharp
1018
- public async Task<PaginatedResult<{Name}ResponseDto>> GetAllAsync(
1019
- string? search = null, int page = 1, int pageSize = 20, CancellationToken ct = default)
1020
- {
1021
- var tenantId = _currentTenant.TenantId
1022
- ?? throw new TenantContextRequiredException();
1023
-
1024
- var query = _db.{Name}s
1025
- .Where(x => x.TenantId == tenantId)
1026
- .AsNoTracking();
1027
-
1028
- if (!string.IsNullOrWhiteSpace(search))
1029
- query = query.Where(x => x.Name.Contains(search) || x.Code.Contains(search));
1030
-
1031
- return await query
1032
- .OrderBy(x => x.Name)
1033
- .Select(x => new {Name}ResponseDto(x.Id, x.Code, x.Name, x.CreatedAt))
1034
- .ToPaginatedResultAsync(page, pageSize, ct);
1035
- }
1036
- ```
1037
-
1038
- > **WARNING — IQueryable .Select() constraints:**
1039
- > Inside `.Select()` on an `IQueryable` (before `ToListAsync`/`FirstAsync`), use ONLY inline DTO construction:
1040
- > `.Select(x => new ResponseDto(x.Id, x.Code, x.Name))`
1041
- >
1042
- > NEVER call a helper method (e.g., `MapToDto(x)`) inside `.Select()` — EF Core cannot translate method calls to SQL → runtime `InvalidOperationException`.
1043
- > Helper methods are fine AFTER materialization (`ToListAsync`, `FirstAsync`, `SaveChangesAsync`).
1044
- >
1045
- > **FK navigation in `.Select()` is safe:** `.Select(x => new Dto(x.Id, x.Employee.Code, x.Employee.User!.LastName))` — EF Core translates navigation property access to SQL JOINs. `.Include()` is NOT needed and is ignored when `.Select()` is used.
1046
-
1047
- ### Frontend Types (`@/types/pagination.ts`)
1048
-
1049
- ```typescript
1050
- export interface PaginatedResult<T> {
1051
- items: T[];
1052
- totalCount: number;
1053
- page: number;
1054
- pageSize: number;
1055
- totalPages: number;
1056
- hasPreviousPage: boolean;
1057
- hasNextPage: boolean;
1058
- }
1059
-
1060
- export interface PaginationParams {
1061
- page?: number;
1062
- pageSize?: number;
1063
- search?: string;
1064
- sortBy?: string;
1065
- sortDirection?: 'asc' | 'desc';
1066
- }
1067
- ```
1068
-
1069
- ### Type Names to Avoid
423
+ **Frontend type:** `PaginatedResult<T>` with `items`, `totalCount`, `page`, `pageSize`, `totalPages`, `hasPreviousPage`, `hasNextPage`.
1070
424
 
1071
425
  | Avoid | Use Instead |
1072
426
  |-------|------------|
1073
- | `PagedResult<T>` | `PaginatedResult<T>` |
1074
- | `PaginatedResultDto<T>` | `PaginatedResult<T>` |
1075
- | `PaginatedResponse<T>` | `PaginatedResult<T>` |
1076
- | `PageResultDto<T>` | `PaginatedResult<T>` |
1077
- | `PaginatedRequest` | `PaginationParams` |
1078
- | `QueryParameters` | `PaginationParams` |
427
+ | `PagedResult<T>` / `PaginatedResultDto<T>` / `PaginatedResponse<T>` | `PaginatedResult<T>` |
1079
428
  | `currentPage` (property) | `page` |
1080
- | `HasPrevious` (property) | `HasPreviousPage` |
1081
- | `HasNext` (property) | `HasNextPage` |
1082
-
1083
- ### Rules
1084
-
1085
- - **Max pageSize = 100** — enforced via `Math.Clamp(pageSize, 1, 100)` or extension method
1086
- - **Default page = 1, pageSize = 20** — all GetAll endpoints
1087
- - **Search param mandatory** — enables `EntityLookup` on frontend
1088
- - **POST-CHECK C12** blocks `List<T>` returns on GetAll
1089
- - **POST-CHECK C28** blocks non-canonical pagination type names
1090
-
1091
- ---
1092
-
1093
- ## Critical Anti-Patterns (with code examples)
1094
-
1095
- > **These are the most common and dangerous mistakes.** Each one has been observed in production code generation.
1096
-
1097
- ### Anti-Pattern 1: HasQueryFilter with `!= Guid.Empty` (Security vulnerability)
1098
-
1099
- The `HasQueryFilter` in EF Core should use **runtime tenant resolution**, NOT a static comparison against `Guid.Empty`.
1100
-
1101
- **INCORRECT — Does NOT isolate tenants:**
1102
- ```csharp
1103
- // WRONG: This only excludes empty GUIDs — ALL tenant data is still visible to everyone!
1104
- public void Configure(EntityTypeBuilder<MyEntity> builder)
1105
- {
1106
- builder.HasQueryFilter(e => e.TenantId != Guid.Empty);
1107
- }
1108
- ```
1109
-
1110
- **CORRECT — Tenant isolation via service:**
1111
- ```csharp
1112
- // Correct: In SmartStack, tenant filtering is done in the SERVICE layer, not via HasQueryFilter.
1113
- public async Task<PaginatedResult<MyEntityDto>> GetAllAsync(...)
1114
- {
1115
- var query = _db.MyEntities
1116
- .Where(x => x.TenantId == _currentUser.TenantId) // Required runtime filter
1117
- .AsNoTracking();
1118
- // ...
1119
- }
1120
- ```
1121
-
1122
- **Why it's wrong:** `HasQueryFilter(e => e.TenantId != Guid.Empty)` is a **static filter** — it only removes records with empty GUIDs. It does NOT restrict data to the current tenant. This is an **OWASP A01 Broken Access Control** vulnerability.
1123
-
1124
- ---
1125
-
1126
- ### Anti-Pattern 2: `List<T>` instead of `PaginatedResult<T>` for GetAll
1127
-
1128
- **INCORRECT — No pagination:**
1129
- ```csharp
1130
- // WRONG: Returns all records at once — no pagination, no totalCount
1131
- public async Task<List<MyEntityDto>> GetAllAsync(CancellationToken ct)
1132
- {
1133
- return await _db.MyEntities
1134
- .Where(x => x.TenantId == _currentUser.TenantId)
1135
- .Select(x => new MyEntityDto(x.Id, x.Code, x.Name))
1136
- .ToListAsync(ct);
1137
- }
1138
- ```
1139
-
1140
- **CORRECT — Paginated with search:**
1141
- ```csharp
1142
- // Correct: Returns PaginatedResult<T> with search, page, pageSize
1143
- public async Task<PaginatedResult<MyEntityDto>> GetAllAsync(
1144
- string? search = null, int page = 1, int pageSize = 20, CancellationToken ct = default)
1145
- {
1146
- var query = _db.MyEntities.Where(x => x.TenantId == _currentUser.TenantId).AsNoTracking();
1147
- if (!string.IsNullOrWhiteSpace(search))
1148
- query = query.Where(x => x.Name.Contains(search) || x.Code.Contains(search));
1149
- var totalCount = await query.CountAsync(ct);
1150
- var items = await query.OrderBy(x => x.Name).Skip((page - 1) * pageSize).Take(pageSize)
1151
- .Select(x => new MyEntityDto(x.Id, x.Code, x.Name, x.CreatedAt)).ToListAsync(ct);
1152
- return new PaginatedResult<MyEntityDto>(items, totalCount, page, pageSize);
1153
- }
1154
- ```
1155
-
1156
- **Why it's wrong:** `List<T>` loads ALL records into memory. It also breaks `EntityLookup` which requires `{ items, totalCount }` response format.
1157
-
1158
- ---
1159
-
1160
- ### Anti-Pattern 3: Missing `IAuditableEntity` on tenant entities
1161
-
1162
- **INCORRECT — No audit trail:**
1163
- ```csharp
1164
- // WRONG: Tenant entity without IAuditableEntity
1165
- public class MyEntity : BaseEntity, ITenantEntity
1166
- {
1167
- public Guid TenantId { get; private set; }
1168
- public string Code { get; private set; } = null!;
1169
- }
1170
- ```
1171
-
1172
- **CORRECT — Always pair ITenantEntity with IAuditableEntity:**
1173
- ```csharp
1174
- public class MyEntity : BaseEntity, ITenantEntity, IAuditableEntity
1175
- {
1176
- public Guid TenantId { get; private set; }
1177
- public string? CreatedBy { get; set; }
1178
- public string? UpdatedBy { get; set; }
1179
- public string Code { get; private set; } = null!;
1180
- }
1181
- ```
1182
-
1183
- **Why it's wrong:** Without `IAuditableEntity`, there is no record of who created or modified data. Mandatory for compliance in multi-tenant environments.
1184
-
1185
- ---
1186
-
1187
- ### Anti-Pattern 4: Code auto-generation with `Count() + 1`
1188
-
1189
- **INCORRECT — Race condition:**
1190
- ```csharp
1191
- // WRONG: Two concurrent requests get the same count
1192
- var count = await _db.MyEntities.Where(x => x.TenantId == tenantId).CountAsync(ct);
1193
- return $"emp-{(count + 1):D5}";
1194
- ```
1195
-
1196
- **CORRECT — Use `ICodeGenerator<T>.NextCodeAsync()` (atomic with retry):**
1197
- ```csharp
1198
- private readonly ICodeGenerator<MyEntity> _codeGenerator;
1199
-
1200
- // In CreateAsync:
1201
- var code = await _codeGenerator.NextCodeAsync(ct);
1202
- var entity = MyEntity.Create(tenantId, code, dto.Name, createdBy: null);
1203
- ```
429
+ | `HasPrevious` / `HasNext` | `HasPreviousPage` / `HasNextPage` |
1204
430
 
1205
- **Why it's wrong:** `Count() + 1` causes **race conditions** concurrent requests generate duplicate codes. `ICodeGenerator<T>` uses `OrderByDescending` on existing codes + retry on unique constraint violation for safe concurrency.
1206
-
1207
- **Key rules when using auto-generated codes:**
1208
- - **REMOVE** `Code` from `CreateDto` (auto-generated, not user-provided)
1209
- - **KEEP** `Code` in `ResponseDto` (returned to frontend)
1210
- - Register `ICodeGenerator<T>` in DI with `CodePatternConfig`
1211
- - Code regex in validators: `^[a-z0-9_-]+$` (supports hyphens)
1212
-
1213
- **Full reference:** See `references/code-generation.md` for strategies (sequential, timestamp, yearly, UUID), volume-to-digits calculation, and complete implementation patterns.
431
+ **Rules:** Max pageSize = 100. Default page = 1, pageSize = 20. Search param mandatory (enables EntityLookup). POST-CHECK C12 blocks `List<T>` returns. POST-CHECK C28 blocks non-canonical type names.
1214
432
 
1215
433
  ---
1216
434
 
1217
- ### Anti-Pattern 5: Missing Update validator
1218
-
1219
- **INCORRECT — Only CreateValidator:**
1220
- ```csharp
1221
- public class CreateMyEntityDtoValidator : AbstractValidator<CreateMyEntityDto>
1222
- {
1223
- public CreateMyEntityDtoValidator()
1224
- {
1225
- RuleFor(x => x.Code).NotEmpty().MaximumLength(100);
1226
- RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
1227
- }
1228
- }
1229
- // No UpdateMyEntityDtoValidator exists!
1230
- ```
1231
-
1232
- **CORRECT — Always create validators in pairs:**
1233
- ```csharp
1234
- public class CreateMyEntityDtoValidator : AbstractValidator<CreateMyEntityDto> { /* ... */ }
1235
- public class UpdateMyEntityDtoValidator : AbstractValidator<UpdateMyEntityDto>
1236
- {
1237
- public UpdateMyEntityDtoValidator()
1238
- {
1239
- RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
1240
- }
1241
- }
1242
- ```
435
+ ## Common Mistakes to Avoid
1243
436
 
1244
- **Why it's wrong:** Without an `UpdateValidator`, the Update endpoint accepts **any data without validation**.
437
+ | Mistake | Reality |
438
+ |---------|---------|
439
+ | `entity.SoftDelete()` | Does NOT exist — no soft delete in BaseEntity |
440
+ | `entity.Code` inherited | Code is a business property — add it yourself |
441
+ | `e.RowVersion` / `e.IsDeleted` in config | Does NOT exist in BaseEntity |
442
+ | `SmartStack.Api.Core.Routing` | Wrong — use `SmartStack.Api.Routing` |
443
+ | `SystemEntity` base class | Does NOT exist — use `BaseEntity` for all |
444
+ | `[Route("api/...")] + [NavRoute]` | Do not combine — causes 404s. Only `[NavRoute]` needed. |
445
+ | `SmartStack.Domain.Common.Interfaces` | Interfaces are in `SmartStack.Domain.Common` directly |
446
+ | `[Authorize]` without `[RequirePermission]` | No RBAC enforcement |
447
+ | `tenantId: Guid.Empty` | OWASP A01 — use validated `_currentTenant.TenantId` |
448
+ | `ICurrentUser` | Does NOT exist — use `ICurrentUserService` + `ICurrentTenantService` |
449
+ | `_currentTenant.TenantId!.Value` | Throws 500 — use `?? throw new TenantContextRequiredException()` |
450
+ | `UnauthorizedAccessException` for tenant | Returns 401 → clears token. Use `TenantContextRequiredException` (400) |
451
+ | `Permission.Create()` | Does NOT exist — use `CreateForModule()`, `CreateForSection()`, etc. |
452
+ | `GetAllAsync()` without search param | ALL GetAll MUST support `?search=` for EntityLookup |
453
+ | `PagedResult<T>` | Use `PaginatedResult<T>` |
454
+ | Controller injects `DbContext` | Controllers use `ISender _mediator` (MediatR) |
455
+ | Controller injects `I{Name}Service` | Use `ISender _mediator` — MediatR dispatches to handlers |
456
+ | `[Authorize]` with using statement | Use `[Microsoft.AspNetCore.Authorization.Authorize]` (fully qualified) |
457
+ | Missing `[Tags]` / `[Produces]` | Always add `[Tags("...")]` and `[Produces("application/json")]` |
458
+ | `[ProducesResponseType(200)]` integer literal | Use `StatusCodes.Status200OK` constants |
459
+ | Missing 401/403 ProducesResponseType | Always include `StatusCodes.Status401Unauthorized` and `Status403Forbidden` |
460
+ | `[Table]` attribute in Domain entity | Infrastructure concern — move to `IEntityTypeConfiguration<T>` |
461
+ | `using Microsoft.EntityFrameworkCore` in Domain | EF Core belongs in Infrastructure — Domain must be persistence-ignorant |
462
+ | Controller returns entity | Domain leak — return `ResponseDto` instead |
463
+ | `IEmployeeService.cs` in Infrastructure | Interface → Application. Implementation → Infrastructure |
464
+ | Service implementation in Application | Implementations → Infrastructure. Application = interfaces only |
465
+ | Duplicate person fields on mandatory extension | Use `Display*` from User join |
466
+ | Missing `Include(User)` on person extension | Always include User for display fields |
467
+ | Missing unique index `(TenantId, UserId)` | Required for person extension entities |
1245
468
 
1246
469
  ---
1247
470
 
1248
- ### Anti-Pattern 6: `TenantId!.Value` null-forgiving operator
1249
-
1250
- The `!` (null-forgiving) operator followed by `.Value` on a `Guid?` suppresses compiler warnings but **throws `InvalidOperationException` at runtime** when TenantId is null.
1251
-
1252
- **INCORRECT — Crashes with 500 Internal Server Error:**
1253
- ```csharp
1254
- // Wrong: Throws InvalidOperationException("Nullable object must have a value") → 500 error
1255
- public async Task<PaginatedResult<MyEntityDto>> GetAllAsync(...)
1256
- {
1257
- var tenantId = _currentTenant.TenantId!.Value; // Crashes if no tenant context
1258
- var query = _db.MyEntities.Where(x => x.TenantId == tenantId);
1259
- // ...
1260
- }
1261
- ```
1262
-
1263
- **CORRECT — Clean 400 via GlobalExceptionHandlerMiddleware:**
1264
- ```csharp
1265
- // Correct: Throws TenantContextRequiredException → middleware converts to 400 Bad Request
1266
- public async Task<PaginatedResult<MyEntityDto>> GetAllAsync(...)
1267
- {
1268
- var tenantId = _currentTenant.TenantId
1269
- ?? throw new TenantContextRequiredException();
1270
- var query = _db.MyEntities.Where(x => x.TenantId == tenantId);
1271
- // ...
1272
- }
1273
- ```
1274
-
1275
- **Why `!.Value` is wrong:** When a user hits an API via Swagger with a valid JWT but no tenant context (missing `X-Tenant-Slug` header), `TenantId` is null. The `!.Value` pattern produces an opaque `500 Internal Server Error` instead of a clear `400 Bad Request` with an actionable message.
471
+ ## Critical Anti-Patterns (summary)
1276
472
 
1277
- **Why `UnauthorizedAccessException` is wrong:** A missing tenant is NOT an auth failure — the JWT is valid, `[Authorize]` passed. Using `UnauthorizedAccessException` returns 401, which triggers the frontend interceptor to clear the token and redirect to login. Use `TenantContextRequiredException` instead (returns 400, does not clear the token).
473
+ | # | Anti-Pattern | Risk | Correct Approach |
474
+ |---|-------------|------|-----------------|
475
+ | 1 | `HasQueryFilter(e => e.TenantId != Guid.Empty)` | OWASP A01 — does NOT isolate tenants | Tenant filter in SERVICE: `.Where(x => x.TenantId == tenantId)` |
476
+ | 2 | `List<T>` return on GetAll | No pagination, breaks EntityLookup | `PaginatedResult<T>` with search + page + pageSize |
477
+ | 3 | `ITenantEntity` without `IAuditableEntity` | No audit trail | Always pair: `BaseEntity, ITenantEntity, IAuditableEntity` |
478
+ | 4 | Code generation via `Count() + 1` | Race condition — duplicate codes | `ICodeGenerator<T>.NextCodeAsync()` (see `references/code-generation.md`) |
479
+ | 5 | Only Create validator, no Update | Update accepts invalid data | Always create validators in pairs |
480
+ | 6 | `TenantId!.Value` null-forgiving | 500 instead of 400 | `?? throw new TenantContextRequiredException()` |