@atlashub/smartstack-cli 3.46.0 → 3.48.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/dist/mcp-entry.mjs +297 -85
- package/dist/mcp-entry.mjs.map +1 -1
- package/package.json +1 -1
- package/templates/skills/apex/SKILL.md +1 -0
- package/templates/skills/apex/references/analysis-methods.md +27 -16
- package/templates/skills/apex/references/challenge-questions.md +143 -0
- package/templates/skills/apex/references/person-extension-pattern.md +596 -0
- package/templates/skills/apex/references/post-checks.md +78 -0
- package/templates/skills/apex/references/smartstack-api.md +69 -0
- package/templates/skills/apex/references/smartstack-layers.md +13 -0
- package/templates/skills/apex/steps/step-00-init.md +27 -5
- package/templates/skills/apex/steps/step-03-execute.md +13 -0
- package/templates/skills/business-analyse/_architecture.md +1 -0
- package/templates/skills/business-analyse/references/entity-architecture-decision.md +22 -0
- package/templates/skills/business-analyse/references/handoff-mappings.md +14 -0
- package/templates/skills/business-analyse/references/spec-auto-inference.md +1 -0
- package/templates/skills/business-analyse/steps/step-03a2-analysis.md +49 -0
- package/templates/skills/gitflow/_shared.md +2 -2
- package/templates/skills/gitflow/references/init-version-detection.md +1 -1
- package/templates/skills/gitflow/steps/step-init.md +44 -15
- package/templates/skills/gitflow/templates/config.json +1 -1
- package/templates/skills/ralph-loop/references/category-rules.md +13 -0
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
# Person Extension Pattern — Source of Truth
|
|
2
|
+
|
|
3
|
+
> **Loaded by:** step-03 (execute) when entity has `personRoleConfig`
|
|
4
|
+
> **Purpose:** Defines the pattern for entities that represent a "person role" of a User (Employee, Customer, Contact, etc.)
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## 1. Overview
|
|
9
|
+
|
|
10
|
+
The Person Extension Pattern links a business entity (e.g., Employee, Customer) to a `User` entity rather than duplicating personal information (FirstName, LastName, Email). This avoids data duplication and ensures identity consistency across the platform.
|
|
11
|
+
|
|
12
|
+
**Two variants exist:**
|
|
13
|
+
|
|
14
|
+
| Variant | UserId | Use case | Unique index |
|
|
15
|
+
|---------|--------|----------|-------------|
|
|
16
|
+
| **Mandatory** | `Guid UserId` (non-nullable) | Employee, Manager — always a User | `(TenantId, UserId)` |
|
|
17
|
+
| **Optional** | `Guid? UserId` (nullable) | Customer, Contact — may or may not have account | `(TenantId, UserId)` filtered (`WHERE UserId IS NOT NULL`) |
|
|
18
|
+
|
|
19
|
+
**Rule:** As soon as a person CAN log in to the system (even potentially), they must be linked to User.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## 2. Entity Pattern — Mandatory UserId
|
|
24
|
+
|
|
25
|
+
For entities where the person is **always** a system User (they must log in).
|
|
26
|
+
|
|
27
|
+
```csharp
|
|
28
|
+
using SmartStack.Domain.Common;
|
|
29
|
+
|
|
30
|
+
namespace {ProjectName}.Domain.Entities.{App}.{Module};
|
|
31
|
+
|
|
32
|
+
public class Employee : BaseEntity, ITenantEntity, IAuditableEntity
|
|
33
|
+
{
|
|
34
|
+
// ITenantEntity
|
|
35
|
+
public Guid TenantId { get; private set; }
|
|
36
|
+
|
|
37
|
+
// IAuditableEntity
|
|
38
|
+
public string? CreatedBy { get; set; }
|
|
39
|
+
public string? UpdatedBy { get; set; }
|
|
40
|
+
|
|
41
|
+
// === USER LINK (Person Extension — mandatory) ===
|
|
42
|
+
public Guid UserId { get; private set; }
|
|
43
|
+
public User? User { get; private set; }
|
|
44
|
+
|
|
45
|
+
// === BUSINESS PROPERTIES (role-specific ONLY) ===
|
|
46
|
+
public string Code { get; private set; } = null!;
|
|
47
|
+
public DateOnly HireDate { get; private set; }
|
|
48
|
+
public EmployeeStatus Status { get; private set; }
|
|
49
|
+
|
|
50
|
+
// ZERO person fields: FirstName, LastName, Email, Department -> come from User
|
|
51
|
+
|
|
52
|
+
private Employee() { }
|
|
53
|
+
|
|
54
|
+
public static Employee Create(Guid tenantId, Guid userId, string code, DateOnly hireDate)
|
|
55
|
+
{
|
|
56
|
+
if (tenantId == Guid.Empty)
|
|
57
|
+
throw new ArgumentException("TenantId is required", nameof(tenantId));
|
|
58
|
+
if (userId == Guid.Empty)
|
|
59
|
+
throw new ArgumentException("UserId is required", nameof(userId));
|
|
60
|
+
|
|
61
|
+
return new Employee
|
|
62
|
+
{
|
|
63
|
+
Id = Guid.NewGuid(),
|
|
64
|
+
TenantId = tenantId,
|
|
65
|
+
UserId = userId,
|
|
66
|
+
Code = code.ToLowerInvariant(),
|
|
67
|
+
HireDate = hireDate,
|
|
68
|
+
Status = EmployeeStatus.Active,
|
|
69
|
+
CreatedAt = DateTime.UtcNow
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
**Key rules (mandatory variant):**
|
|
76
|
+
- `Guid UserId` (non-nullable) — entity cannot exist without a User
|
|
77
|
+
- ZERO person fields (`FirstName`, `LastName`, `Email`, `PhoneNumber`) — all come from `User`
|
|
78
|
+
- Only business-specific properties (Code, HireDate, Status, etc.)
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## 3. Entity Pattern — Optional UserId
|
|
83
|
+
|
|
84
|
+
For entities where the person **may or may not** have a system account.
|
|
85
|
+
|
|
86
|
+
```csharp
|
|
87
|
+
using SmartStack.Domain.Common;
|
|
88
|
+
|
|
89
|
+
namespace {ProjectName}.Domain.Entities.{App}.{Module};
|
|
90
|
+
|
|
91
|
+
public class Customer : BaseEntity, ITenantEntity, IAuditableEntity
|
|
92
|
+
{
|
|
93
|
+
// ITenantEntity
|
|
94
|
+
public Guid TenantId { get; private set; }
|
|
95
|
+
|
|
96
|
+
// IAuditableEntity
|
|
97
|
+
public string? CreatedBy { get; set; }
|
|
98
|
+
public string? UpdatedBy { get; set; }
|
|
99
|
+
|
|
100
|
+
// === USER LINK (Person Extension — optional) ===
|
|
101
|
+
public Guid? UserId { get; private set; }
|
|
102
|
+
public User? User { get; private set; }
|
|
103
|
+
|
|
104
|
+
// === PERSON FIELDS (standalone when no User linked) ===
|
|
105
|
+
public string FirstName { get; private set; } = null!;
|
|
106
|
+
public string LastName { get; private set; } = null!;
|
|
107
|
+
public string? Email { get; private set; }
|
|
108
|
+
public string? PhoneNumber { get; private set; }
|
|
109
|
+
|
|
110
|
+
// === BUSINESS PROPERTIES ===
|
|
111
|
+
public string Code { get; private set; } = null!;
|
|
112
|
+
public string? CompanyName { get; private set; }
|
|
113
|
+
public CustomerStatus Status { get; private set; }
|
|
114
|
+
|
|
115
|
+
// === COMPUTED (read from User if linked, else from own fields) ===
|
|
116
|
+
public string DisplayFirstName => User?.FirstName ?? FirstName;
|
|
117
|
+
public string DisplayLastName => User?.LastName ?? LastName;
|
|
118
|
+
public string? DisplayEmail => User?.Email ?? Email;
|
|
119
|
+
|
|
120
|
+
private Customer() { }
|
|
121
|
+
|
|
122
|
+
public static Customer Create(
|
|
123
|
+
Guid tenantId, string code, string firstName, string lastName,
|
|
124
|
+
Guid? userId = null, string? email = null, string? companyName = null)
|
|
125
|
+
{
|
|
126
|
+
if (tenantId == Guid.Empty)
|
|
127
|
+
throw new ArgumentException("TenantId is required", nameof(tenantId));
|
|
128
|
+
|
|
129
|
+
return new Customer
|
|
130
|
+
{
|
|
131
|
+
Id = Guid.NewGuid(),
|
|
132
|
+
TenantId = tenantId,
|
|
133
|
+
UserId = userId,
|
|
134
|
+
Code = code.ToLowerInvariant(),
|
|
135
|
+
FirstName = firstName,
|
|
136
|
+
LastName = lastName,
|
|
137
|
+
Email = email,
|
|
138
|
+
CompanyName = companyName,
|
|
139
|
+
Status = CustomerStatus.Active,
|
|
140
|
+
CreatedAt = DateTime.UtcNow
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
**Key rules (optional variant):**
|
|
147
|
+
- `Guid? UserId` (nullable) — entity can exist independently
|
|
148
|
+
- Person fields present (`FirstName`, `LastName`, `Email`, `PhoneNumber`) for standalone use
|
|
149
|
+
- Computed `Display*` properties resolve from User when linked, else from own fields
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## 4. EF Configuration Pattern
|
|
154
|
+
|
|
155
|
+
### Mandatory UserId
|
|
156
|
+
|
|
157
|
+
```csharp
|
|
158
|
+
public class EmployeeConfiguration : IEntityTypeConfiguration<Employee>
|
|
159
|
+
{
|
|
160
|
+
public void Configure(EntityTypeBuilder<Employee> builder)
|
|
161
|
+
{
|
|
162
|
+
builder.ToTable("{prefix}Employees", "{schema}");
|
|
163
|
+
builder.HasKey(x => x.Id);
|
|
164
|
+
|
|
165
|
+
// Tenant
|
|
166
|
+
builder.Property(x => x.TenantId).IsRequired();
|
|
167
|
+
builder.HasIndex(x => x.TenantId)
|
|
168
|
+
.HasDatabaseName("IX_{prefix}Employees_TenantId");
|
|
169
|
+
|
|
170
|
+
// User link (mandatory)
|
|
171
|
+
builder.Property(x => x.UserId).IsRequired();
|
|
172
|
+
builder.HasOne(e => e.User)
|
|
173
|
+
.WithMany()
|
|
174
|
+
.HasForeignKey(e => e.UserId)
|
|
175
|
+
.OnDelete(DeleteBehavior.Restrict);
|
|
176
|
+
|
|
177
|
+
// Unique constraint: one person-role per User per Tenant
|
|
178
|
+
builder.HasIndex(e => new { e.TenantId, e.UserId })
|
|
179
|
+
.IsUnique()
|
|
180
|
+
.HasDatabaseName("IX_{prefix}Employees_Tenant_User");
|
|
181
|
+
|
|
182
|
+
// Business properties
|
|
183
|
+
builder.Property(x => x.Code).HasMaxLength(50).IsRequired();
|
|
184
|
+
builder.Property(x => x.HireDate).IsRequired();
|
|
185
|
+
|
|
186
|
+
// Audit
|
|
187
|
+
builder.Property(x => x.CreatedBy).HasMaxLength(256);
|
|
188
|
+
builder.Property(x => x.UpdatedBy).HasMaxLength(256);
|
|
189
|
+
|
|
190
|
+
// Unique code per tenant
|
|
191
|
+
builder.HasIndex(x => new { x.TenantId, x.Code })
|
|
192
|
+
.IsUnique()
|
|
193
|
+
.HasDatabaseName("IX_{prefix}Employees_Tenant_Code");
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### Optional UserId
|
|
199
|
+
|
|
200
|
+
```csharp
|
|
201
|
+
public class CustomerConfiguration : IEntityTypeConfiguration<Customer>
|
|
202
|
+
{
|
|
203
|
+
public void Configure(EntityTypeBuilder<Customer> builder)
|
|
204
|
+
{
|
|
205
|
+
builder.ToTable("{prefix}Customers", "{schema}");
|
|
206
|
+
builder.HasKey(x => x.Id);
|
|
207
|
+
|
|
208
|
+
// Tenant
|
|
209
|
+
builder.Property(x => x.TenantId).IsRequired();
|
|
210
|
+
builder.HasIndex(x => x.TenantId)
|
|
211
|
+
.HasDatabaseName("IX_{prefix}Customers_TenantId");
|
|
212
|
+
|
|
213
|
+
// User link (optional)
|
|
214
|
+
builder.Property(x => x.UserId).IsRequired(false);
|
|
215
|
+
builder.HasOne(e => e.User)
|
|
216
|
+
.WithMany()
|
|
217
|
+
.HasForeignKey(e => e.UserId)
|
|
218
|
+
.OnDelete(DeleteBehavior.Restrict);
|
|
219
|
+
|
|
220
|
+
// Filtered unique constraint: one person-role per User per Tenant (only when linked)
|
|
221
|
+
builder.HasIndex(e => new { e.TenantId, e.UserId })
|
|
222
|
+
.IsUnique()
|
|
223
|
+
.HasFilter("[UserId] IS NOT NULL")
|
|
224
|
+
.HasDatabaseName("IX_{prefix}Customers_Tenant_User");
|
|
225
|
+
|
|
226
|
+
// Person fields (for standalone use)
|
|
227
|
+
builder.Property(x => x.FirstName).HasMaxLength(100).IsRequired();
|
|
228
|
+
builder.Property(x => x.LastName).HasMaxLength(100).IsRequired();
|
|
229
|
+
builder.Property(x => x.Email).HasMaxLength(256);
|
|
230
|
+
builder.Property(x => x.PhoneNumber).HasMaxLength(50);
|
|
231
|
+
|
|
232
|
+
// Computed properties are NOT mapped (read-only in C#)
|
|
233
|
+
builder.Ignore(x => x.DisplayFirstName);
|
|
234
|
+
builder.Ignore(x => x.DisplayLastName);
|
|
235
|
+
builder.Ignore(x => x.DisplayEmail);
|
|
236
|
+
|
|
237
|
+
// Business properties
|
|
238
|
+
builder.Property(x => x.Code).HasMaxLength(50).IsRequired();
|
|
239
|
+
builder.Property(x => x.CompanyName).HasMaxLength(200);
|
|
240
|
+
|
|
241
|
+
// Audit
|
|
242
|
+
builder.Property(x => x.CreatedBy).HasMaxLength(256);
|
|
243
|
+
builder.Property(x => x.UpdatedBy).HasMaxLength(256);
|
|
244
|
+
|
|
245
|
+
// Unique code per tenant
|
|
246
|
+
builder.HasIndex(x => new { x.TenantId, x.Code })
|
|
247
|
+
.IsUnique()
|
|
248
|
+
.HasDatabaseName("IX_{prefix}Customers_Tenant_Code");
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
**Key differences:**
|
|
254
|
+
- Mandatory: `.IsRequired()` on UserId, plain `IsUnique()` on `(TenantId, UserId)`
|
|
255
|
+
- Optional: `.IsRequired(false)` on UserId, `IsUnique().HasFilter("[UserId] IS NOT NULL")` on `(TenantId, UserId)`
|
|
256
|
+
- Optional: `builder.Ignore()` on computed `Display*` properties (not persisted)
|
|
257
|
+
|
|
258
|
+
---
|
|
259
|
+
|
|
260
|
+
## 5. Service Pattern
|
|
261
|
+
|
|
262
|
+
### Mandatory Variant
|
|
263
|
+
|
|
264
|
+
```csharp
|
|
265
|
+
public class EmployeeService : IEmployeeService
|
|
266
|
+
{
|
|
267
|
+
private readonly IExtensionsDbContext _db;
|
|
268
|
+
private readonly ICoreDbContext _coreDb; // For User reads
|
|
269
|
+
private readonly ICurrentTenantService _currentTenant;
|
|
270
|
+
private readonly ICurrentUserService _currentUser;
|
|
271
|
+
private readonly ILogger<EmployeeService> _logger;
|
|
272
|
+
|
|
273
|
+
public async Task<PaginatedResult<EmployeeResponseDto>> GetAllAsync(
|
|
274
|
+
string? search = null, int page = 1, int pageSize = 20, CancellationToken ct = default)
|
|
275
|
+
{
|
|
276
|
+
var tenantId = _currentTenant.TenantId
|
|
277
|
+
?? throw new TenantContextRequiredException();
|
|
278
|
+
|
|
279
|
+
var query = _db.Employees
|
|
280
|
+
.Include(x => x.User) // MANDATORY — always load User for display
|
|
281
|
+
.Where(x => x.TenantId == tenantId)
|
|
282
|
+
.AsNoTracking();
|
|
283
|
+
|
|
284
|
+
if (!string.IsNullOrWhiteSpace(search))
|
|
285
|
+
{
|
|
286
|
+
query = query.Where(x =>
|
|
287
|
+
x.Code.Contains(search) ||
|
|
288
|
+
x.User!.FirstName.Contains(search) || // Search on User fields
|
|
289
|
+
x.User!.LastName.Contains(search) ||
|
|
290
|
+
x.User!.Email.Contains(search));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return await query
|
|
294
|
+
.OrderBy(x => x.User!.LastName)
|
|
295
|
+
.Select(x => new EmployeeResponseDto(
|
|
296
|
+
x.Id, x.Code, x.HireDate, x.Status,
|
|
297
|
+
x.User!.FirstName, x.User!.LastName, x.User!.Email))
|
|
298
|
+
.ToPaginatedResultAsync(page, pageSize, ct);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
public async Task<EmployeeResponseDto> CreateAsync(CreateEmployeeDto dto, CancellationToken ct)
|
|
302
|
+
{
|
|
303
|
+
var tenantId = _currentTenant.TenantId
|
|
304
|
+
?? throw new TenantContextRequiredException();
|
|
305
|
+
|
|
306
|
+
// Verify User exists
|
|
307
|
+
var userExists = await _coreDb.Users.AnyAsync(u => u.Id == dto.UserId, ct);
|
|
308
|
+
if (!userExists)
|
|
309
|
+
throw new KeyNotFoundException($"User {dto.UserId} not found");
|
|
310
|
+
|
|
311
|
+
// Verify no duplicate (TenantId, UserId) — one employee per user per tenant
|
|
312
|
+
var duplicate = await _db.Employees
|
|
313
|
+
.AnyAsync(e => e.TenantId == tenantId && e.UserId == dto.UserId, ct);
|
|
314
|
+
if (duplicate)
|
|
315
|
+
throw new InvalidOperationException($"An employee already exists for user {dto.UserId} in this tenant");
|
|
316
|
+
|
|
317
|
+
var entity = Employee.Create(tenantId, dto.UserId, dto.Code, dto.HireDate);
|
|
318
|
+
entity.CreatedBy = _currentUser.UserId?.ToString();
|
|
319
|
+
|
|
320
|
+
_db.Employees.Add(entity);
|
|
321
|
+
await _db.SaveChangesAsync(ct);
|
|
322
|
+
|
|
323
|
+
// Reload with User for response
|
|
324
|
+
var created = await _db.Employees
|
|
325
|
+
.Include(x => x.User)
|
|
326
|
+
.FirstAsync(x => x.Id == entity.Id, ct);
|
|
327
|
+
|
|
328
|
+
return new EmployeeResponseDto(
|
|
329
|
+
created.Id, created.Code, created.HireDate, created.Status,
|
|
330
|
+
created.User!.FirstName, created.User!.LastName, created.User!.Email);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
### Optional Variant
|
|
336
|
+
|
|
337
|
+
```csharp
|
|
338
|
+
public class CustomerService : ICustomerService
|
|
339
|
+
{
|
|
340
|
+
private readonly IExtensionsDbContext _db;
|
|
341
|
+
private readonly ICoreDbContext _coreDb; // For User reads
|
|
342
|
+
private readonly ICurrentTenantService _currentTenant;
|
|
343
|
+
private readonly ICurrentUserService _currentUser;
|
|
344
|
+
private readonly ILogger<CustomerService> _logger;
|
|
345
|
+
|
|
346
|
+
public async Task<PaginatedResult<CustomerResponseDto>> GetAllAsync(
|
|
347
|
+
string? search = null, int page = 1, int pageSize = 20, CancellationToken ct = default)
|
|
348
|
+
{
|
|
349
|
+
var tenantId = _currentTenant.TenantId
|
|
350
|
+
?? throw new TenantContextRequiredException();
|
|
351
|
+
|
|
352
|
+
var query = _db.Customers
|
|
353
|
+
.Include(x => x.User) // MANDATORY — load User for Display* resolution
|
|
354
|
+
.Where(x => x.TenantId == tenantId)
|
|
355
|
+
.AsNoTracking();
|
|
356
|
+
|
|
357
|
+
if (!string.IsNullOrWhiteSpace(search))
|
|
358
|
+
{
|
|
359
|
+
// Search fallback: User fields first, then own fields
|
|
360
|
+
query = query.Where(x =>
|
|
361
|
+
x.Code.Contains(search) ||
|
|
362
|
+
(x.User != null ? x.User.FirstName.Contains(search) : x.FirstName.Contains(search)) ||
|
|
363
|
+
(x.User != null ? x.User.LastName.Contains(search) : x.LastName.Contains(search)) ||
|
|
364
|
+
(x.User != null ? x.User.Email!.Contains(search) : (x.Email != null && x.Email.Contains(search))));
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return await query
|
|
368
|
+
.OrderBy(x => x.User != null ? x.User.LastName : x.LastName)
|
|
369
|
+
.Select(x => new CustomerResponseDto(
|
|
370
|
+
x.Id, x.Code, x.Status, x.CompanyName,
|
|
371
|
+
x.User != null ? x.User.FirstName : x.FirstName,
|
|
372
|
+
x.User != null ? x.User.LastName : x.LastName,
|
|
373
|
+
x.User != null ? x.User.Email : x.Email,
|
|
374
|
+
x.UserId))
|
|
375
|
+
.ToPaginatedResultAsync(page, pageSize, ct);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
public async Task<CustomerResponseDto> CreateAsync(CreateCustomerDto dto, CancellationToken ct)
|
|
379
|
+
{
|
|
380
|
+
var tenantId = _currentTenant.TenantId
|
|
381
|
+
?? throw new TenantContextRequiredException();
|
|
382
|
+
|
|
383
|
+
// If UserId provided, verify existence + no duplicate
|
|
384
|
+
if (dto.UserId.HasValue)
|
|
385
|
+
{
|
|
386
|
+
var userExists = await _coreDb.Users.AnyAsync(u => u.Id == dto.UserId.Value, ct);
|
|
387
|
+
if (!userExists)
|
|
388
|
+
throw new KeyNotFoundException($"User {dto.UserId} not found");
|
|
389
|
+
|
|
390
|
+
var duplicate = await _db.Customers
|
|
391
|
+
.AnyAsync(c => c.TenantId == tenantId && c.UserId == dto.UserId.Value, ct);
|
|
392
|
+
if (duplicate)
|
|
393
|
+
throw new InvalidOperationException($"A customer already exists for user {dto.UserId} in this tenant");
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
var entity = Customer.Create(
|
|
397
|
+
tenantId, dto.Code, dto.FirstName, dto.LastName,
|
|
398
|
+
dto.UserId, dto.Email, dto.CompanyName);
|
|
399
|
+
entity.CreatedBy = _currentUser.UserId?.ToString();
|
|
400
|
+
|
|
401
|
+
_db.Customers.Add(entity);
|
|
402
|
+
await _db.SaveChangesAsync(ct);
|
|
403
|
+
|
|
404
|
+
return MapToDto(entity);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
**Key service rules:**
|
|
410
|
+
- Both variants: `Include(x => x.User)` on ALL queries
|
|
411
|
+
- Both variants: inject `ICoreDbContext` for User reads (User is in the core schema)
|
|
412
|
+
- Mandatory: search on `User.FirstName`, `User.LastName`, `User.Email` directly
|
|
413
|
+
- Optional: search fallback `User.FirstName ?? x.FirstName`
|
|
414
|
+
- Both: CreateAsync verifies User exists + no duplicate `(TenantId, UserId)`
|
|
415
|
+
|
|
416
|
+
---
|
|
417
|
+
|
|
418
|
+
## 6. DTO Pattern
|
|
419
|
+
|
|
420
|
+
### Mandatory Variant
|
|
421
|
+
|
|
422
|
+
```csharp
|
|
423
|
+
// CreateDto — only UserId + business fields (no person fields)
|
|
424
|
+
public record CreateEmployeeDto(
|
|
425
|
+
Guid UserId, // mandatory — links to existing User
|
|
426
|
+
string Code,
|
|
427
|
+
DateOnly HireDate
|
|
428
|
+
);
|
|
429
|
+
|
|
430
|
+
// UpdateDto — no UserId change (person link is immutable)
|
|
431
|
+
public record UpdateEmployeeDto(
|
|
432
|
+
DateOnly? HireDate,
|
|
433
|
+
EmployeeStatus? Status
|
|
434
|
+
);
|
|
435
|
+
|
|
436
|
+
// ResponseDto — includes Display* fields resolved from User
|
|
437
|
+
public record EmployeeResponseDto(
|
|
438
|
+
Guid Id,
|
|
439
|
+
string Code,
|
|
440
|
+
DateOnly HireDate,
|
|
441
|
+
EmployeeStatus Status,
|
|
442
|
+
string DisplayFirstName, // from User.FirstName
|
|
443
|
+
string DisplayLastName, // from User.LastName
|
|
444
|
+
string? DisplayEmail // from User.Email
|
|
445
|
+
);
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
### Optional Variant
|
|
449
|
+
|
|
450
|
+
```csharp
|
|
451
|
+
// CreateDto — UserId optional + person fields for standalone use
|
|
452
|
+
public record CreateCustomerDto(
|
|
453
|
+
Guid? UserId, // optional — null if no User account
|
|
454
|
+
string FirstName, // required when UserId is null
|
|
455
|
+
string LastName, // required when UserId is null
|
|
456
|
+
string? Email,
|
|
457
|
+
string Code,
|
|
458
|
+
string? CompanyName
|
|
459
|
+
);
|
|
460
|
+
|
|
461
|
+
// UpdateDto — person fields updatable (may change independently of User)
|
|
462
|
+
public record UpdateCustomerDto(
|
|
463
|
+
string? FirstName,
|
|
464
|
+
string? LastName,
|
|
465
|
+
string? Email,
|
|
466
|
+
string? CompanyName,
|
|
467
|
+
CustomerStatus? Status
|
|
468
|
+
);
|
|
469
|
+
|
|
470
|
+
// ResponseDto — includes resolved Display* fields
|
|
471
|
+
public record CustomerResponseDto(
|
|
472
|
+
Guid Id,
|
|
473
|
+
string Code,
|
|
474
|
+
CustomerStatus Status,
|
|
475
|
+
string? CompanyName,
|
|
476
|
+
string DisplayFirstName, // from User.FirstName ?? own FirstName
|
|
477
|
+
string DisplayLastName, // from User.LastName ?? own LastName
|
|
478
|
+
string? DisplayEmail, // from User.Email ?? own Email
|
|
479
|
+
Guid? UserId // null if not linked
|
|
480
|
+
);
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
**Key DTO rules:**
|
|
484
|
+
- Mandatory CreateDto: `Guid UserId` + business fields only (no name/email)
|
|
485
|
+
- Optional CreateDto: `Guid? UserId` + person fields (FirstName, LastName, Email)
|
|
486
|
+
- ResponseDto (both): includes `Display*` fields resolved from User or own fields
|
|
487
|
+
- UserId is NOT in UpdateDto — the person link is set at creation time
|
|
488
|
+
|
|
489
|
+
---
|
|
490
|
+
|
|
491
|
+
## 7. Frontend Pattern
|
|
492
|
+
|
|
493
|
+
### Mandatory — CreatePage
|
|
494
|
+
|
|
495
|
+
```tsx
|
|
496
|
+
// EntityLookup for User selection (mandatory)
|
|
497
|
+
<EntityLookup
|
|
498
|
+
apiEndpoint="/api/administration/users"
|
|
499
|
+
value={formData.userId}
|
|
500
|
+
onChange={(id) => handleChange('userId', id)}
|
|
501
|
+
label={t('module:form.user', 'User')}
|
|
502
|
+
mapOption={(user) => ({
|
|
503
|
+
id: user.id,
|
|
504
|
+
label: `${user.firstName} ${user.lastName}`,
|
|
505
|
+
sublabel: user.email
|
|
506
|
+
})}
|
|
507
|
+
required
|
|
508
|
+
/>
|
|
509
|
+
|
|
510
|
+
// Business fields only — no person fields
|
|
511
|
+
<input type="date" value={formData.hireDate} ... />
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
### Optional — CreatePage
|
|
515
|
+
|
|
516
|
+
```tsx
|
|
517
|
+
// EntityLookup for optional User selection
|
|
518
|
+
<EntityLookup
|
|
519
|
+
apiEndpoint="/api/administration/users"
|
|
520
|
+
value={formData.userId}
|
|
521
|
+
onChange={(id) => handleChange('userId', id)}
|
|
522
|
+
label={t('module:form.linkUser', 'Link to User (optional)')}
|
|
523
|
+
mapOption={(user) => ({
|
|
524
|
+
id: user.id,
|
|
525
|
+
label: `${user.firstName} ${user.lastName}`,
|
|
526
|
+
sublabel: user.email
|
|
527
|
+
})}
|
|
528
|
+
clearable
|
|
529
|
+
/>
|
|
530
|
+
|
|
531
|
+
{/* Person fields shown when NO User selected */}
|
|
532
|
+
{!formData.userId && (
|
|
533
|
+
<>
|
|
534
|
+
<input value={formData.firstName} label={t('module:form.firstName', 'First Name')} required />
|
|
535
|
+
<input value={formData.lastName} label={t('module:form.lastName', 'Last Name')} required />
|
|
536
|
+
<input value={formData.email} label={t('module:form.email', 'Email')} />
|
|
537
|
+
</>
|
|
538
|
+
)}
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
### ListPage (both variants)
|
|
542
|
+
|
|
543
|
+
```tsx
|
|
544
|
+
// Composite columns from response DTO (already resolved by backend)
|
|
545
|
+
<SmartTable
|
|
546
|
+
columns={[
|
|
547
|
+
{ key: 'code', label: t('module:columns.code', 'Code') },
|
|
548
|
+
{ key: 'displayFirstName', label: t('module:columns.firstName', 'First Name') },
|
|
549
|
+
{ key: 'displayLastName', label: t('module:columns.lastName', 'Last Name') },
|
|
550
|
+
{ key: 'displayEmail', label: t('module:columns.email', 'Email') },
|
|
551
|
+
{ key: 'status', label: t('module:columns.status', 'Status') },
|
|
552
|
+
]}
|
|
553
|
+
data={items}
|
|
554
|
+
/>
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
### DetailPage — Identity Section
|
|
558
|
+
|
|
559
|
+
```tsx
|
|
560
|
+
// "Identity" section showing User info read-only
|
|
561
|
+
<section>
|
|
562
|
+
<h3>{t('module:detail.identity', 'Identity')}</h3>
|
|
563
|
+
<dl>
|
|
564
|
+
<dt>{t('module:detail.firstName', 'First Name')}</dt>
|
|
565
|
+
<dd>{entity.displayFirstName}</dd>
|
|
566
|
+
<dt>{t('module:detail.lastName', 'Last Name')}</dt>
|
|
567
|
+
<dd>{entity.displayLastName}</dd>
|
|
568
|
+
<dt>{t('module:detail.email', 'Email')}</dt>
|
|
569
|
+
<dd>{entity.displayEmail}</dd>
|
|
570
|
+
{entity.userId && (
|
|
571
|
+
<>
|
|
572
|
+
<dt>{t('module:detail.linkedUser', 'Linked User')}</dt>
|
|
573
|
+
<dd>{t('module:detail.yes', 'Yes')}</dd>
|
|
574
|
+
</>
|
|
575
|
+
)}
|
|
576
|
+
</dl>
|
|
577
|
+
</section>
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
---
|
|
581
|
+
|
|
582
|
+
## 8. Decision Guide
|
|
583
|
+
|
|
584
|
+
| Scenario | Variant | Reason |
|
|
585
|
+
|----------|---------|--------|
|
|
586
|
+
| Employee, Manager | Mandatory | Always a system User — they log in to manage their work |
|
|
587
|
+
| Instructor, Trainer | Mandatory | Must log in to manage schedules, courses, content |
|
|
588
|
+
| Technician, Agent | Mandatory | Must log in to receive assignments and report |
|
|
589
|
+
| Customer with portal | Optional | May or may not have a portal account |
|
|
590
|
+
| Supplier | Optional | May have a supplier portal or be contact-only |
|
|
591
|
+
| Contact, Lead | Optional | May become a User later (CRM lifecycle) |
|
|
592
|
+
| Patient (patient portal) | Optional | Some patients use the portal, others don't |
|
|
593
|
+
| Candidate (recruitment) | Optional | May become an Employee (and User) after hiring |
|
|
594
|
+
| External consultant | Optional | Temporary access, may or may not have a system account |
|
|
595
|
+
|
|
596
|
+
**Rule of thumb:** If the person MUST interact with the system to do their job, use **mandatory**. If the person EXISTS independently of system access, use **optional**.
|
|
@@ -1511,6 +1511,84 @@ if [ -n "$NAV_SEED_FILES" ]; then
|
|
|
1511
1511
|
fi
|
|
1512
1512
|
```
|
|
1513
1513
|
|
|
1514
|
+
### POST-CHECK 51: Person Extension entities must not duplicate User fields (BLOCKING)
|
|
1515
|
+
|
|
1516
|
+
```bash
|
|
1517
|
+
# Mandatory person extension entities (UserId non-nullable + ITenantEntity) must NOT have
|
|
1518
|
+
# person fields (FirstName, LastName, Email, PhoneNumber). These come from the linked User.
|
|
1519
|
+
# Optional variants (UserId nullable) are expected to have their own person fields.
|
|
1520
|
+
ENTITY_FILES=$(find src/ -path "*/Domain/Entities/*" -name "*.cs" 2>/dev/null)
|
|
1521
|
+
if [ -n "$ENTITY_FILES" ]; then
|
|
1522
|
+
FAIL=false
|
|
1523
|
+
for f in $ENTITY_FILES; do
|
|
1524
|
+
# Check if entity has UserId (person extension pattern)
|
|
1525
|
+
HAS_USERID=$(grep -P 'public\s+Guid\s+UserId\s*\{' "$f" 2>/dev/null)
|
|
1526
|
+
if [ -z "$HAS_USERID" ]; then continue; fi
|
|
1527
|
+
|
|
1528
|
+
# Check if UserId is non-nullable (mandatory variant)
|
|
1529
|
+
IS_MANDATORY=$(grep -P 'public\s+Guid\s+UserId\s*\{' "$f" 2>/dev/null | grep -v 'Guid?')
|
|
1530
|
+
if [ -z "$IS_MANDATORY" ]; then continue; fi
|
|
1531
|
+
|
|
1532
|
+
# Check for ITenantEntity (confirms it's a person extension, not a random FK)
|
|
1533
|
+
if ! grep -q "ITenantEntity" "$f"; then continue; fi
|
|
1534
|
+
|
|
1535
|
+
# Mandatory person extension: MUST NOT have person fields
|
|
1536
|
+
PERSON_FIELDS=$(grep -Pn 'public\s+string\S*\s+(FirstName|LastName|Email|PhoneNumber)\s*\{' "$f" 2>/dev/null)
|
|
1537
|
+
if [ -n "$PERSON_FIELDS" ]; then
|
|
1538
|
+
echo "BLOCKING: Mandatory person extension entity duplicates User fields: $f"
|
|
1539
|
+
echo " Entity has non-nullable UserId (mandatory variant) — person fields come from User"
|
|
1540
|
+
echo "$PERSON_FIELDS"
|
|
1541
|
+
echo " Fix: Remove FirstName/LastName/Email/PhoneNumber — use Display* from User join in ResponseDto"
|
|
1542
|
+
echo " See references/person-extension-pattern.md section 2"
|
|
1543
|
+
FAIL=true
|
|
1544
|
+
fi
|
|
1545
|
+
done
|
|
1546
|
+
if [ "$FAIL" = true ]; then
|
|
1547
|
+
exit 1
|
|
1548
|
+
fi
|
|
1549
|
+
fi
|
|
1550
|
+
```
|
|
1551
|
+
|
|
1552
|
+
### POST-CHECK 52: Person Extension service must Include(User) (BLOCKING)
|
|
1553
|
+
|
|
1554
|
+
```bash
|
|
1555
|
+
# Services operating on entities with UserId FK (person extension pattern) MUST include
|
|
1556
|
+
# .Include(x => x.User) on all queries. Without this, Display* fields are always null.
|
|
1557
|
+
ENTITY_FILES=$(find src/ -path "*/Domain/Entities/*" -name "*.cs" 2>/dev/null)
|
|
1558
|
+
if [ -n "$ENTITY_FILES" ]; then
|
|
1559
|
+
FAIL=false
|
|
1560
|
+
for f in $ENTITY_FILES; do
|
|
1561
|
+
# Check if entity has UserId + ITenantEntity (person extension pattern)
|
|
1562
|
+
HAS_USERID=$(grep -P 'public\s+Guid\??\s+UserId\s*\{' "$f" 2>/dev/null)
|
|
1563
|
+
if [ -z "$HAS_USERID" ]; then continue; fi
|
|
1564
|
+
if ! grep -q "ITenantEntity" "$f"; then continue; fi
|
|
1565
|
+
|
|
1566
|
+
# Check if navigation property User? exists (confirms person extension)
|
|
1567
|
+
HAS_USER_NAV=$(grep -P 'public\s+User\?\s+User\s*\{' "$f" 2>/dev/null)
|
|
1568
|
+
if [ -z "$HAS_USER_NAV" ]; then continue; fi
|
|
1569
|
+
|
|
1570
|
+
# Find the corresponding service file
|
|
1571
|
+
ENTITY_NAME=$(basename "$f" .cs)
|
|
1572
|
+
SERVICE_FILE=$(find src/ -path "*/Services/*" -name "${ENTITY_NAME}Service.cs" ! -name "I${ENTITY_NAME}Service.cs" 2>/dev/null | head -1)
|
|
1573
|
+
if [ -z "$SERVICE_FILE" ]; then continue; fi
|
|
1574
|
+
|
|
1575
|
+
# Check for Include(x => x.User) or Include("User") pattern
|
|
1576
|
+
HAS_INCLUDE=$(grep -P 'Include.*User' "$SERVICE_FILE" 2>/dev/null)
|
|
1577
|
+
if [ -z "$HAS_INCLUDE" ]; then
|
|
1578
|
+
echo "BLOCKING: Service for Person Extension entity must Include(x => x.User): $SERVICE_FILE"
|
|
1579
|
+
echo " Entity: $f has UserId FK + User navigation property"
|
|
1580
|
+
echo " Fix: Add .Include(x => x.User) to all queries in $SERVICE_FILE"
|
|
1581
|
+
echo " Without Include, Display* fields will always be null"
|
|
1582
|
+
echo " See references/person-extension-pattern.md section 5"
|
|
1583
|
+
FAIL=true
|
|
1584
|
+
fi
|
|
1585
|
+
done
|
|
1586
|
+
if [ "$FAIL" = true ]; then
|
|
1587
|
+
exit 1
|
|
1588
|
+
fi
|
|
1589
|
+
fi
|
|
1590
|
+
```
|
|
1591
|
+
|
|
1514
1592
|
---
|
|
1515
1593
|
|
|
1516
1594
|
## Architecture — Clean Architecture Layer Isolation
|