Skip to main content

maintenance-service · EVENT_SCHEMAS

All events conform to the platform envelope (docs/04-event-driven-architecture.md §3) and the naming rule melmastoon.<service>.<aggregate>.<verb-past-tense>.v<n>. Topics map 1:1 to the dotted name. Retention class is operational (30 days hot, 18 months cold) unless flagged regulated (7 years cold). Partition key is tenantId unless noted.

1. Common envelope

export interface EventEnvelope<T = unknown> {
readonly id: ULID; // event id; also the Pub/Sub message id we set
readonly type: string; // e.g. "melmastoon.maintenance.work_order.created.v1"
readonly version: 1; // envelope version
readonly tenantId: TenantId;
readonly propertyId?: PropertyId;
readonly correlationId: ULID; // trace correlation
readonly causationId?: ULID; // upstream event id
readonly producedAt: string; // ISO-8601 UTC
readonly producedBy: { service: 'maintenance-service'; build: string; revision: string };
readonly partitionKey: string; // = tenantId by default
readonly retentionClass: 'operational' | 'regulated';
readonly schemaUri: string; // gs:// URI of the JSON Schema
readonly payload: T;
}

2. Topic registry (this service)

TopicClassPartition keySchema
melmastoon.maintenance.work_order.created.v1operationaltenantIdWorkOrderCreatedV1
melmastoon.maintenance.work_order.assigned.v1operationaltenantIdWorkOrderAssignedV1
melmastoon.maintenance.work_order.started.v1operationaltenantIdWorkOrderStartedV1
melmastoon.maintenance.work_order.in_progress.v1operationaltenantIdWorkOrderInProgressNotedV1
melmastoon.maintenance.work_order.blocked.v1operationaltenantIdWorkOrderBlockedV1
melmastoon.maintenance.work_order.resolved.v1operationaltenantIdWorkOrderResolvedV1
melmastoon.maintenance.work_order.verified.v1operationaltenantIdWorkOrderVerifiedV1
melmastoon.maintenance.work_order.cancelled.v1operationaltenantIdWorkOrderCancelledV1
melmastoon.maintenance.work_order.escalated.v1operationaltenantIdWorkOrderEscalatedV1
melmastoon.maintenance.work_order.sla_breached.v1operationaltenantIdWorkOrderSlaBreachedV1
melmastoon.maintenance.work_order.room_blocked.v1operationaltenantIdWorkOrderRoomBlockedV1
melmastoon.maintenance.work_order.relocation_required.v1operationaltenantIdWorkOrderRelocationRequiredV1
melmastoon.maintenance.preventive.scheduled.v1operationaltenantIdPreventiveScheduledV1
melmastoon.maintenance.preventive.due.v1operationaltenantIdPreventiveDueV1
melmastoon.maintenance.preventive.completed.v1operationaltenantIdPreventiveCompletedV1
melmastoon.maintenance.asset.registered.v1operationaltenantIdAssetRegisteredV1
melmastoon.maintenance.asset.health_changed.v1operationaltenantIdAssetHealthChangedV1
melmastoon.maintenance.vendor.assigned.v1operationaltenantIdVendorAssignedV1
melmastoon.maintenance.vendor.invoice_recorded.v1regulatedtenantIdVendorInvoiceRecordedV1
melmastoon.maintenance.part_usage.recorded.v1operationaltenantIdPartUsageRecordedV1

3. Payload contracts

3.1 WorkOrderCreatedV1

export interface WorkOrderCreatedV1 {
readonly workOrderId: WorkOrderId;
readonly propertyId: PropertyId;
readonly roomId?: RoomId;
readonly assetId?: AssetId;
readonly category: CategoryCode;
readonly severity: WorkOrderSeverity;
readonly title: string;
readonly source: CreationSource;
readonly originRef?: string;
readonly createdBy: UserId | 'system';
readonly slaTimer?: { targetMinutes: number; dueAt: string };
readonly aiAssist?: { categoryClassified: boolean; severitySuggested: boolean };
}
{
"type": "melmastoon.maintenance.work_order.created.v1",
"version": 1,
"tenantId": "tnt_01HXP...",
"propertyId": "prop_01HXP...",
"correlationId": "01HXYZ...",
"producedAt": "2026-04-22T14:03:21.812Z",
"producedBy": { "service": "maintenance-service", "build": "1.4.0", "revision": "g7f9c12" },
"partitionKey": "tnt_01HXP...",
"retentionClass": "operational",
"schemaUri": "gs://melmastoon-event-schemas/maintenance/work_order_created_v1.json",
"payload": {
"workOrderId": "mnt_01HXY...",
"propertyId": "prop_01HXP...",
"roomId": "room_01HXR...",
"category": "hvac",
"severity": "high",
"title": "AC blowing warm air in 204",
"source": "guest_complaint",
"createdBy": "usr_01HXG...",
"slaTimer": { "targetMinutes": 240, "dueAt": "2026-04-22T18:03:21.812Z" }
}
}

3.2 WorkOrderAssignedV1

export interface WorkOrderAssignedV1 {
readonly workOrderId: WorkOrderId;
readonly assignee: AssigneeRef;
readonly assignedBy: UserId;
readonly previousAssignee?: AssigneeRef;
readonly notifyChannel?: 'whatsapp' | 'sms' | 'email' | 'call_only';
}

3.3 WorkOrderStartedV1

export interface WorkOrderStartedV1 {
readonly workOrderId: WorkOrderId;
readonly startedBy: UserId;
readonly startedAt: string;
}

3.4 WorkOrderInProgressNotedV1

export interface WorkOrderInProgressNotedV1 {
readonly workOrderId: WorkOrderId;
readonly note: string;
readonly notedBy: UserId;
readonly notedAt: string;
}

3.5 WorkOrderBlockedV1

export interface WorkOrderBlockedV1 {
readonly workOrderId: WorkOrderId;
readonly reason: 'awaiting_part' | 'awaiting_vendor' | 'awaiting_approval' | 'guest_in_room' | 'other';
readonly note?: string;
readonly etaIso?: string;
readonly blockedBy: UserId | 'system';
}

3.6 WorkOrderResolvedV1

export interface WorkOrderResolvedV1 {
readonly workOrderId: WorkOrderId;
readonly resolutionNote: string;
readonly costRollup: { currency: string; amountMicro: string }; // bigint serialised
readonly costLines: ReadonlyArray<{
readonly kind: 'labor' | 'part' | 'vendor_invoice' | 'other';
readonly description: string;
readonly amount: { currency: string; amountMicro: string };
readonly minutes?: number;
}>;
readonly partsUsed: ReadonlyArray<{
readonly partId: PartId;
readonly quantity: number;
readonly unitCost: { currency: string; amountMicro: string };
}>;
readonly resolvedBy: UserId;
readonly resolvedAt: string;
readonly preventiveScheduleId?: PreventiveScheduleId;
}

3.7 WorkOrderVerifiedV1

export interface WorkOrderVerifiedV1 {
readonly workOrderId: WorkOrderId;
readonly verifiedBy: UserId; // must be GM or owner
readonly verifiedAt: string;
readonly note?: string;
readonly releasedRoomId?: RoomId; // if causedRoomBlock was true
readonly preventiveScheduleId?: PreventiveScheduleId;
readonly nextDueAt?: string;
}

3.8 WorkOrderCancelledV1

export interface WorkOrderCancelledV1 {
readonly workOrderId: WorkOrderId;
readonly reason: string;
readonly cancelledBy: UserId;
readonly cancelledAt: string;
readonly releasedRoomId?: RoomId;
}

3.9 WorkOrderEscalatedV1

export interface WorkOrderEscalatedV1 {
readonly workOrderId: WorkOrderId;
readonly reason: string;
readonly escalatedBy: UserId | 'system';
readonly fromAssignee?: AssigneeRef;
readonly toTarget: { kind: 'user'; userId: UserId } | { kind: 'role'; role: string };
readonly hopNumber: number; // 1, 2, 3...
}

3.10 WorkOrderSlaBreachedV1

export interface WorkOrderSlaBreachedV1 {
readonly workOrderId: WorkOrderId;
readonly category: CategoryCode;
readonly severity: WorkOrderSeverity;
readonly targetMinutes: number;
readonly elapsedMinutes: number;
readonly breachedAt: string;
readonly breachCount: number;
}

3.11 WorkOrderRoomBlockedV1

This is a request event consumed by property-service to flip Room.status = out_of_order. We do not write to property's tables.

export interface WorkOrderRoomBlockedV1 {
readonly workOrderId: WorkOrderId;
readonly roomId: RoomId;
readonly reason: 'maintenance_high_severity' | 'maintenance_critical_severity';
readonly estimatedDurationMinutes?: number;
readonly category: CategoryCode;
readonly requestedAt: string;
}

3.12 WorkOrderRelocationRequiredV1

export interface WorkOrderRelocationRequiredV1 {
readonly workOrderId: WorkOrderId;
readonly roomId: RoomId;
readonly affectedReservationIds: readonly ReservationId[];
readonly windowFrom: string;
readonly windowTo: string;
readonly severity: WorkOrderSeverity;
}

3.13 PreventiveScheduledV1

export interface PreventiveScheduledV1 {
readonly scheduleId: PreventiveScheduleId;
readonly assetId?: AssetId;
readonly assetClass?: AssetClass;
readonly category: CategoryCode;
readonly cadence: PreventiveSchedule['cadence'];
readonly active: boolean;
readonly action: 'created' | 'updated' | 'deactivated';
}

3.14 PreventiveDueV1

export interface PreventiveDueV1 {
readonly scheduleId: PreventiveScheduleId;
readonly draftWorkOrderId: WorkOrderId;
readonly dueAt: string;
readonly assetId?: AssetId;
readonly category: CategoryCode;
}

3.15 PreventiveCompletedV1

export interface PreventiveCompletedV1 {
readonly scheduleId: PreventiveScheduleId;
readonly workOrderId: WorkOrderId;
readonly completedAt: string;
readonly nextDueAt: string;
readonly runHoursAtCompletion?: number;
}

3.16 AssetRegisteredV1

export interface AssetRegisteredV1 {
readonly assetId: AssetId;
readonly propertyId: PropertyId;
readonly roomId?: RoomId;
readonly class: AssetClass;
readonly displayName: string;
readonly model?: string;
readonly serialNumber?: string;
readonly externalRef?: string;
}

3.17 AssetHealthChangedV1

export interface AssetHealthChangedV1 {
readonly assetId: AssetId;
readonly previousHealthIndex: number;
readonly newHealthIndex: number;
readonly delta: number;
readonly source: 'manual' | 'ai_forecaster' | 'lock_health_alert' | 'work_order_resolved' | 'sensor';
readonly aiProvenance?: AIProvenance;
}

3.18 VendorAssignedV1

export interface VendorAssignedV1 {
readonly workOrderId: WorkOrderId;
readonly vendorId: VendorId;
readonly displayName: string;
readonly category: CategoryCode;
readonly notifyChannel: 'whatsapp' | 'sms' | 'email' | 'call_only';
}

3.19 VendorInvoiceRecordedV1 (regulated)

export interface VendorInvoiceRecordedV1 {
readonly workOrderId: WorkOrderId;
readonly vendorId: VendorId;
readonly amount: { currency: string; amountMicro: string };
readonly invoiceNumber: string;
readonly issuedAt: string;
readonly dueAt: string;
readonly fileRef?: string;
}

3.20 PartUsageRecordedV1

export interface PartUsageRecordedV1 {
readonly partUsageId: PartUsageId;
readonly partId: PartId;
readonly workOrderId?: WorkOrderId;
readonly quantity: number;
readonly unitCost: { currency: string; amountMicro: string };
readonly totalCost: { currency: string; amountMicro: string };
readonly recordedBy: UserId;
readonly recordedAt: string;
}

4. Consumed events (this service is a subscriber)

Source topicProducerSubscriptionConsumer behaviour
melmastoon.housekeeping.room.maintenance_required.v1housekeeping-servicemnt.in.housekeeping.maintenance_requiredAuto-create WO; src=housekeeping_flag
melmastoon.lock_integration.device.health_alert.v1lock-integration-servicemnt.in.lock.health_alertUpsert Asset; auto-create WO; category=lock
melmastoon.property.room.taken_out_of_order.v1property-servicemnt.in.property.room_taken_oooLink to active WOs
melmastoon.property.room.returned_to_service.v1property-servicemnt.in.property.room_releasedSanity check
melmastoon.staff.shift.started.v1staff-servicemnt.in.staff.shift_startedRefresh roster cache
melmastoon.tenant.settings.changed.v1tenant-servicemnt.in.tenant.settings_changedRefresh SLA / escalation cache
melmastoon.reservation.checked_in.v1reservation-servicemnt.in.reservation.checked_inRe-evaluate WOs on the room
melmastoon.billing.vendor_invoice.posted.v1billing-servicemnt.in.billing.vendor_invoice_postedMark posted_to_folio = true

5. Schema evolution rules

  • Additive only within v1: optional fields may be added; existing fields' types may not change; no field renames.
  • Removal: expand-contract with a v2 topic; v1 stays for ≥ 6 months; both published in parallel during deprecation.
  • Breaking: v2 topic, v2 schema, dual publish, consumers migrate, v1 frozen for 6 months then archived.
  • Schema URI is immutable per topic version. Versioned files live in the event-schemas GCS bucket.

6. Producer guarantees

  • At-least-once delivery via Pub/Sub.
  • Outbox-tx parity: an event is appended to outbox in the same transaction as its state mutation. The outbox relay publishes within < 2 s p99.
  • Ordering: only within a partitionKey (tenantId). Cross-tenant ordering is undefined.
  • Idempotency on consumer: every handler must use messageId against an inbox_processed table.

7. Testing

  • Every payload type has a *.contract.test.ts that asserts:
    • Sample valid JSON parses with the published schema.
    • Sample missing-required fails.
    • TypeScript type matches the JSON Schema (via ts-json-schema-generator golden file).
  • Cross-service contract tests (Pact) verify expected payloads from housekeeping-service, lock-integration-service, property-service, reservation-service, billing-service, and staff-service.