Skip to main content

maintenance-service · API_CONTRACTS

Base path: /api/v1/maintenance · OpenAPI source of truth: services/maintenance-service/openapi/v1.yaml (generated from NestJS decorators on each release) · Error envelope: RFC 7807 Problem+JSON with MELMASTOON.MAINTENANCE.* codes.

1. Common headers

HeaderRequiredPurpose
Authorization: Bearer <jwt>Caller identity (BFF or service-to-service)
X-Tenant-Id: tnt_<ULID>Tenant scope; cross-checked against JWT claim
X-Property-Id: prop_<ULID>for property-scoped commandsProperty scope
X-Correlation-Id: <ULID>optionalSaga / trace correlation
X-Idempotency-Key: <client-uuid>for unsafe POSTs from BFFIdempotent retry support
If-Match: <version>on PATCH and state transitionsOCC token

2. Error envelope (RFC 7807)

{
"type": "https://errors.melmastoon.app/MELMASTOON.MAINTENANCE.INVALID_STATUS_TRANSITION",
"title": "Invalid status transition",
"status": 409,
"code": "MELMASTOON.MAINTENANCE.INVALID_STATUS_TRANSITION",
"detail": "Cannot transition from 'open' to 'resolved'",
"instance": "/api/v1/maintenance/work-orders/mnt_01HXY...3K/resolve",
"correlationId": "01HXYZABCDEF...",
"i18nKey": "errors.maintenance.invalid_status_transition",
"context": { "from": "open", "to": "resolved" }
}

Canonical codes used by this service (full list and HTTP mapping in docs/standards/ERROR_CODES.md):

CodeHTTP
MELMASTOON.MAINTENANCE.INVALID_STATUS_TRANSITION409
MELMASTOON.MAINTENANCE.SEVERITY_REQUIRES_TARGET422
MELMASTOON.MAINTENANCE.DUPLICATE_OPEN_WORK_ORDER409
MELMASTOON.MAINTENANCE.COST_CURRENCY_MISMATCH422
MELMASTOON.MAINTENANCE.VENDOR_CHANNEL_MISMATCH422
MELMASTOON.MAINTENANCE.PART_OUT_OF_STOCK409
MELMASTOON.MAINTENANCE.SCHEDULE_NEXT_DUE_REGRESSION422
MELMASTOON.MAINTENANCE.PREVENTIVE_DUPLICATE_FIRE409 (returned silently as 200 internally)
MELMASTOON.MAINTENANCE.WORK_ORDER_TERMINAL409
MELMASTOON.MAINTENANCE.ASSET_NOT_FOUND404
MELMASTOON.MAINTENANCE.VENDOR_NOT_FOUND404
MELMASTOON.MAINTENANCE.WORK_ORDER_NOT_FOUND404
MELMASTOON.SYS.OCC_CONFLICT409
MELMASTOON.IAM.AUTHZ_DENIED403
MELMASTOON.IAM.AUTHN_REQUIRED401

3. Resources

3.1 POST /api/v1/maintenance/work-orders

Request:

{
"propertyId": "prop_01HXP...",
"roomId": "room_01HXR...",
"assetId": null,
"category": "hvac",
"severity": "high",
"title": "AC blowing warm air in 204",
"description": "Guest reports AC has been warm since check-in around 14:00. Filter looks dusty.",
"source": "guest_complaint",
"originRef": null,
"allowDuplicate": false,
"aiAssist": { "categoryClassify": false, "severitySuggest": false }
}

Response 201:

{
"id": "mnt_01HXY1...",
"status": "open",
"severity": "high",
"category": "hvac",
"version": 1,
"causedRoomBlock": true,
"relocationRequired": false,
"slaTimer": { "targetMinutes": 240, "dueAt": "2026-04-22T18:00:00Z" },
"aiProvenance": []
}

Errors: 400 INVALID_INPUT, 404 ASSET_NOT_FOUND, 409 DUPLICATE_OPEN_WORK_ORDER, 422 SEVERITY_REQUIRES_TARGET.

3.2 GET /api/v1/maintenance/work-orders/:id

Returns the full WO including tasks[], partsUsed[], costLines[], vendorAcknowledgement, vendorInvoice, aiProvenance[], version.

3.3 GET /api/v1/maintenance/work-orders

Query parameters:

ParamTypeNotes
propertyIdULIDrequired for non-tenant-admin callers
statusopen|assigned|in_progress|blocked|resolved|verified|cancelled (repeatable)
severityrepeatable
categoryrepeatable
assetIdULID
roomIdULID
assigneeUserIdULID
vendorIdULID
sourcerepeatable
dateFrom, dateToISO-8601filters on createdAt
slaStateon_track|breach_imminent|breachedderived
cursoropaquepagination
limitint 1..100, default 50

Response: { items: WorkOrder[], cursor: string|null }.

3.4 PATCH /api/v1/maintenance/work-orders/:id

Mutates only narrow editable fields (title, description, severity, category, customCategoryId, assetId, roomId). Requires If-Match.

3.5 POST /api/v1/maintenance/work-orders/:id/assign

{
"assignee": { "kind": "vendor", "vendorId": "vnd_01HXV..." },
"acknowledgeManualNotify": true,
"note": "Habibi Electrical, asked for callback 5pm"
}

Response 200: { id, status: "assigned", version }.

3.6 POST /api/v1/maintenance/work-orders/:id/start

Empty body. Response 200: { id, status: "in_progress", version }.

3.7 POST /api/v1/maintenance/work-orders/:id/block

{ "reason": "awaiting_part", "etaIso": "2026-04-23T10:00:00+04:30", "note": "Compressor capacitor on order from Peshawar" }

3.8 POST /api/v1/maintenance/work-orders/:id/resume

{ "note": "Part arrived, resuming" }

3.9 POST /api/v1/maintenance/work-orders/:id/resolve

{
"resolutionNote": "Replaced capacitor; airflow restored.",
"costLines": [
{ "kind": "labor", "description": "2 staff × 90 min", "amount": { "currency": "AFN", "amountMicro": "150000000" }, "minutes": 180 },
{ "kind": "vendor_invoice", "description": "Habibi Electrical service call", "amount": { "currency": "AFN", "amountMicro": "300000000" } }
],
"partsUsed": [
{ "partId": "prt_01HXP...", "quantity": 1 }
]
}

Response 200: { id, status: "resolved", version, costRollup: { currency, amountMicro } }.

Errors: 409 PART_OUT_OF_STOCK, 422 COST_CURRENCY_MISMATCH.

3.10 POST /api/v1/maintenance/work-orders/:id/verify

{ "note": "Inspected room, AC cold. Released to housekeeping." }

RBAC: gm or owner. Releases the room block (if any) and triggers preventive next-due update.

3.11 POST /api/v1/maintenance/work-orders/:id/cancel

{ "reason": "Issue resolved itself; guest no longer reporting." }

3.12 POST /api/v1/maintenance/work-orders/:id/escalate

{ "reason": "Vendor missed second appointment", "target": { "kind": "user", "userId": "usr_01HXG..." } }

target.kinduser | role; role looks up tenant escalation chain.

3.13 POST /api/v1/maintenance/work-orders/:id/parts-usage

Append a part usage standalone (without resolve).

{ "partId": "prt_01HXP...", "quantity": 2, "note": "scrapped first attempt" }

3.14 POST /api/v1/maintenance/work-orders/:id/vendor-acknowledged

{ "channel": "phone", "note": "Vendor confirmed visit at 5pm tomorrow" }

3.15 POST /api/v1/maintenance/work-orders/:id/vendor-invoice

{
"amount": { "currency": "AFN", "amountMicro": "500000000" },
"invoiceNumber": "HE-2026-0418",
"issuedAt": "2026-04-22",
"dueAt": "2026-05-22",
"fileRef": "gs://melmastoon-vendor-invoices/tnt_x/2026/HE-2026-0418.pdf"
}

3.16 Preventive schedules

GET /api/v1/maintenance/preventive-schedules
POST /api/v1/maintenance/preventive-schedules
GET /api/v1/maintenance/preventive-schedules/:id
PATCH /api/v1/maintenance/preventive-schedules/:id
DELETE /api/v1/maintenance/preventive-schedules/:id
POST /api/v1/maintenance/preventive-schedules/:id/trigger-now

POST body example:

{
"propertyId": "prop_01HXP...",
"assetId": "ast_01HXG...",
"category": "generator",
"title": "Generator A — 250-hour service",
"cadence": { "kind": "composite", "everyHours": 250, "everyDays": 90, "tz": "Asia/Kabul" },
"slaTargetMinutes": 1440,
"defaultSeverity": "normal",
"defaultAssignee": { "kind": "vendor", "vendorId": "vnd_01HXV..." },
"notifyChannel": "whatsapp",
"active": true
}

3.17 Assets

GET /api/v1/maintenance/assets
POST /api/v1/maintenance/assets
GET /api/v1/maintenance/assets/:id
PATCH /api/v1/maintenance/assets/:id
DELETE /api/v1/maintenance/assets/:id
POST /api/v1/maintenance/assets/:id/health-update

POST /assets:

{
"propertyId": "prop_01HXP...",
"roomId": null,
"class": "generator",
"displayName": "Main Generator (Cummins 25 kVA)",
"model": "C25D5",
"serialNumber": "CUM-25D5-AFG-117",
"installedAt": "2024-08-12",
"capacity": { "value": 25, "unit": "kVA" }
}

POST /assets/:id/health-update:

{
"runHoursDelta": 47,
"healthIndexOverride": null,
"note": "Generator ran 47h since last reading; oil pressure normal."
}

3.18 Vendors

GET /api/v1/maintenance/vendors
POST /api/v1/maintenance/vendors
GET /api/v1/maintenance/vendors/:id
PATCH /api/v1/maintenance/vendors/:id
DELETE /api/v1/maintenance/vendors/:id

POST /vendors:

{
"displayName": "Habibi Electrical",
"categories": ["electrical", "generator", "hvac"],
"contactName": "Habibi Khan",
"phoneE164": "+93700123456",
"whatsappE164":"+93700123456",
"email": null,
"channelPreference": { "primary": "whatsapp", "fallback": "sms" },
"addressFreeText": "Shahre Naw, Kabul",
"callbackWindows": [{ "startsAt": "09:00", "endsAt": "17:00", "note": "weekdays only" }]
}

3.19 Parts (light inventory)

GET /api/v1/maintenance/parts
POST /api/v1/maintenance/parts
GET /api/v1/maintenance/parts/:id
PATCH /api/v1/maintenance/parts/:id
{
"propertyId": "prop_01HXP...",
"partNumber": "FILTER-HVAC-12X12",
"displayName": "HVAC dust filter 12×12",
"category": "hvac",
"onHand": 24,
"reorderThreshold": 6,
"lastUnitCost": { "currency": "AFN", "amountMicro": "5000000" }
}

4. Internal endpoints (Pub/Sub push & cron, not in public OpenAPI)

PathSourceEffect
POST /internal/pubsub/maintenance/housekeeping-requiredmelmastoon.housekeeping.room.maintenance_required.v1Auto-create WO
POST /internal/pubsub/maintenance/lock-health-alertmelmastoon.lock_integration.device.health_alert.v1Upsert asset + auto-create WO
POST /internal/pubsub/maintenance/property-room-ooomelmastoon.property.room.taken_out_of_order.v1Link OOO to WOs
POST /internal/pubsub/maintenance/property-room-releasedmelmastoon.property.room.returned_to_service.v1Sanity-check release
POST /internal/pubsub/maintenance/staff-shift-startedmelmastoon.staff.shift.started.v1Refresh roster cache
POST /internal/pubsub/maintenance/tenant-settings-changedmelmastoon.tenant.settings.changed.v1Refresh SLA / escalation cache
POST /internal/pubsub/maintenance/reservation-checked-inmelmastoon.reservation.checked_in.v1Re-evaluate active WOs on the room
POST /internal/pubsub/maintenance/billing-vendor-invoice-postedmelmastoon.billing.vendor_invoice.posted.v1Mark vendorInvoice.postedToFolio
POST /internal/cron/preventive-schedulerCloud Scheduler 1× per minuteMaterialise due preventive WOs
POST /internal/cron/sla-breach-scannerCloud Scheduler 1× per minuteDetect & emit SLA breaches
POST /internal/cron/vendor-reminderCloud Scheduler 1× per 5 minRe-notify pending vendors
POST /internal/cron/asset-health-forecasterCloud Scheduler 1× per hourAI-update asset health indices

All internal/* endpoints require IAM-auth via Workload Identity, never user-issued JWT.

5. Pagination

  • Cursor-based; cursor is opaque base64-encoded (createdAt, id) tuple.
  • limit 1..100; default 50.
  • Reverse iteration not supported in v1 (use dateTo/dateFrom instead).

6. Idempotency

  • All state-changing endpoints accept X-Idempotency-Key. Same key + same body within 24 h returns the original response.
  • Implementation: idempotency_keys table keyed (tenant_id, key, route).

7. Versioning

  • v1 is the only published version.
  • Breaking changes require a v2 path; old v1 path remains for at least 6 months with deprecation header.
  • Additive changes to response bodies are non-breaking and rolled out immediately.

8. Rate limits

Caller typeDefaultBurst
BFF (per tenantId)50 RPS100
Pub/Sub pushunlimited (Cloud Run handles backpressure)
Cron internalunlimited

Limits enforced at Kong; codes returned: 429 MELMASTOON.SYS.RATE_LIMITED with Retry-After header.