# Person Extension Pattern — Source of Truth

> **Loaded by:** step-03 (execute) when entity has `personRoleConfig`
> **Purpose:** Defines the pattern for entities that represent a "person role" of a User (Employee, Customer, Contact, etc.)

---

## 1. Overview

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.

**Two variants exist:**

| Variant | UserId | Use case | Unique index |
|---------|--------|----------|-------------|
| **Mandatory** | `Guid UserId` (non-nullable) | Employee, Manager — always a User | `(TenantId, UserId)` |
| **Optional** | `Guid? UserId` (nullable) | Customer, Contact — may or may not have account | `(TenantId, UserId)` filtered (`WHERE UserId IS NOT NULL`) |

**Rule:** As soon as a person CAN log in to the system (even potentially), they must be linked to User.

---

## 2. Entity Pattern — Mandatory UserId

For entities where the person is **always** a system User (they must log in).

```csharp
using SmartStack.Domain.Common;

namespace {ProjectName}.Domain.Entities.{App}.{Module};

public class Employee : BaseEntity, ITenantEntity, IAuditableEntity
{
    // ITenantEntity
    public Guid TenantId { get; private set; }

    // IAuditableEntity
    public string? CreatedBy { get; set; }
    public string? UpdatedBy { get; set; }

    // === USER LINK (Person Extension — required) ===
    public Guid UserId { get; private set; }
    public User? User { get; private set; }

    // === BUSINESS PROPERTIES (role-specific ONLY) ===
    public string Code { get; private set; } = null!;
    public DateOnly HireDate { get; private set; }
    public EmployeeStatus Status { get; private set; }

    // ZERO person fields: FirstName, LastName, Email, Department -> come from User

    private Employee() { }

    public static Employee Create(Guid tenantId, Guid userId, string code, DateOnly hireDate)
    {
        if (tenantId == Guid.Empty)
            throw new ArgumentException("TenantId is required", nameof(tenantId));
        if (userId == Guid.Empty)
            throw new ArgumentException("UserId is required", nameof(userId));

        return new Employee
        {
            Id = Guid.NewGuid(),
            TenantId = tenantId,
            UserId = userId,
            Code = code.ToLowerInvariant(),
            HireDate = hireDate,
            Status = EmployeeStatus.Active,
            CreatedAt = DateTime.UtcNow
        };
    }
}
```

**Key rules (mandatory variant):**
- `Guid UserId` (non-nullable) — entity cannot exist without a User
- ZERO person fields (`FirstName`, `LastName`, `Email`, `PhoneNumber`) — all come from `User`
- Only business-specific properties (Code, HireDate, Status, etc.)

---

## 3. Entity Pattern — Optional UserId

For entities where the person **may or may not** have a system account.

```csharp
using SmartStack.Domain.Common;

namespace {ProjectName}.Domain.Entities.{App}.{Module};

public class Customer : BaseEntity, ITenantEntity, IAuditableEntity
{
    // ITenantEntity
    public Guid TenantId { get; private set; }

    // IAuditableEntity
    public string? CreatedBy { get; set; }
    public string? UpdatedBy { get; set; }

    // === USER LINK (Person Extension — optional) ===
    public Guid? UserId { get; private set; }
    public User? User { get; private set; }

    // === PERSON FIELDS (standalone when no User linked) ===
    public string FirstName { get; private set; } = null!;
    public string LastName { get; private set; } = null!;
    public string? Email { get; private set; }
    public string? PhoneNumber { get; private set; }

    // === BUSINESS PROPERTIES ===
    public string Code { get; private set; } = null!;
    public string? CompanyName { get; private set; }
    public CustomerStatus Status { get; private set; }

    // === COMPUTED (read from User if linked, else from own fields) ===
    public string DisplayFirstName => User?.FirstName ?? FirstName;
    public string DisplayLastName => User?.LastName ?? LastName;
    public string? DisplayEmail => User?.Email ?? Email;

    private Customer() { }

    public static Customer Create(
        Guid tenantId, string code, string firstName, string lastName,
        Guid? userId = null, string? email = null, string? companyName = null)
    {
        if (tenantId == Guid.Empty)
            throw new ArgumentException("TenantId is required", nameof(tenantId));

        return new Customer
        {
            Id = Guid.NewGuid(),
            TenantId = tenantId,
            UserId = userId,
            Code = code.ToLowerInvariant(),
            FirstName = firstName,
            LastName = lastName,
            Email = email,
            CompanyName = companyName,
            Status = CustomerStatus.Active,
            CreatedAt = DateTime.UtcNow
        };
    }
}
```

**Key rules (optional variant):**
- `Guid? UserId` (nullable) — entity can exist independently
- Person fields present (`FirstName`, `LastName`, `Email`, `PhoneNumber`) for standalone use
- Computed `Display*` properties resolve from User when linked, else from own fields

---

## 4. EF Configuration Pattern

### Mandatory UserId

```csharp
public class EmployeeConfiguration : IEntityTypeConfiguration<Employee>
{
    public void Configure(EntityTypeBuilder<Employee> builder)
    {
        builder.ToTable("{prefix}Employees", "{schema}");
        builder.HasKey(x => x.Id);

        // Tenant
        builder.Property(x => x.TenantId).IsRequired();
        builder.HasIndex(x => x.TenantId)
            .HasDatabaseName("IX_{prefix}Employees_TenantId");

        // User link (required)
        builder.Property(x => x.UserId).IsRequired();
        builder.HasOne(e => e.User)
            .WithMany()
            .HasForeignKey(e => e.UserId)
            .OnDelete(DeleteBehavior.Restrict);

        // Unique constraint: one person-role per User per Tenant
        builder.HasIndex(e => new { e.TenantId, e.UserId })
            .IsUnique()
            .HasDatabaseName("IX_{prefix}Employees_Tenant_User");

        // Business properties
        builder.Property(x => x.Code).HasMaxLength(50).IsRequired();
        builder.Property(x => x.HireDate).IsRequired();

        // Audit
        builder.Property(x => x.CreatedBy).HasMaxLength(256);
        builder.Property(x => x.UpdatedBy).HasMaxLength(256);

        // Unique code per tenant
        builder.HasIndex(x => new { x.TenantId, x.Code })
            .IsUnique()
            .HasDatabaseName("IX_{prefix}Employees_Tenant_Code");
    }
}
```

### Optional UserId

```csharp
public class CustomerConfiguration : IEntityTypeConfiguration<Customer>
{
    public void Configure(EntityTypeBuilder<Customer> builder)
    {
        builder.ToTable("{prefix}Customers", "{schema}");
        builder.HasKey(x => x.Id);

        // Tenant
        builder.Property(x => x.TenantId).IsRequired();
        builder.HasIndex(x => x.TenantId)
            .HasDatabaseName("IX_{prefix}Customers_TenantId");

        // User link (optional)
        builder.Property(x => x.UserId).IsRequired(false);
        builder.HasOne(e => e.User)
            .WithMany()
            .HasForeignKey(e => e.UserId)
            .OnDelete(DeleteBehavior.Restrict);

        // Filtered unique constraint: one person-role per User per Tenant (only when linked)
        builder.HasIndex(e => new { e.TenantId, e.UserId })
            .IsUnique()
            .HasFilter("[UserId] IS NOT NULL")
            .HasDatabaseName("IX_{prefix}Customers_Tenant_User");

        // Person fields (for standalone use)
        builder.Property(x => x.FirstName).HasMaxLength(100).IsRequired();
        builder.Property(x => x.LastName).HasMaxLength(100).IsRequired();
        builder.Property(x => x.Email).HasMaxLength(256);
        builder.Property(x => x.PhoneNumber).HasMaxLength(50);

        // Computed properties are NOT mapped (read-only in C#)
        builder.Ignore(x => x.DisplayFirstName);
        builder.Ignore(x => x.DisplayLastName);
        builder.Ignore(x => x.DisplayEmail);

        // Business properties
        builder.Property(x => x.Code).HasMaxLength(50).IsRequired();
        builder.Property(x => x.CompanyName).HasMaxLength(200);

        // Audit
        builder.Property(x => x.CreatedBy).HasMaxLength(256);
        builder.Property(x => x.UpdatedBy).HasMaxLength(256);

        // Unique code per tenant
        builder.HasIndex(x => new { x.TenantId, x.Code })
            .IsUnique()
            .HasDatabaseName("IX_{prefix}Customers_Tenant_Code");
    }
}
```

**Key differences:**
- Required variant: `.IsRequired()` on UserId, plain `IsUnique()` on `(TenantId, UserId)`
- Optional variant: `.IsRequired(false)` on UserId, `IsUnique().HasFilter("[UserId] IS NOT NULL")` on `(TenantId, UserId)`
- Optional: `builder.Ignore()` on computed `Display*` properties (not persisted)

---

## 5. Service Pattern

### Mandatory Variant

```csharp
public class EmployeeService : IEmployeeService
{
    private readonly IExtensionsDbContext _db;
    private readonly ICoreDbContext _coreDb;  // For User reads
    private readonly ICurrentTenantService _currentTenant;
    private readonly ICurrentUserService _currentUser;
    private readonly ILogger<EmployeeService> _logger;

    public async Task<PaginatedResult<EmployeeResponseDto>> GetAllAsync(
        string? search = null, int page = 1, int pageSize = 20, CancellationToken ct = default)
    {
        var tenantId = _currentTenant.TenantId
            ?? throw new TenantContextRequiredException();

        var query = _db.Employees
            .Include(x => x.User)  // Always load User for display
            .Where(x => x.TenantId == tenantId)
            .AsNoTracking();

        if (!string.IsNullOrWhiteSpace(search))
        {
            query = query.Where(x =>
                x.Code.Contains(search) ||
                x.User!.FirstName.Contains(search) ||  // Search on User fields
                x.User!.LastName.Contains(search) ||
                x.User!.Email.Contains(search));
        }

        return await query
            .OrderBy(x => x.User!.LastName)
            .Select(x => new EmployeeResponseDto(
                x.Id, x.Code, x.HireDate, x.Status,
                x.User!.FirstName, x.User!.LastName, x.User!.Email))
            .ToPaginatedResultAsync(page, pageSize, ct);
    }

    public async Task<EmployeeResponseDto> CreateAsync(CreateEmployeeDto dto, CancellationToken ct)
    {
        var tenantId = _currentTenant.TenantId
            ?? throw new TenantContextRequiredException();

        // Verify User exists
        var userExists = await _coreDb.Users.AnyAsync(u => u.Id == dto.UserId, ct);
        if (!userExists)
            throw new KeyNotFoundException($"User {dto.UserId} not found");

        // Verify no duplicate (TenantId, UserId) — one employee per user per tenant
        var duplicate = await _db.Employees
            .AnyAsync(e => e.TenantId == tenantId && e.UserId == dto.UserId, ct);
        if (duplicate)
            throw new InvalidOperationException($"An employee already exists for user {dto.UserId} in this tenant");

        var entity = Employee.Create(tenantId, dto.UserId, dto.Code, dto.HireDate);
        entity.CreatedBy = _currentUser.UserId?.ToString();

        _db.Employees.Add(entity);
        await _db.SaveChangesAsync(ct);

        // Reload with User for response
        var created = await _db.Employees
            .Include(x => x.User)
            .FirstAsync(x => x.Id == entity.Id, ct);

        return new EmployeeResponseDto(
            created.Id, created.Code, created.HireDate, created.Status,
            created.User!.FirstName, created.User!.LastName, created.User!.Email);
    }
}
```

### Optional Variant

```csharp
public class CustomerService : ICustomerService
{
    private readonly IExtensionsDbContext _db;
    private readonly ICoreDbContext _coreDb;  // For User reads
    private readonly ICurrentTenantService _currentTenant;
    private readonly ICurrentUserService _currentUser;
    private readonly ILogger<CustomerService> _logger;

    public async Task<PaginatedResult<CustomerResponseDto>> GetAllAsync(
        string? search = null, int page = 1, int pageSize = 20, CancellationToken ct = default)
    {
        var tenantId = _currentTenant.TenantId
            ?? throw new TenantContextRequiredException();

        var query = _db.Customers
            .Include(x => x.User)  // Load User for Display* resolution
            .Where(x => x.TenantId == tenantId)
            .AsNoTracking();

        if (!string.IsNullOrWhiteSpace(search))
        {
            // Search fallback: User fields first, then own fields
            query = query.Where(x =>
                x.Code.Contains(search) ||
                (x.User != null ? x.User.FirstName.Contains(search) : x.FirstName.Contains(search)) ||
                (x.User != null ? x.User.LastName.Contains(search) : x.LastName.Contains(search)) ||
                (x.User != null ? x.User.Email!.Contains(search) : (x.Email != null && x.Email.Contains(search))));
        }

        return await query
            .OrderBy(x => x.User != null ? x.User.LastName : x.LastName)
            .Select(x => new CustomerResponseDto(
                x.Id, x.Code, x.Status, x.CompanyName,
                x.User != null ? x.User.FirstName : x.FirstName,
                x.User != null ? x.User.LastName : x.LastName,
                x.User != null ? x.User.Email : x.Email,
                x.UserId))
            .ToPaginatedResultAsync(page, pageSize, ct);
    }

    public async Task<CustomerResponseDto> CreateAsync(CreateCustomerDto dto, CancellationToken ct)
    {
        var tenantId = _currentTenant.TenantId
            ?? throw new TenantContextRequiredException();

        // If UserId provided, verify existence + no duplicate
        if (dto.UserId.HasValue)
        {
            var userExists = await _coreDb.Users.AnyAsync(u => u.Id == dto.UserId.Value, ct);
            if (!userExists)
                throw new KeyNotFoundException($"User {dto.UserId} not found");

            var duplicate = await _db.Customers
                .AnyAsync(c => c.TenantId == tenantId && c.UserId == dto.UserId.Value, ct);
            if (duplicate)
                throw new InvalidOperationException($"A customer already exists for user {dto.UserId} in this tenant");
        }

        var entity = Customer.Create(
            tenantId, dto.Code, dto.FirstName, dto.LastName,
            dto.UserId, dto.Email, dto.CompanyName);
        entity.CreatedBy = _currentUser.UserId?.ToString();

        _db.Customers.Add(entity);
        await _db.SaveChangesAsync(ct);

        // Reload with User for response
        var created = await _db.Customers
            .Include(x => x.User)
            .FirstAsync(x => x.Id == entity.Id, ct);

        return new CustomerResponseDto(
            created.Id, created.Code, created.Status, created.CompanyName,
            created.User != null ? created.User.FirstName : created.FirstName,
            created.User != null ? created.User.LastName : created.LastName,
            created.User != null ? created.User.Email : created.Email,
            created.UserId);
    }
}
```

**Key service rules:**
- Both variants: `Include(x => x.User)` on ALL queries
- Both variants: inject `ICoreDbContext` for User reads (User is in the core schema)
- Mandatory variant: search on `User.FirstName`, `User.LastName`, `User.Email` directly
- Optional variant: search fallback `User.FirstName ?? x.FirstName`
- Both: CreateAsync verifies User exists + no duplicate `(TenantId, UserId)`

---

## 6. DTO Pattern

### Mandatory Variant

```csharp
// CreateDto — only UserId + business fields (no person fields)
public record CreateEmployeeDto(
    Guid UserId,        // required — links to existing User
    string Code,
    DateOnly HireDate
);

// UpdateDto — no UserId change (person link is immutable)
public record UpdateEmployeeDto(
    DateOnly? HireDate,
    EmployeeStatus? Status
);

// ResponseDto — includes Display* fields resolved from User
public record EmployeeResponseDto(
    Guid Id,
    string Code,
    DateOnly HireDate,
    EmployeeStatus Status,
    string DisplayFirstName,   // from User.FirstName
    string DisplayLastName,    // from User.LastName
    string? DisplayEmail       // from User.Email
);
```

### Optional Variant

```csharp
// CreateDto — UserId optional + person fields for standalone use
public record CreateCustomerDto(
    Guid? UserId,         // optional — null if no User account
    string FirstName,     // required when UserId is null
    string LastName,      // required when UserId is null
    string? Email,
    string Code,
    string? CompanyName
);

// UpdateDto — person fields updatable (may change independently of User)
public record UpdateCustomerDto(
    string? FirstName,
    string? LastName,
    string? Email,
    string? CompanyName,
    CustomerStatus? Status
);

// ResponseDto — includes resolved Display* fields
public record CustomerResponseDto(
    Guid Id,
    string Code,
    CustomerStatus Status,
    string? CompanyName,
    string DisplayFirstName,   // from User.FirstName ?? own FirstName
    string DisplayLastName,    // from User.LastName ?? own LastName
    string? DisplayEmail,      // from User.Email ?? own Email
    Guid? UserId               // null if not linked
);
```

**Key DTO rules:**
- Mandatory variant CreateDto: `Guid UserId` + business fields only (no name/email)
- Optional variant CreateDto: `Guid? UserId` + person fields (FirstName, LastName, Email)
- ResponseDto (both): includes `Display*` fields resolved from User or own fields
- UserId is NOT in UpdateDto — the person link is set at creation time

---

## 7. Frontend Pattern

### Mandatory — CreatePage

```tsx
// EntityLookup for User selection (required)
<EntityLookup
  apiEndpoint="/api/administration/users"
  value={formData.userId}
  onChange={(id) => handleChange('userId', id)}
  label={t('module:form.user', 'User')}
  mapOption={(user) => ({
    id: user.id,
    label: `${user.firstName} ${user.lastName}`,
    sublabel: user.email
  })}
  required
/>

// Business fields only — no person fields
<div>
  <label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
    {t('module:form.hireDate', 'Hire Date')}
  </label>
  <input
    type="date"
    value={formData.hireDate}
    onChange={(e) => handleChange('hireDate', e.target.value)}
    required
    className="w-full px-3 py-2 rounded-[var(--radius-input)] border border-[var(--border-color)] bg-[var(--bg-tertiary)] text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-accent-500)]"
  />
</div>
```

### Optional — CreatePage

```tsx
// EntityLookup for optional User selection
<EntityLookup
  apiEndpoint="/api/administration/users"
  value={formData.userId}
  onChange={(id) => handleChange('userId', id)}
  label={t('module:form.linkUser', 'Link to User (optional)')}
  mapOption={(user) => ({
    id: user.id,
    label: `${user.firstName} ${user.lastName}`,
    sublabel: user.email
  })}
  clearable
/>

{/* Person fields shown when NO User selected */}
{!formData.userId && (
  <>
    <input value={formData.firstName} label={t('module:form.firstName', 'First Name')} required />
    <input value={formData.lastName} label={t('module:form.lastName', 'Last Name')} required />
    <input value={formData.email} label={t('module:form.email', 'Email')} />
  </>
)}
```

### ListPage (both variants)

```tsx
// Composite columns from response DTO (already resolved by backend)
<DataTable
  columns={[
    { key: 'code', label: t('module:columns.code', 'Code'), sortable: true },
    { key: 'displayFirstName', label: t('module:columns.firstName', 'First Name'), sortable: true },
    { key: 'displayLastName', label: t('module:columns.lastName', 'Last Name'), sortable: true },
    { key: 'displayEmail', label: t('module:columns.email', 'Email'), sortable: true },
    { key: 'status', label: t('module:columns.status', 'Status') },
  ]}
  data={items}
  searchable
  pagination={{ pageSize: 10 }}
/>
```

### DetailPage — Identity Section

```tsx
// "Identity" section showing User info read-only
<section>
  <h3>{t('module:detail.identity', 'Identity')}</h3>
  <dl>
    <dt>{t('module:detail.firstName', 'First Name')}</dt>
    <dd>{entity.displayFirstName}</dd>
    <dt>{t('module:detail.lastName', 'Last Name')}</dt>
    <dd>{entity.displayLastName}</dd>
    <dt>{t('module:detail.email', 'Email')}</dt>
    <dd>{entity.displayEmail}</dd>
    {entity.userId && (
      <>
        <dt>{t('module:detail.linkedUser', 'Linked User')}</dt>
        <dd>{t('module:detail.yes', 'Yes')}</dd>
      </>
    )}
  </dl>
</section>
```

---

## 8. Decision Guide

| Scenario | Variant | Reason |
|----------|---------|--------|
| Employee, Manager | Mandatory | Always a system User — they log in to manage their work |
| Instructor, Trainer | Mandatory | Must log in to manage schedules, courses, content |
| Technician, Agent | Mandatory | Must log in to receive assignments and report |
| Customer with portal | Optional | May or may not have a portal account |
| Supplier | Optional | May have a supplier portal or be contact-only |
| Contact, Lead | Optional | May become a User later (CRM lifecycle) |
| Patient (patient portal) | Optional | Some patients use the portal, others don't |
| Candidate (recruitment) | Optional | May become an Employee (and User) after hiring |
| External consultant | Optional | Temporary access, may or may not have a system account |

**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**.
