@atlashub/smartstack-cli 3.45.0 → 3.47.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.
@@ -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