Skip to main content

housekeeping-service — DOMAIN_MODEL

Pure domain layer. No I/O, no framework imports, no ORM types. Everything below lives in src/domain/** of the service repo. All TypeScript here is illustrative of the canonical types — exact import paths follow the monorepo's @melmastoon/housekeeping-domain package.

This file declares the value objects, aggregates, domain events, invariants, and domain errors that make housekeeping-service a Core bounded context. Application orchestration is in APPLICATION_LOGIC.md; persistence in DATA_MODEL.md.


1. Value objects

1.1 Identifiers

type Brand<T, B> = T & { readonly __brand: B };

export type TenantId = Brand<string, "TenantId">; // tnt_<ulid>
export type PropertyId = Brand<string, "PropertyId">; // prp_<ulid>
export type RoomId = Brand<string, "RoomId">; // rom_<ulid>
export type ReservationId = Brand<string, "ReservationId">; // rsv_<ulid>
export type StaffId = Brand<string, "StaffId">; // stf_<ulid>
export type HousekeepingTaskId = Brand<string, "HousekeepingTaskId">; // hkt_<ulid>
export type ChecklistId = Brand<string, "ChecklistId">; // chl_<ulid>
export type InspectionId = Brand<string, "InspectionId">; // ins_<ulid>
export type LinenInventoryId = Brand<string, "LinenInventoryId">; // lin_<ulid>
export type LostAndFoundId = Brand<string, "LostAndFoundId">; // laf_<ulid>
export type RoomBlockId = Brand<string, "RoomBlockId">; // blk_<ulid>
export type ShiftAssignmentId = Brand<string, "ShiftAssignmentId">; // sft_<ulid>

All IDs are ULIDs with a 4-letter prefix (NAMING §6). Constructors validate format and reject mismatched prefixes (assertId(value, "hkt_")).

1.2 Enumerations

export enum TaskKind {
Turnover = "turnover", // post-checkout cleaning
MidStayClean = "mid_stay_clean", // daily / on-request during stay
DeepClean = "deep_clean", // periodic / between long stays
PostMaintenance = "post_maintenance", // after a maintenance work order
PostRenovation = "post_renovation", // after capex work
Inspection = "inspection", // standalone inspection round
}

export enum TaskStatus {
Pending = "pending",
Assigned = "assigned",
InProgress = "in_progress",
Paused = "paused",
Completed = "completed",
Failed = "failed",
Cancelled = "cancelled",
RequiresMaintenance = "requires_maintenance", // terminal: handed off to maintenance-service
}

export enum TaskPriority { Low = "low", Normal = "normal", High = "high", Urgent = "urgent" }

export enum RoomStatusValue {
Clean = "clean",
Dirty = "dirty",
Cleaning = "cleaning",
Cleaned = "cleaned", // post-clean, awaiting inspection (if required)
Inspected = "inspected", // inspection passed
Ready = "ready", // available for next guest
OutOfOrder = "out_of_order", // transient (maintenance in progress)
OutOfService = "out_of_service", // longer-term (echoed from property-service)
}

export enum PauseReason {
Break = "break",
AwaitingLinen = "awaiting_linen",
AwaitingMaintenance = "awaiting_maintenance",
StaffSwap = "staff_swap",
GuestPresent = "guest_present",
Other = "other",
}

export enum FailureReason {
RoomNotVacated = "room_not_vacated",
AccessDenied = "access_denied",
Sickness = "sickness",
EquipmentMissing = "equipment_missing",
Other = "other",
}

export enum LostItemStatus { Recorded = "recorded", Matched = "matched", Disposed = "disposed", Returned = "returned" }

1.3 Small VOs

export class Locale {
private constructor(public readonly value: "ps-AF" | "fa-AF" | "fa-IR" | "tg-TJ" | "en") {}
static of(v: string): Locale { /* validate or throw DomainError(MELMASTOON.HOUSEKEEPING.INVALID_LOCALE) */ }
}

export class TimeWindow {
constructor(public readonly fromUtc: Date, public readonly toUtc: Date) {
if (toUtc <= fromUtc) throw DomainError.of("MELMASTOON.HOUSEKEEPING.INVALID_TIME_WINDOW");
}
durationMinutes(): number { return Math.round((+this.toUtc - +this.fromUtc) / 60000); }
}

export class LinenCount {
constructor(public readonly issued: number, public readonly returned: number) {
if (issued < 0 || returned < 0) throw DomainError.of("MELMASTOON.HOUSEKEEPING.INVALID_LINEN_COUNT");
}
delta(): number { return this.issued - this.returned; }
}

export class ChecklistItemResult {
constructor(
public readonly itemKey: string,
public readonly checked: boolean,
public readonly note?: string,
public readonly photoMediaId?: string,
) {}
}

2. Aggregate: HousekeepingTask

The root aggregate. Every state transition produces a domain event recorded on the aggregate's pending-events buffer; the application layer flushes them through the outbox.

2.1 State machine

┌─────────┐ assign ┌──────────┐ start ┌──────────────┐
create ────▶ │ Pending │ ──────────▶ │ Assigned │ ─────────▶ │ InProgress │
└─────────┘ └──────────┘ └──────┬───────┘
│ cancel │ reassign │ pause / resume
▼ ▼ ▼
┌─────────────┐ ┌──────────┐ ┌──────────┐
│ Cancelled │ │ Assigned │ ◀───────── │ Paused │
└─────────────┘ └──────────┘ └──────────┘
│ complete / fail / requires_maint

┌─────────┐ ┌────────┐ ┌──────────────────────┐
│Completed│ │ Failed │ │ RequiresMaintenance │
└─────────┘ └────────┘ └──────────────────────┘

2.2 Type

export class HousekeepingTask {
readonly id: HousekeepingTaskId;
readonly tenantId: TenantId;
readonly propertyId: PropertyId;
readonly roomId: RoomId;
readonly kind: TaskKind;
status: TaskStatus;
priority: TaskPriority;
assigneeStaffId?: StaffId;
checklistId?: ChecklistId;
checklistVersion?: number;
reservationId?: ReservationId; // turnover and mid-stay tasks reference the originating reservation
scheduledFor?: Date;
startedAt?: Date;
pausedAt?: Date;
pauseReason?: PauseReason;
completedAt?: Date;
failureReason?: FailureReason;
failureNote?: string;
outcomeChecklistResults?: ChecklistItemResult[];
linen?: LinenCount;
durationMinutes?: number;
localeHint: Locale;
pendingEvents: DomainEvent[] = [];
// …timestamps, version (for optimistic concurrency)…
}

2.3 Behaviours (selected)

class HousekeepingTask {
static create(input: CreateTaskInput): HousekeepingTask { /* emits HousekeepingTaskCreatedV1 */ }

assign(staffId: StaffId, by: ActorRef): void {
this.guard(this.status === TaskStatus.Pending || this.status === TaskStatus.Assigned,
"MELMASTOON.HOUSEKEEPING.INVALID_TRANSITION");
const previous = this.assigneeStaffId;
this.assigneeStaffId = staffId;
this.status = TaskStatus.Assigned;
this.pendingEvents.push(previous
? new TaskReassignedV1(this.id, previous, staffId, by)
: new TaskAssignedV1(this.id, staffId, by));
}

start(by: ActorRef, at: Date): void { /* InProgress; emits TaskStartedV1 */ }
pause(reason: PauseReason, by: ActorRef, at: Date): void { /* Paused; emits TaskPausedV1 */ }
resume(by: ActorRef, at: Date): void { /* InProgress; emits TaskResumedV1 */ }
complete(input: CompleteTaskInput, by: ActorRef): void { /* Completed; emits TaskCompletedV1 + RoomStatusChangedV1 (cleaned) */ }
fail(reason: FailureReason, note: string | undefined, by: ActorRef): void { /* Failed; emits TaskFailedV1 */ }
cancel(reason: string, by: ActorRef): void { /* Cancelled; emits TaskCancelledV1 */ }
escalate(toStaffOrRole: ActorRef, by: ActorRef): void { /* emits TaskEscalatedV1; priority bump */ }
requireMaintenance(issue: MaintenanceIssue, by: ActorRef): void {
/* If light → pause(awaiting_maintenance); if blocking → terminal RequiresMaintenance.
Always emits RoomMaintenanceRequiredV1. */
}
bumpPriority(to: TaskPriority, by: ActorRef): void { /* emits TaskPriorityBumpedV1 */ }
}

2.4 Invariants

  • status transitions follow the diagram above. Any other transition throws MELMASTOON.HOUSEKEEPING.INVALID_TRANSITION.
  • assigneeStaffId must reference a staff member with an active housekeeping shift covering "now" (verified by the application layer via StaffShiftPort).
  • checklistId and checklistVersion are bound at assign time and become immutable for the rest of the task's life. Updating the checklist template never mutates an in-flight task.
  • RequiresMaintenance is terminal; the room flips to out_of_order and ownership of "what to do next" hands off to maintenance-service. After maintenance completes, a new task is created (we do not resurrect the old one).
  • complete requires every checklist item with mandatory=true to be present in outcomeChecklistResults with checked=true, otherwise MELMASTOON.HOUSEKEEPING.CHECKLIST_INCOMPLETE.
  • linen.returned may be < linen.issued (loss); we record but do not block.
  • bumpPriority to Urgent for a non-InProgress, non-Paused task auto-fires escalate if it stays unworked for 5 minutes (enforced by a domain timer in the application layer; see APPLICATION_LOGIC.md §6).

3. Aggregate: RoomStatus

A singleton per (tenant, property, room). State is one of RoomStatusValue. Holds lastReason, lastTaskId, lastFlippedAt, and lastFlippedBy.

3.1 Allowed transitions

From → ToTrigger
dirty → cleaningtask start
cleaning → cleanedtask complete (no inspection required)
cleaning → cleanedtask complete (inspection required → inspected after pass)
cleaned → inspectedinspection pass
cleaned → dirtyinspection fail (re-clean)
inspected → readypublish to front desk (automatic)
* → out_of_orderrequireMaintenance or RoomBlock(maintenance)
out_of_order → dirtymaintenance.work_order.completed.v1
* → out_of_serviceecho from property-service (manual long-term block)
ready → dirtyreservation checkout (or manual flip)
* → clean (manual)supervisor override (audit-flagged, requires reason)

Invariants:

  • The transition table is exhaustive. Any other transition throws MELMASTOON.HOUSEKEEPING.ROOM_STATE_CONFLICT.
  • A room cannot enter cleaning without an assigned task pointing at it.
  • A room cannot enter ready while a RoomBlock exists (cleaning, maintenance, oos).
  • Manual flips require actor.role ∈ { housekeeping_supervisor, property_manager, owner } and include reason (free text, persisted on room_status_audit).

4. Aggregate: CleaningChecklist

Versioned, append-only.

export class CleaningChecklist {
readonly id: ChecklistId;
readonly tenantId: TenantId;
readonly kind: TaskKind; // template binds to a task kind
readonly version: number; // monotonically increasing
readonly items: ChecklistItem[];
readonly publishedAt: Date;
readonly publishedBy: ActorRef;
}

export interface ChecklistItem {
key: string; // stable identifier across versions
labelI18n: Record<string, string>;
mandatory: boolean;
requiresPhoto: boolean;
}

Invariants: once publishedAt is set, the aggregate is immutable. New versions are new aggregate instances. Item keys are stable across versions; new items get fresh keys. Removed items keep their key reserved (to keep historical results readable).

5. Aggregate: Inspection

export class Inspection {
readonly id: InspectionId;
readonly tenantId: TenantId;
readonly taskId: HousekeepingTaskId;
readonly inspectorStaffId: StaffId;
readonly checklistId: ChecklistId;
readonly checklistVersion: number;
readonly results: ChecklistItemResult[];
readonly outcome: "pass" | "fail";
readonly note?: string;
readonly performedAt: Date;
}

Invariant: outcome=pass requires all mandatory items checked=true. Otherwise MELMASTOON.HOUSEKEEPING.INSPECTION_INCOMPLETE.

6. Aggregate: LinenInventory

export class LinenInventory {
readonly id: LinenInventoryId;
readonly tenantId: TenantId;
readonly propertyId: PropertyId;
readonly line: string; // e.g., "towel-large", "sheet-double"
onHand: number;
lowWatermark: number;
// …issued/returned roll-ups updated transactionally with task linen ops…
}

Invariant: onHand >= 0. Crossing below lowWatermark emits LinenLowStockAlertV1 exactly once per crossing (debounce at application layer).

7. Aggregate: LostAndFound

Lifecycle: recorded → matched → returned or recorded → disposed. Retention defaults to 30 days; configurable per tenant.

8. Aggregate: StaffShiftAssignment

A read-side projection updated by staff.shift.started.v1 / .ended.v1. Carries capacityMinutes, activeTaskCount, and languages. Used by the router and the staffing-gap detector.

9. Aggregate: RoomBlock

Composite ownership with maintenance-service. We own rows where reason ∈ { cleaning, inspection, oos_echo }; maintenance owns reason = maintenance. Both project into a single read view under room_blocks_view.

10. Domain events (canonical names → TS classes)

EventTS class
melmastoon.housekeeping.task.created.v1HousekeepingTaskCreatedV1
.assigned.v1TaskAssignedV1
.reassigned.v1TaskReassignedV1
.started.v1TaskStartedV1
.paused.v1TaskPausedV1
.resumed.v1TaskResumedV1
.completed.v1TaskCompletedV1
.failed.v1TaskFailedV1
.cancelled.v1TaskCancelledV1
.escalated.v1TaskEscalatedV1
melmastoon.housekeeping.room.status_changed.v1RoomStatusChangedV1
melmastoon.housekeeping.room.maintenance_required.v1RoomMaintenanceRequiredV1
melmastoon.housekeeping.inspection.passed.v1InspectionPassedV1
melmastoon.housekeeping.inspection.failed.v1InspectionFailedV1
melmastoon.housekeeping.checklist.template_updated.v1ChecklistTemplateUpdatedV1
melmastoon.housekeeping.lost_item.recorded.v1LostItemRecordedV1
melmastoon.housekeeping.lost_item.matched.v1LostItemMatchedV1
melmastoon.housekeeping.lost_item.disposed.v1LostItemDisposedV1
melmastoon.housekeeping.linen.low_stock_alert.v1LinenLowStockAlertV1
melmastoon.housekeeping.shift.staffing_gap_detected.v1ShiftStaffingGapDetectedV1

JSON wire schemas in EVENT_SCHEMAS.md.

11. Domain errors

All domain errors are subclasses of DomainError and carry an error code. The presentation layer (shared/http/error-mapper.ts) maps them to RFC 7807. Catalogue lives in docs/standards/ERROR_CODES.md under the HOUSEKEEPING domain.

CodeMeaning
MELMASTOON.HOUSEKEEPING.TASK_NOT_FOUNDUnknown task ID
MELMASTOON.HOUSEKEEPING.ROOM_STATE_CONFLICTIllegal room-status transition
MELMASTOON.HOUSEKEEPING.STAFF_UNAVAILABLEAssignee has no active shift
MELMASTOON.HOUSEKEEPING.TASK_ALREADY_COMPLETEDRe-completion attempt
MELMASTOON.HOUSEKEEPING.SCHEDULE_OVERFLOWToo many tasks for shift capacity
MELMASTOON.HOUSEKEEPING.INVALID_TRANSITIONTask lifecycle illegal transition
MELMASTOON.HOUSEKEEPING.CHECKLIST_INCOMPLETEMissing mandatory checklist items at completion
MELMASTOON.HOUSEKEEPING.INSPECTION_INCOMPLETEInspection passed without all mandatory items checked
MELMASTOON.HOUSEKEEPING.LINEN_OUT_OF_STOCKIssuing more linen than onHand
MELMASTOON.HOUSEKEEPING.ROOM_BLOCKEDAction blocked by an active RoomBlock
MELMASTOON.HOUSEKEEPING.INVALID_LOCALELocale.of rejected the value
MELMASTOON.HOUSEKEEPING.INVALID_TIME_WINDOWTimeWindow constructor rejected the input
MELMASTOON.HOUSEKEEPING.INVALID_LINEN_COUNTNegative linen count
MELMASTOON.HOUSEKEEPING.CHECKLIST_FROZENAttempt to mutate a published checklist version
MELMASTOON.HOUSEKEEPING.LOST_ITEM_NOT_FOUNDUnknown lost-item ID
MELMASTOON.HOUSEKEEPING.LOST_ITEM_ALREADY_DISPOSEDAction on already-disposed item

12. Concurrency

  • Every aggregate row has a version column; repositories perform optimistic-concurrency UPDATE … WHERE id = $1 AND version = $2.
  • On version mismatch the application layer throws ConcurrencyConflictError, which the presentation layer maps to HTTP 409 Conflict with MELMASTOON.SHARED.CONCURRENCY_CONFLICT.
  • RoomStatus flips serialise per (tenant, property, room) via row-level SELECT … FOR UPDATE to keep multi-actor flips race-free.

13. Folder layout (domain only)

src/domain/
housekeeping-task/
housekeeping-task.aggregate.ts
housekeeping-task.events.ts
housekeeping-task.errors.ts
room-status/
room-status.aggregate.ts
room-status.events.ts
cleaning-checklist/
inspection/
linen-inventory/
lost-and-found/
staff-shift-assignment/ (read-side projection)
room-block/
shared/
ids.ts (branded ID factories)
enums.ts
value-objects.ts
domain-error.ts
actor.ts