@atlashub/smartstack-cli 4.32.0 → 4.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.documentation/index.html +2 -2
- package/.documentation/init.html +358 -174
- package/dist/index.js +45 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp-entry.mjs +271 -44
- package/dist/mcp-entry.mjs.map +1 -1
- package/package.json +1 -1
- package/templates/mcp-scaffolding/controller.cs.hbs +54 -128
- package/templates/project/README.md +19 -0
- package/templates/project/claude-md/api.CLAUDE.md.template +315 -0
- package/templates/project/claude-md/application.CLAUDE.md.template +181 -0
- package/templates/project/claude-md/domain.CLAUDE.md.template +125 -0
- package/templates/project/claude-md/infrastructure.CLAUDE.md.template +168 -0
- package/templates/project/claude-md/root.CLAUDE.md.template +339 -0
- package/templates/project/claude-md/web.CLAUDE.md.template +339 -0
- package/templates/skills/apex/SKILL.md +16 -10
- package/templates/skills/apex/_shared.md +1 -1
- package/templates/skills/apex/references/checks/architecture-checks.sh +154 -0
- package/templates/skills/apex/references/checks/backend-checks.sh +194 -0
- package/templates/skills/apex/references/checks/frontend-checks.sh +448 -0
- package/templates/skills/apex/references/checks/infrastructure-checks.sh +255 -0
- package/templates/skills/apex/references/checks/security-checks.sh +153 -0
- package/templates/skills/apex/references/checks/seed-checks.sh +536 -0
- package/templates/skills/apex/references/frontend-route-wiring-app-tsx.md +49 -192
- package/templates/skills/apex/references/post-checks.md +124 -2156
- package/templates/skills/apex/references/smartstack-api.md +160 -957
- package/templates/skills/apex/references/smartstack-frontend.md +134 -1022
- package/templates/skills/apex/references/smartstack-layers.md +12 -6
- package/templates/skills/apex/steps/step-00-init.md +81 -238
- package/templates/skills/apex/steps/step-03-execute.md +25 -752
- package/templates/skills/apex/steps/step-03a-layer0-domain.md +118 -0
- package/templates/skills/apex/steps/step-03b-layer1-seed.md +91 -0
- package/templates/skills/apex/steps/step-03c-layer2-backend.md +240 -0
- package/templates/skills/apex/steps/step-03d-layer3-frontend.md +300 -0
- package/templates/skills/apex/steps/step-03e-layer4-devdata.md +44 -0
- package/templates/skills/apex/steps/step-04-examine.md +70 -150
- package/templates/skills/application/references/frontend-i18n-and-output.md +2 -2
- package/templates/skills/application/references/frontend-route-naming.md +5 -1
- package/templates/skills/application/references/frontend-route-wiring-app-tsx.md +49 -198
- package/templates/skills/application/references/frontend-verification.md +11 -11
- package/templates/skills/application/steps/step-05-frontend.md +26 -15
- package/templates/skills/application/templates-frontend.md +4 -0
- package/templates/skills/cli-app-sync/SKILL.md +2 -2
- package/templates/skills/cli-app-sync/references/comparison-map.md +1 -1
- package/templates/skills/controller/references/controller-code-templates.md +70 -67
- 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-
|
|
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
|
|
93
|
+
## Entity Patterns
|
|
93
94
|
|
|
94
|
-
|
|
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
|
|
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
|
-
###
|
|
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
|
-
|
|
177
|
-
|
|
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
|
-
|
|
204
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
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
|
-
**
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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 (
|
|
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
|
-
>
|
|
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
|
|
579
|
-
- `ICurrentUserService` (
|
|
580
|
-
- `ICurrentTenantService` (
|
|
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
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
Do
|
|
592
|
-
- `_currentTenant.TenantId!.Value` — throws
|
|
593
|
-
- `UnauthorizedAccessException("Tenant context is required")` —
|
|
594
|
-
- `tenantId: Guid.Empty` — always use validated
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
617
|
-
private readonly ILogger<{Name}Controller> _logger;
|
|
292
|
+
private readonly ISender _mediator; // MediatR — NOT I{Name}Service
|
|
618
293
|
|
|
619
|
-
public {Name}Controller(
|
|
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}.
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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
|
-
|
|
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
|
-
|
|
669
|
-
- `
|
|
670
|
-
-
|
|
671
|
-
-
|
|
672
|
-
-
|
|
673
|
-
|
|
674
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
706
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
**
|
|
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
|
|
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
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
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
|
-
-
|
|
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
|
-
=>
|
|
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
|
-
|
|
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
|
-
|
|
|
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
|
|
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`, `
|
|
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
|
|
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
|
|
962
|
-
|
|
963
|
-
> **Canonical type for ALL paginated responses.** One name, one contract, everywhere.
|
|
403
|
+
## PaginatedResult
|
|
964
404
|
|
|
965
|
-
|
|
405
|
+
> **Canonical type — one name, one contract, everywhere.**
|
|
966
406
|
|
|
967
407
|
```csharp
|
|
968
|
-
|
|
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
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
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
|
-
|
|
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`
|
|
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
|
-
**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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()` |
|