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
cancelledis terminal; cancellation afterin_progressis forbidden (MELMASTOON.STAFF.SHIFT_ALREADY_STARTED).- A shift may auto-transition
in_progress → completedifnow > window.endUtc + autoCloseGraceMin(default 60), emitting asystem_autoclock.out.v1for 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'⇒employmentEndedAtis set and ≥employmentStartedAt. - I-Staff-2.
emailnull ⇒managerEmailForNotificationsnon-null. - I-Staff-3.
staffCodeis unique withintenantId. - I-Staff-4.
homePropertyId ∈ propertyAccess. - I-Staff-5.
clockInPinHmacpresent ⇒clockInPinSetAtpresent. - I-Staff-6.
positionId.tenantId == tenantIdanddepartmentId.tenantId == tenantId.
Shift
- I-Shift-1.
window.endUtc > window.startUtcand duration ≤ 24 h. - I-Shift-2.
primaryHeadcount ≥ 1andstandbyHeadcount ≥ 0. - I-Shift-3.
status='cancelled'⇒cancelledAtset,startedAtnull. - I-Shift-4. Active assignments with
role='primary'whoseunassignedAtis null ≤primaryHeadcount.
Assignment
- I-Asn-1. A given
staffIdmay have at most one active (unassignedAtnull) assignment pershiftId. - I-Asn-2. No two active primary assignments for the same
staffIdwhose shifts'windowoverlap (across the same tenant). This is the hard "no double-shift" rule. - I-Asn-3.
role='on_call'requiresShift.standbyHeadcount > 0.
Clock
- I-Clock-1. A
staffIdhas at most one open clock-in (kind=in, no matchingoutafter) across all properties of the tenant. The "matching" rule is the most-recentoutwhoseoccurredAtUtc > the in's occurredAtUtc. - I-Clock-2.
kind='break_start'is only legal while there is an open clock-in; matchingbreak_endfollows. - I-Clock-3.
kind='out'is only legal if there is an open clock-in. - I-Clock-4.
source='manager_override'⇒managerOverrideBypresent andmanagerOverrideReasonnon-empty. - I-Clock-5.
source='offline_replay'⇒offlineQueueAgeSecondspresent 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.
statusis terminal ⇒decidedAtset. - I-Leave-3.
forceUnassignedAssignmentIdsnon-empty ⇒status='approved'(cascade only on approval).
Cross-aggregate
- I-X-1. A
Shiftcannot be created withpropertyIdnot in any activeDepartmentof that property. - I-X-2. Cancelling a
Shiftrequires that no active primaryClockEntryexists for it.
5. Domain Services (pure)
| Service | Purpose |
|---|---|
ShiftWindowResolver | Convert LocalTimeWindow to TimeWindow using a property tz; handles cross-midnight |
OverlapDetector | Decide if two TimeWindows overlap (half-open intervals) |
PatternMaterializer | Generate Shift[] from a ShiftPattern over a date window, skipping holidays / pauses |
ConflictEvaluator | Given a candidate (staffId, shift), return a list of typed conflicts: double_shift, on_leave, multi_property_active, expired_certification |
CapacitySnapshotBuilder | From (propertyId, position, at), materialize the live capacity DTO |
StaffCodeGenerator | Deterministic 'GM-{prop3}-{posCode}-{seq}' |
PinHmacService | (Port — implemented in infra) HMAC a 6-digit PIN with the KMS-backed pepper |
LeaveCollisionResolver | Decide 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 class | Maps to |
|---|---|
IllegalStaffTransitionError | MELMASTOON.STAFF.ILLEGAL_TRANSITION (409) |
ShiftConflictError | MELMASTOON.STAFF.SHIFT_CONFLICT (409) |
MultiPropertyActiveError | MELMASTOON.STAFF.MULTI_PROPERTY_ACTIVE (409) |
ClockSequenceError | MELMASTOON.STAFF.CLOCK_SEQUENCE_INVALID (409) |
PinLockedError | MELMASTOON.STAFF.PIN_LOCKED (423) |
PinIncorrectError | MELMASTOON.STAFF.PIN_INCORRECT (401) |
LeaveCollisionError | MELMASTOON.STAFF.LEAVE_COLLISION (409) |
CertificationExpiredError | MELMASTOON.STAFF.CERTIFICATION_EXPIRED (422) |
EmailOrManagerRequiredError | MELMASTOON.STAFF.CONTACT_MISSING (422) |
StaffCodeCollisionError | MELMASTOON.STAFF.CODE_COLLISION (409) |
OptimisticConcurrencyError | MELMASTOON.COMMON.OCC_CONFLICT (409) |
InvalidValueError | MELMASTOON.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.