Skip to main content

DOMAIN_MODEL — staff-service

Sibling: SERVICE_OVERVIEW · APPLICATION_LOGIC · DATA_MODEL · EVENT_SCHEMAS

Strategic anchors: 02 Enterprise Architecture §3 · 04 Event-Driven Architecture §3 · standards/NAMING · standards/ERROR_CODES

The domain layer is pure TypeScript. No NestJS decorators, no Drizzle imports, no pg client, no fetch, no process.env. The CI dependency-graph guard fails any PR introducing such imports under src/domain/. The model below is the source of truth for invariants, value objects, and state machine semantics; the SQL projection (DATA_MODEL) and wire DTOs (API_CONTRACTS / EVENT_SCHEMAS) are downstream of this.


1. Value Objects

import { Branded } from '@ghasi/domain-primitives';

// IDs (ULID with prefix; see DATA_MODEL §2)
export type TenantId = Branded<string, 'TenantId'>; // ten_…
export type PropertyId = Branded<string, 'PropertyId'>; // ppt_…
export type UserId = Branded<string, 'UserId'>; // usr_… (foreign — iam-service)
export type StaffId = Branded<string, 'StaffId'>; // stf_…
export type PositionId = Branded<string, 'PositionId'>; // pos_…
export type DepartmentId = Branded<string, 'DepartmentId'>; // dpt_…
export type ShiftPatternId = Branded<string, 'ShiftPatternId'>; // shp_…
export type ShiftId = Branded<string, 'ShiftId'>; // shf_…
export type AssignmentId = Branded<string, 'AssignmentId'>; // sha_…
export type ClockEntryId = Branded<string, 'ClockEntryId'>; // clk_…
export type LeaveRequestId = Branded<string, 'LeaveRequestId'>; // lvr_…
export type SkillId = Branded<string, 'SkillId'>; // skl_…
export type CertificationId= Branded<string, 'CertificationId'>; // crt_…
export type HandoffNoteId = Branded<string, 'HandoffNoteId'>; // hno_…
export type DeviceId = Branded<string, 'DeviceId'>; // dev_… (foreign — iam-service)

// Time
export type ISODateTime = Branded<string, 'ISODateTime'>; // RFC 3339 UTC
export type ISODate = Branded<string, 'ISODate'>; // YYYY-MM-DD (property local)
export type IanaTz = Branded<string, 'IanaTz'>; // e.g. 'Asia/Kabul'

export interface TimeWindow {
startUtc: ISODateTime;
endUtc: ISODateTime; // exclusive; invariant: endUtc > startUtc
}

export interface LocalTimeWindow {
date: ISODate; // property-local date the shift "belongs to"
startLocal: string; // HH:mm 24h
endLocal: string; // HH:mm 24h, may cross midnight (then date+1)
tz: IanaTz; // resolved from property-service
}

// Localized labels — required for Position, Department, Skill names
export interface LocalizedLabel {
en: string;
ps?: string; // Pashto
fa?: string; // Persian / Dari
ar?: string; // Arabic
ur?: string; // Urdu
tg?: string; // Tajik
}

// Languages spoken — BCP-47 with proficiency
export type LanguageProficiency = 'basic' | 'conversational' | 'fluent' | 'native';
export interface SpokenLanguage {
code: string; // 'ps', 'fa', 'en', 'ar', 'ur', 'tg', …
proficiency: LanguageProficiency;
}

// Emergency contact (PII; envelope-encrypted at rest, see SECURITY_MODEL §6)
export interface EmergencyContact {
fullName: string;
relationship: string; // free-form, max 64
phoneE164: string; // +AAA…
altPhoneE164?: string;
}

// Employment state
export type EmploymentType = 'full_time' | 'part_time' | 'temporary' | 'seasonal' | 'family_help' | 'contractor';
export type EmploymentStatus = 'pending_invite' | 'active' | 'on_leave' | 'suspended' | 'terminated';

// Clock-in source attribution
export type ClockSource =
| 'electron_pin' // Electron desktop, PIN
| 'electron_jwt' // Electron desktop, JWT (rare; manager session)
| 'mobile_jwt' // Mobile app
| 'web_jwt' // Backoffice web
| 'manager_override' // Manager added retroactively
| 'offline_replay' // Sync push from offline queue
| 'system_auto'; // Auto-out at +N min after shift end

export type AssignmentRole = 'primary' | 'standby' | 'on_call';

All value-object factories throw InvalidValueError on validation; LocalTimeWindow validates HH:mm shape and tz; SpokenLanguage rejects unknown ISO 639-1 codes.


2. Aggregates and Entities

2.1 Staff (aggregate root)

export interface Staff {
id: StaffId;
tenantId: TenantId;
homePropertyId: PropertyId;
propertyAccess: PropertyId[]; // includes homePropertyId; multi-property staff

userId?: UserId; // nullable — pending invite or PIN-only staff
staffCode: string; // human-readable, unique per tenant: 'GM-DOH-FD-014'
givenName: string;
familyName: string;
displayName?: string; // optional override; otherwise '{given} {family}'
preferredLocale: string; // BCP-47, default 'en'

email?: string; // nullable
managerEmailForNotifications?: string; // required if email is null
phoneE164?: string;
emergencyContact?: EmergencyContact;

positionId: PositionId;
departmentId: DepartmentId;
employmentType: EmploymentType;
employmentStatus: EmploymentStatus;
employmentStartedAt: ISODate;
employmentEndedAt?: ISODate; // present iff status='terminated'

spokenLanguages: SpokenLanguage[];
skillIds: SkillId[];
certificationIds: CertificationId[];

clockInPinHmac?: Uint8Array; // present iff PIN was set; never plaintext in domain
clockInPinSetAt?: ISODateTime;
clockInPinFailedAttempts: number; // reset on success; lockout at 5
clockInPinLockedUntil?: ISODateTime;

version: number; // OCC
createdAt: ISODateTime;
updatedAt: ISODateTime;
}

2.2 Position

export interface Position {
id: PositionId;
tenantId: TenantId;
departmentId: DepartmentId;
code: string; // tenant-unique: 'FRONT_DESK', 'HOUSEKEEPER', 'GM', …
label: LocalizedLabel;
capacitySignalKey: string; // e.g., 'housekeeper_active' — what consumers subscribe to
active: boolean;
createdAt: ISODateTime;
updatedAt: ISODateTime;
}

2.3 Department

export interface Department {
id: DepartmentId;
tenantId: TenantId;
propertyId: PropertyId; // departments are property-scoped
code: string; // tenant-unique per property
label: LocalizedLabel;
active: boolean;
createdAt: ISODateTime;
updatedAt: ISODateTime;
}

2.4 ShiftPattern

export interface ShiftPattern {
id: ShiftPatternId;
tenantId: TenantId;
propertyId: PropertyId;
positionId: PositionId;
name: string; // 'Front Desk Morning'
cadence: 'weekly' | 'bi_weekly';
weekDays: WeekDay[]; // ['mon','tue','wed','thu','fri']
startLocal: string; // 'HH:mm'
endLocal: string; // 'HH:mm' — may be next-day
primaryHeadcount: number; // ≥ 1
standbyHeadcount: number; // ≥ 0
effectiveFrom: ISODate;
effectiveTo?: ISODate; // null = open-ended
active: boolean;
version: number;
createdAt: ISODateTime;
updatedAt: ISODateTime;
}

export type WeekDay = 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat' | 'sun';

2.5 Shift

export interface Shift {
id: ShiftId;
tenantId: TenantId;
propertyId: PropertyId;
positionId: PositionId;
patternId?: ShiftPatternId; // null = ad-hoc
window: TimeWindow; // resolved UTC
localWindow: LocalTimeWindow;
primaryHeadcount: number;
standbyHeadcount: number;
status: ShiftStatus;
notes?: string; // pre-shift; max 2000
startedAt?: ISODateTime; // first primary clocked in
endedAt?: ISODateTime; // last primary clocked out OR auto-close
cancelledAt?: ISODateTime;
cancelReason?: string;
version: number;
createdAt: ISODateTime;
updatedAt: ISODateTime;
}

export type ShiftStatus =
| 'scheduled' // future or current, not yet started
| 'in_progress' // at least one primary clocked in
| 'completed' // all primaries clocked out, past endedAt
| 'cancelled'; // cancelled before start

2.6 ShiftAssignment

export interface ShiftAssignment {
id: AssignmentId;
tenantId: TenantId;
shiftId: ShiftId;
staffId: StaffId;
role: AssignmentRole;
source: 'manual' | 'ai_suggested_accepted' | 'auto_promoted';
swappedFromAssignmentId?: AssignmentId; // chain on swap
acknowledgedAt?: ISODateTime;
unassignedAt?: ISODateTime; // soft-removal; preserved for audit
unassignReason?: string;
version: number;
createdAt: ISODateTime;
updatedAt: ISODateTime;
}

2.7 ClockEntry (append-only)

export interface ClockEntry {
id: ClockEntryId;
tenantId: TenantId;
staffId: StaffId;
propertyId: PropertyId;
shiftId?: ShiftId; // nullable — clock-in without scheduled shift
kind: 'in' | 'out' | 'break_start' | 'break_end';
occurredAtUtc: ISODateTime;
recordedAtUtc: ISODateTime; // server-receive time
source: ClockSource;
deviceId?: DeviceId;
managerOverrideBy?: UserId; // present iff source='manager_override'
managerOverrideReason?: string;
geofencedOk?: boolean; // future use; M1 always null
offlineQueueAgeSeconds?: number;// only for source='offline_replay'
// No updatedAt — append-only.
}

2.8 LeaveRequest

export interface LeaveRequest {
id: LeaveRequestId;
tenantId: TenantId;
staffId: StaffId;
type: 'sick' | 'vacation' | 'unpaid';
windowLocal: { from: ISODate; to: ISODate }; // inclusive
reason?: string; // max 500
status: 'requested' | 'approved' | 'rejected' | 'cancelled';
decidedBy?: UserId;
decidedAt?: ISODateTime;
decisionNote?: string;
forceUnassignedAssignmentIds?: AssignmentId[]; // present iff approval cascaded
version: number;
createdAt: ISODateTime;
updatedAt: ISODateTime;
}

2.9 StaffSkill

export interface StaffSkill {
id: SkillId; // tenant catalog row id (referenced by Staff.skillIds)
tenantId: TenantId;
code: string; // tenant-unique: 'OPERATE_LAUNDRY', 'BARTEND_BASIC'
label: LocalizedLabel;
category: 'language' | 'equipment' | 'soft' | 'other';
active: boolean;
}

2.10 StaffCertification

export interface StaffCertification {
id: CertificationId;
tenantId: TenantId;
staffId: StaffId;
type: 'food_handling' | 'first_aid' | 'fire_safety' | 'pool_safety' | 'security_license' | 'other';
customLabel?: LocalizedLabel; // required iff type='other'
issuedAt: ISODate;
expiresAt?: ISODate; // null = perpetual
documentRef?: string; // gs:// path; uploaded via signed URL
expiredEmittedAt?: ISODateTime; // bookkeeping for the .expired.v1 publisher
createdAt: ISODateTime;
updatedAt: ISODateTime;
}

2.11 HandoffNote (append-only)

export interface HandoffNote {
id: HandoffNoteId;
tenantId: TenantId;
propertyId: PropertyId;
positionId: PositionId;
fromStaffId: StaffId;
fromShiftId?: ShiftId;
body: string; // max 4000; markdown light
attachments?: string[]; // gs:// references
acknowledgedBy?: { staffId: StaffId; at: ISODateTime }[];
createdAt: ISODateTime;
// No update; corrections require a new note.
}

3. State Machines

3.1 Staff.employmentStatus

invite issued
(none) ─────────────▶ pending_invite
│ invite accepted

active ───────────▶ on_leave (approved leave starts)
│ ◀────────── (leave ends)

│ suspend

suspended ──reinstate──▶ active

│ terminate

terminated ──rehire──▶ active (emits .reactivated.v1)

Terminal: none in M1 (terminated rows are retained for the legal window; see DATA_MODEL §6 retention).

3.2 Shift.status

create
(none) ───────▶ scheduled ──first primary clocks in──▶ in_progress
│ │
│ cancel before start │ all primaries out
▼ ▼
cancelled completed
  • cancelled is terminal; cancellation after in_progress is forbidden (MELMASTOON.STAFF.SHIFT_ALREADY_STARTED).
  • A shift may auto-transition in_progress → completed if now > window.endUtc + autoCloseGraceMin (default 60), emitting a system_auto clock.out.v1 for any still-clocked-in primary.

3.3 LeaveRequest.status

(none) ──submit──▶ requested ──approve──▶ approved
│ ──reject──▶ rejected

│ cancel by requester

cancelled

approved and rejected and cancelled are terminal.


4. Invariants

The domain enforces the following. Each is unit-tested in staff.invariants.spec.ts (pass + fail).

Staff

  • I-Staff-1. employmentStatus = 'terminated'employmentEndedAt is set and ≥ employmentStartedAt.
  • I-Staff-2. email null ⇒ managerEmailForNotifications non-null.
  • I-Staff-3. staffCode is unique within tenantId.
  • I-Staff-4. homePropertyId ∈ propertyAccess.
  • I-Staff-5. clockInPinHmac present ⇒ clockInPinSetAt present.
  • I-Staff-6. positionId.tenantId == tenantId and departmentId.tenantId == tenantId.

Shift

  • I-Shift-1. window.endUtc > window.startUtc and duration ≤ 24 h.
  • I-Shift-2. primaryHeadcount ≥ 1 and standbyHeadcount ≥ 0.
  • I-Shift-3. status='cancelled'cancelledAt set, startedAt null.
  • I-Shift-4. Active assignments with role='primary' whose unassignedAt is null ≤ primaryHeadcount.

Assignment

  • I-Asn-1. A given staffId may have at most one active (unassignedAt null) assignment per shiftId.
  • I-Asn-2. No two active primary assignments for the same staffId whose shifts' window overlap (across the same tenant). This is the hard "no double-shift" rule.
  • I-Asn-3. role='on_call' requires Shift.standbyHeadcount > 0.

Clock

  • I-Clock-1. A staffId has at most one open clock-in (kind=in, no matching out after) across all properties of the tenant. The "matching" rule is the most-recent out whose occurredAtUtc > the in's occurredAtUtc.
  • I-Clock-2. kind='break_start' is only legal while there is an open clock-in; matching break_end follows.
  • I-Clock-3. kind='out' is only legal if there is an open clock-in.
  • I-Clock-4. source='manager_override'managerOverrideBy present and managerOverrideReason non-empty.
  • I-Clock-5. source='offline_replay'offlineQueueAgeSeconds present and ≥ 0.
  • I-Clock-6. Append-only: no row may be deleted or updated post-insert (DB-level via DATA_MODEL §5 trigger).

Leave

  • I-Leave-1. windowLocal.from ≤ windowLocal.to.
  • I-Leave-2. status is terminal ⇒ decidedAt set.
  • I-Leave-3. forceUnassignedAssignmentIds non-empty ⇒ status='approved' (cascade only on approval).

Cross-aggregate

  • I-X-1. A Shift cannot be created with propertyId not in any active Department of that property.
  • I-X-2. Cancelling a Shift requires that no active primary ClockEntry exists for it.

5. Domain Services (pure)

ServicePurpose
ShiftWindowResolverConvert LocalTimeWindow to TimeWindow using a property tz; handles cross-midnight
OverlapDetectorDecide if two TimeWindows overlap (half-open intervals)
PatternMaterializerGenerate Shift[] from a ShiftPattern over a date window, skipping holidays / pauses
ConflictEvaluatorGiven a candidate (staffId, shift), return a list of typed conflicts: double_shift, on_leave, multi_property_active, expired_certification
CapacitySnapshotBuilderFrom (propertyId, position, at), materialize the live capacity DTO
StaffCodeGeneratorDeterministic 'GM-{prop3}-{posCode}-{seq}'
PinHmacService(Port — implemented in infra) HMAC a 6-digit PIN with the KMS-backed pepper
LeaveCollisionResolverDecide which assignments need force-unassign on leave approval

Domain services accept and return value objects; they do not perform I/O.


6. Domain Errors

Domain layer throws typed errors (no HTTP knowledge). The application layer maps them to the canonical error codes.

Domain error classMaps to
IllegalStaffTransitionErrorMELMASTOON.STAFF.ILLEGAL_TRANSITION (409)
ShiftConflictErrorMELMASTOON.STAFF.SHIFT_CONFLICT (409)
MultiPropertyActiveErrorMELMASTOON.STAFF.MULTI_PROPERTY_ACTIVE (409)
ClockSequenceErrorMELMASTOON.STAFF.CLOCK_SEQUENCE_INVALID (409)
PinLockedErrorMELMASTOON.STAFF.PIN_LOCKED (423)
PinIncorrectErrorMELMASTOON.STAFF.PIN_INCORRECT (401)
LeaveCollisionErrorMELMASTOON.STAFF.LEAVE_COLLISION (409)
CertificationExpiredErrorMELMASTOON.STAFF.CERTIFICATION_EXPIRED (422)
EmailOrManagerRequiredErrorMELMASTOON.STAFF.CONTACT_MISSING (422)
StaffCodeCollisionErrorMELMASTOON.STAFF.CODE_COLLISION (409)
OptimisticConcurrencyErrorMELMASTOON.COMMON.OCC_CONFLICT (409)
InvalidValueErrorMELMASTOON.COMMON.INVALID_INPUT (400)

All error codes appear in docs/standards/ERROR_CODES.md; any new code must be PR-added there before the use case ships.