maintenance-service · DOMAIN_MODEL
Pure TypeScript domain model. No I/O, no ORM imports, no framework code. Lives in
domain/of the package and is the only layer the application layer is allowed to depend on for business logic.
1. Value objects
import type { Brand } from '@melmastoon/shared/brand';
import type { TenantId, PropertyId, RoomId, UserId, ULID } from '@melmastoon/shared/ids';
export type WorkOrderId = Brand<`mnt_${string}`, 'WorkOrderId'>;
export type MaintenanceTaskId = Brand<`mtk_${string}`, 'MaintenanceTaskId'>;
export type PreventiveScheduleId = Brand<`psch_${string}`, 'PreventiveScheduleId'>;
export type AssetId = Brand<`ast_${string}`, 'AssetId'>;
export type PartId = Brand<`prt_${string}`, 'PartId'>;
export type PartUsageId = Brand<`pus_${string}`, 'PartUsageId'>;
export type VendorId = Brand<`vnd_${string}`, 'VendorId'>;
export type MaintenanceCategoryId = Brand<`mcat_${string}`, 'MaintenanceCategoryId'>;
export type WorkOrderSeverity = 'low' | 'normal' | 'high' | 'critical';
export type WorkOrderStatus =
| 'open'
| 'assigned'
| 'in_progress'
| 'blocked'
| 'resolved'
| 'verified'
| 'cancelled';
export type CategoryCode =
| 'plumbing'
| 'electrical'
| 'hvac'
| 'lock'
| 'generator'
| 'water'
| 'structural'
| 'it'
| 'other';
export type AssetClass =
| 'hvac_unit'
| 'generator'
| 'water_tank'
| 'lock_device'
| 'linen_lot'
| 'furniture_lot'
| 'electrical_panel'
| 'plumbing_fixture'
| 'it_device'
| 'other';
export type CreationSource =
| 'manual_staff'
| 'guest_complaint'
| 'housekeeping_flag'
| 'lock_health_alert'
| 'preventive_schedule'
| 'reservation_relocation_failure';
export type AssigneeRef =
| { kind: 'staff'; userId: UserId }
| { kind: 'vendor'; vendorId: VendorId };
export interface Money {
readonly currency: string; // ISO-4217
readonly amountMicro: bigint; // tenant-base currency, micro-units
}
export interface CostLine {
readonly kind: 'labor' | 'part' | 'vendor_invoice' | 'other';
readonly description: string;
readonly amount: Money;
readonly minutes?: number; // for labor
readonly partUsageId?: PartUsageId; // for part
}
export interface SLATimer {
readonly targetMinutes: number;
readonly startedAt: string; // ISO-8601 UTC; usually = createdAt
readonly dueAt: string; // = startedAt + targetMinutes
readonly breachedAt?: string; // set on first breach
readonly breachCount: number; // increments per scan if still un-resolved
}
export interface ChannelPreference {
readonly primary: 'whatsapp' | 'sms' | 'email' | 'call_only';
readonly fallback?: 'whatsapp' | 'sms' | 'email';
}
export interface CallbackWindow {
readonly startsAt: string; // ISO-8601 in tenant tz
readonly endsAt: string;
readonly note?: string; // e.g. "vendor said morning, before 11"
}
export interface AIProvenance {
readonly capability: string; // e.g. "maintenance.severity-suggestion"
readonly model: string; // e.g. "vertex/text-bison@001"
readonly score: number; // 0..1
readonly redactedInput: boolean;
readonly humanAccepted: boolean;
readonly correlationId: ULID;
readonly producedAt: string;
}
2. Aggregates
2.1 WorkOrder
export interface WorkOrder {
readonly id: WorkOrderId;
readonly tenantId: TenantId;
readonly propertyId: PropertyId;
readonly roomId?: RoomId; // present when affects a room
readonly assetId?: AssetId; // present when an Asset is the focus
readonly category: CategoryCode;
readonly customCategoryId?: MaintenanceCategoryId; // tenant override
readonly severity: WorkOrderSeverity;
readonly title: string; // ≤ 140 chars
readonly description: string; // ≤ 4 KB
readonly source: CreationSource;
readonly originRef?: string; // e.g. housekeeping flag id, lock alert id, schedule id
readonly status: WorkOrderStatus;
readonly assignee?: AssigneeRef;
readonly tasks: readonly MaintenanceTask[];
readonly partsUsed: readonly PartUsage[];
readonly costLines: readonly CostLine[];
readonly costRollup: Money; // sum of cost lines, recomputed on each mutation
readonly slaTimer?: SLATimer; // absent ⇒ no SLA configured
readonly causedRoomBlock: boolean; // true if we've published room_blocked.v1
readonly relocationRequired: boolean; // true if reservations overlap OOO window
readonly vendorAcknowledgement?: {
readonly recordedBy: UserId; // staff who logged the verbal/SMS confirmation
readonly channel: 'phone' | 'whatsapp' | 'sms' | 'in_person';
readonly note: string;
readonly recordedAt: string;
};
readonly vendorInvoice?: {
readonly amount: Money;
readonly invoiceNumber: string;
readonly issuedAt: string;
readonly dueAt: string;
readonly fileRef?: string; // gs:// URI to scanned invoice
readonly postedToFolio: boolean;
};
readonly aiProvenance?: readonly AIProvenance[];
readonly reopenCount: number;
readonly verifiedBy?: UserId;
readonly verifiedAt?: string;
readonly cancelledBy?: UserId;
readonly cancellationReason?: string;
readonly createdBy: UserId | 'system';
readonly createdAt: string;
readonly updatedAt: string;
readonly version: number; // OCC
}
2.2 MaintenanceTask
export interface MaintenanceTask {
readonly id: MaintenanceTaskId;
readonly workOrderId: WorkOrderId;
readonly seq: number; // 1..N ordered
readonly title: string;
readonly status: 'pending' | 'in_progress' | 'done' | 'skipped';
readonly note?: string;
readonly completedBy?: UserId;
readonly completedAt?: string;
}
2.3 PreventiveSchedule
export interface PreventiveSchedule {
readonly id: PreventiveScheduleId;
readonly tenantId: TenantId;
readonly propertyId: PropertyId;
readonly assetId?: AssetId; // bound to a specific asset
readonly assetClass?: AssetClass; // OR bound to a class (all generators in property)
readonly category: CategoryCode;
readonly title: string;
readonly cadence:
| { kind: 'time'; everyDays: number; tz: string }
| { kind: 'run_hours'; everyHours: number } // generators
| { kind: 'composite'; everyDays: number; everyHours: number; tz: string };
readonly slaTargetMinutes: number;
readonly defaultSeverity: WorkOrderSeverity;
readonly defaultAssignee?: AssigneeRef;
readonly notifyChannel?: 'whatsapp' | 'sms' | 'email'; // for the draft WO assignee
readonly active: boolean;
readonly nextDueAt: string; // recomputed on each completion
readonly lastFiredAt?: string;
readonly version: number;
readonly createdAt: string;
readonly updatedAt: string;
}
2.4 Asset
export interface Asset {
readonly id: AssetId;
readonly tenantId: TenantId;
readonly propertyId: PropertyId;
readonly roomId?: RoomId; // null ⇒ shared/common-area asset
readonly class: AssetClass;
readonly displayName: string; // "Lobby HVAC #2"
readonly model?: string;
readonly manufacturer?: string;
readonly serialNumber?: string;
readonly installedAt?: string;
readonly lastServicedAt?: string;
readonly runHours?: number; // for generators / HVAC
readonly capacity?: { value: number; unit: string }; // tank L, generator kVA, etc.
readonly externalRef?: string; // e.g. lock-integration deviceId
readonly healthIndex: number; // 0..100
readonly healthIndexUpdatedAt?: string;
readonly active: boolean;
readonly version: number;
readonly createdAt: string;
readonly updatedAt: string;
}
2.5 Part and PartUsage
export interface Part {
readonly id: PartId;
readonly tenantId: TenantId;
readonly propertyId: PropertyId;
readonly partNumber: string;
readonly displayName: string;
readonly category: CategoryCode;
readonly onHand: number;
readonly reorderThreshold: number;
readonly lastUnitCost?: Money;
readonly active: boolean;
readonly version: number;
}
export interface PartUsage {
readonly id: PartUsageId;
readonly tenantId: TenantId;
readonly workOrderId: WorkOrderId;
readonly partId: PartId;
readonly quantity: number;
readonly unitCost: Money;
readonly totalCost: Money;
readonly recordedBy: UserId;
readonly recordedAt: string;
}
2.6 Vendor
export interface Vendor {
readonly id: VendorId;
readonly tenantId: TenantId;
readonly displayName: string;
readonly categories: readonly CategoryCode[]; // services they cover
readonly contactName?: string;
readonly phoneE164?: string; // optional but at least one of phone/email required
readonly email?: string;
readonly whatsappE164?: string;
readonly channelPreference: ChannelPreference;
readonly addressFreeText?: string;
readonly callbackWindows?: readonly CallbackWindow[];
readonly active: boolean;
readonly version: number;
}
2.7 MaintenanceCategory (taxonomy)
export interface MaintenanceCategory {
readonly id: MaintenanceCategoryId;
readonly tenantId: TenantId;
readonly code: CategoryCode; // canonical
readonly displayName: string; // tenant override allowed
readonly active: boolean;
readonly version: number;
}
3. State machine matrix (canonical)
| From \ To | open | assigned | in_progress | blocked | resolved | verified | cancelled |
|---|---|---|---|---|---|---|---|
| open | — | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ |
| assigned | ✅ | — | ✅ | ❌ | ❌ | ❌ | ✅ |
| in_progress | ❌ | ❌ | — | ✅ | ✅ | ❌ | ✅ |
| blocked | ❌ | ❌ | ✅ | — | ❌ | ❌ | ✅ |
| resolved | ❌ | ❌ | ✅ | ❌ | — | ✅ | ❌ |
| verified | ❌ | ❌ | ❌ | ❌ | ❌ | — | ❌ |
| cancelled | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | — |
assigned → open is unassign; resolved → in_progress is reopen. Anything else: MELMASTOON.MAINTENANCE.INVALID_STATUS_TRANSITION.
4. Invariants (enforced by domain functions)
| # | Invariant | Error code | Domain event on first violation |
|---|---|---|---|
| 1 | Status transition must follow §3 | MELMASTOON.MAINTENANCE.INVALID_STATUS_TRANSITION | — |
| 2 | severity = critical ⇒ assetId || roomId set | MELMASTOON.MAINTENANCE.SEVERITY_REQUIRES_TARGET | — |
| 3 | Auto-OOO only when severity ∈ {high, critical} AND target is a roomId or room-attached asset | (no error; just no event emitted) | work_order.room_blocked.v1 (when condition met) |
| 4 | At most one open WO per (assetId, category) unless allowDuplicate=true | MELMASTOON.MAINTENANCE.DUPLICATE_OPEN_WORK_ORDER | — |
| 5 | costLines.currency ≡ tenant.baseCurrency | MELMASTOON.MAINTENANCE.COST_CURRENCY_MISMATCH | — |
| 6 | Vendor.channelPreference.primary = call_only ⇒ outbound notifications blocked | MELMASTOON.MAINTENANCE.VENDOR_CHANNEL_MISMATCH | — |
| 7 | verify requires caller to have role gm or owner | MELMASTOON.IAM.AUTHZ_DENIED | — |
| 8 | PartUsage.quantity ≤ Part.onHand (snapshot at time of recording) | MELMASTOON.MAINTENANCE.PART_OUT_OF_STOCK | work_order.blocked.v1 (auto, with reason part_out_of_stock) |
| 9 | PreventiveSchedule.nextDueAt strictly monotonic on completion | MELMASTOON.MAINTENANCE.SCHEDULE_NEXT_DUE_REGRESSION | — |
| 10 | Schedule firing dedupe: (scheduleId, dueAtBucketHour) unique | MELMASTOON.MAINTENANCE.PREVENTIVE_DUPLICATE_FIRE (silently dropped) | — |
| 11 | verified and cancelled are terminal — any mutation rejected | MELMASTOON.MAINTENANCE.WORK_ORDER_TERMINAL | — |
| 12 | OCC: caller-supplied version must equal current; otherwise reject | MELMASTOON.SYS.OCC_CONFLICT | — |
| 13 | relocationRequired = true only when roomId present, severity high+, and at least one confirmed reservation overlaps the OOO window | (computed; not user-settable) | work_order.relocation_required.v1 |
5. Domain events (constructors live here, transport in EVENT_SCHEMAS.md)
export type DomainEvent =
| { type: 'WorkOrderCreated'; payload: WorkOrderCreatedV1 }
| { type: 'WorkOrderAssigned'; payload: WorkOrderAssignedV1 }
| { type: 'WorkOrderStarted'; payload: WorkOrderStartedV1 }
| { type: 'WorkOrderInProgressNoted'; payload: WorkOrderInProgressNotedV1 }
| { type: 'WorkOrderBlocked'; payload: WorkOrderBlockedV1 }
| { type: 'WorkOrderResolved'; payload: WorkOrderResolvedV1 }
| { type: 'WorkOrderVerified'; payload: WorkOrderVerifiedV1 }
| { type: 'WorkOrderCancelled'; payload: WorkOrderCancelledV1 }
| { type: 'WorkOrderEscalated'; payload: WorkOrderEscalatedV1 }
| { type: 'WorkOrderSlaBreached'; payload: WorkOrderSlaBreachedV1 }
| { type: 'WorkOrderRoomBlocked'; payload: WorkOrderRoomBlockedV1 }
| { type: 'WorkOrderRelocationRequired'; payload: WorkOrderRelocationRequiredV1 }
| { type: 'PreventiveScheduled'; payload: PreventiveScheduledV1 }
| { type: 'PreventiveDue'; payload: PreventiveDueV1 }
| { type: 'PreventiveCompleted'; payload: PreventiveCompletedV1 }
| { type: 'AssetRegistered'; payload: AssetRegisteredV1 }
| { type: 'AssetHealthChanged'; payload: AssetHealthChangedV1 }
| { type: 'VendorAssigned'; payload: VendorAssignedV1 }
| { type: 'VendorInvoiceRecorded'; payload: VendorInvoiceRecordedV1 }
| { type: 'PartUsageRecorded'; payload: PartUsageRecordedV1 };
6. Pure domain functions (signatures)
// All return either { ok: true, next, events } or { ok: false, error: ErrorCode }.
export function createWorkOrder(input: CreateWorkOrderInput, deps: TimeAndIdDeps): Result<WorkOrder>;
export function assignWorkOrder(wo: WorkOrder, assignee: AssigneeRef, by: UserId, now: string): Result<WorkOrder>;
export function startWorkOrder(wo: WorkOrder, by: UserId, now: string): Result<WorkOrder>;
export function noteInProgress(wo: WorkOrder, note: string, by: UserId, now: string): Result<WorkOrder>;
export function blockWorkOrder(wo: WorkOrder, reason: BlockReason, eta: string|undefined, by: UserId, now: string): Result<WorkOrder>;
export function resumeWorkOrder(wo: WorkOrder, note: string, by: UserId, now: string): Result<WorkOrder>;
export function resolveWorkOrder(wo: WorkOrder, input: ResolveInput, by: UserId, now: string): Result<WorkOrder>;
export function verifyWorkOrder(wo: WorkOrder, by: UserId, note: string|undefined, now: string): Result<WorkOrder>;
export function cancelWorkOrder(wo: WorkOrder, reason: string, by: UserId, now: string): Result<WorkOrder>;
export function escalateWorkOrder(wo: WorkOrder, reason: string, by: UserId|'system', now: string): Result<WorkOrder>;
export function recordPartUsage(wo: WorkOrder, part: Part, qty: number, by: UserId, now: string): Result<{ wo: WorkOrder; part: Part; usage: PartUsage }>;
export function recordVendorInvoice(wo: WorkOrder, invoice: VendorInvoiceInput, by: UserId, now: string): Result<WorkOrder>;
export function evaluateSlaBreach(wo: WorkOrder, now: string): Result<WorkOrder>;
export function evaluateAutoOOO(wo: WorkOrder): Result<WorkOrder>; // pure; emits room_blocked event when applicable
export function nextPreventiveDueAt(schedule: PreventiveSchedule, completedAt: string, runHoursDelta?: number): string;
7. Cross-aggregate invariants (enforced at application layer, not domain)
- Reservation overlap test for
relocation_required: requires readingreservation-serviceprojection for active confirmed reservations that overlap[now, now + estimatedDurationHours]for the affectedroomId. Lives in application layer because it requires I/O. - Vendor channel availability at notification dispatch time: the application checks
notification-servicechannel health before publishingvendor.assigned.v1with channel hint. - Lock device asset linkage on
lock.device.health_alert.v1: application layer resolvesexternalRef = deviceIdto anAssetId; if not found, registers a newAssetwith classlock_device.
8. Forbidden in this layer
- ORM imports, HTTP framework imports, Pub/Sub imports, KMS clients, time sources other than the injected
now. - Any branch on
process.env. - Any
awaiton I/O (only on already-resolved promises if at all). - Throwing exceptions for business-rule violations — return
Result<E>instead. Exceptions are reserved for true programmer errors.