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.