housekeeping-service — API_CONTRACTS
Base URL:
https://housekeeping.<env>.melmastoon.com/api/v1/housekeeping· Versioning: URL-prefixedv1· Errors: RFC 7807 withMELMASTOON.HOUSEKEEPING.*codes · AuthN: tenant-scoped JWT issued byiam-service.
This document is the binding REST contract. Every endpoint here MUST be present in openapi.yaml and exercised in tests/integration/api-contract.spec.ts. Direct callers are bff-backoffice-service (Electron desktop) and bff-tenant-booking-service (mid-stay request). Other services use events, not these endpoints.
1. Common headers
| Header | Direction | Required | Notes |
|---|---|---|---|
Authorization | request | yes | Bearer <jwt> issued by iam-service; carries tenant_id, staff_id, roles. |
X-Tenant-Id | request | yes (defence-in-depth) | Must match JWT claim or → 403. |
X-Request-Id | request | recommended | Echoed in response and logs; ULID. |
Idempotency-Key | request | required on POST and PATCH mutating endpoints | UUID; replay window 24 h. |
Accept-Language | request | recommended | Persisted as locale_hint on tasks. |
If-Match | request | optional | ETag for optimistic concurrency on aggregate updates. |
ETag | response | yes on aggregate reads | "v=<version>". |
X-RateLimit-* | response | yes | Standard limits. |
2. Error envelope (RFC 7807)
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json
{
"type": "https://errors.melmastoon.com/MELMASTOON.HOUSEKEEPING.ROOM_STATE_CONFLICT",
"title": "Room cannot transition from 'ready' to 'cleaning'",
"status": 422,
"code": "MELMASTOON.HOUSEKEEPING.ROOM_STATE_CONFLICT",
"detail": "Room rom_01HZX… is currently 'ready' and has no active task to start cleaning.",
"instance": "/api/v1/housekeeping/tasks/hkt_01HZY…/start",
"i18nKey": "errors.housekeeping.room_state_conflict",
"context": { "currentStatus": "ready", "attemptedTransition": "cleaning" },
"requestId": "req_01HZZ..."
}
The full code catalogue lives in docs/standards/ERROR_CODES.md.
3. Pagination, filtering, sorting
- Pagination: cursor-based —
?cursor=<opaque>&limit=<n>(default 50, max 200). Response includesnextCursor(null if last page). - Filtering: RHS-colon style
?filter[status]=in:assigned,in_progress&filter[priority]=urgent. - Sorting:
?sort=-priority,scheduled_for(-prefix = desc).
4. Endpoints
4.1 Tasks
Create task
POST /tasks
{
"kind": "turnover",
"propertyId": "prp_01HZ…",
"roomId": "rom_01HZ…",
"reservationId": "rsv_01HZ…",
"priority": "high",
"scheduledFor": "2026-04-22T11:30:00Z",
"checklistKind": "turnover",
"localeHint": "ps-AF",
"note": "Late checkout; arriving guest at 13:00"
}
Response 201 Created:
{ "id": "hkt_01J…", "status": "pending", "version": 1,
"checklistId": "chl_01HZ…", "checklistVersion": 7,
"createdAt": "2026-04-22T11:05:11Z" }
Errors: 400 INVALID_INPUT, 409 TASK_ALREADY_EXISTS_FOR_ROOM, 404 ROOM_NOT_FOUND, 422 ROOM_BLOCKED.
List tasks (board)
GET /tasks?propertyId=prp_…&filter[status]=in:pending,assigned,in_progress&sort=-priority,scheduled_for
{
"items": [ { "id": "hkt_…", "status": "in_progress", "priority": "urgent",
"roomId": "rom_…", "assigneeStaffId": "stf_…",
"scheduledFor": "2026-04-22T11:30:00Z", "version": 4 } ],
"nextCursor": null
}
Read one task
GET /tasks/{id} — full aggregate with checklist binding, linen, durations, last 20 audit entries.
Assign / reassign
POST /tasks/{id}/assign
{ "staffId": "stf_01HZ…", "reason": "router_suggestion", "suggestionId": "sug_01HZ…" }
Response 200 OK with new version. Reassignment auto-detected if assignee already set.
Errors: 409 INVALID_TRANSITION, 422 STAFF_UNAVAILABLE.
Start
POST /tasks/{id}/start
{ "at": "2026-04-22T11:42:01Z", "actor": { "staffId": "stf_…" } }
Errors: 409 INVALID_TRANSITION, 422 STAFF_UNAVAILABLE, 422 ROOM_BLOCKED.
Pause / Resume
POST /tasks/{id}/pause
{ "reason": "awaiting_linen", "note": "Need 4 large towels", "at": "2026-04-22T11:51:00Z" }
POST /tasks/{id}/resume
{ "at": "2026-04-22T11:58:21Z" }
Complete
POST /tasks/{id}/complete
{
"completedAt": "2026-04-22T12:14:55Z",
"checklistResults": [
{ "itemKey": "bath_clean", "checked": true, "photoMediaId": "med_01J…" },
{ "itemKey": "linen_change","checked": true },
{ "itemKey": "tv_remote_present","checked": true }
],
"linen": { "issued": 4, "returned": 4 },
"noMaintenanceFound": true,
"note": ""
}
Errors: 409 INVALID_TRANSITION, 422 CHECKLIST_INCOMPLETE.
Fail
POST /tasks/{id}/fail — { reason, note }. Routes the task back to pending unless reason is terminal (sickness reroutes; room_not_vacated escalates).
Cancel
POST /tasks/{id}/cancel — { reason }. Idempotent.
Escalate
POST /tasks/{id}/escalate — { to: { staffId? | role: "supervisor" }, note }.
Maintenance required
POST /tasks/{id}/maintenance-required
{
"issue": {
"category": "plumbing",
"severity": "blocking",
"description": "Bathroom sink leak under cabinet"
},
"photoMediaIds": ["med_01J…"],
"outcome": "convert_terminal" // or "pause"
}
Side effects: emits room.maintenance_required.v1; flips room → out_of_order; if outcome=convert_terminal task moves to requires_maintenance, otherwise paused(awaiting_maintenance).
Bump priority
PATCH /tasks/{id}/priority — { priority: "urgent", reason: "front_desk_needs_now" }.
4.2 Inspections
POST /tasks/{id}/inspections
{
"inspectorStaffId": "stf_01HZ…",
"checklistId": "chl_01HZ…",
"checklistVersion": 7,
"results": [ { "itemKey": "bath_clean", "checked": true } ],
"outcome": "pass",
"note": ""
}
outcome=fail resets the room to dirty and creates a follow-up turnover task (priority bumped one level).
GET /tasks/{id}/inspections — list all inspections for the task (typically 0 or 1, more if re-inspected).
4.3 Rooms
GET /rooms/{roomId}/status →
{ "roomId": "rom_…", "status": "ready", "lastFlippedAt": "2026-04-22T12:30:11Z",
"lastTaskId": "hkt_…", "blocks": [], "version": 12 }
POST /rooms/{roomId}/status (manual flip)
{ "to": "clean", "reason": "post_renovation_walkthrough_ok", "actor": { "role": "property_manager" } }
POST /rooms/{roomId}/block
{ "reason": "inspection", "until": "2026-04-22T13:00:00Z", "note": "Awaiting supervisor walkthrough" }
DELETE /rooms/{roomId}/block/{blockId} — clears the block.
4.4 Checklists
POST /checklists
{
"kind": "turnover",
"items": [
{ "key": "bath_clean", "labelI18n": { "en":"Bathroom cleaned",
"ps-AF":"تشناب پاک شو","fa-AF":"حمام تمیز شد" }, "mandatory": true, "requiresPhoto": true },
{ "key": "linen_change","labelI18n": {"en":"Linen changed","ps-AF":"څادر بدل شو"},
"mandatory": true, "requiresPhoto": false },
{ "key": "tv_remote_present", "labelI18n":{"en":"TV remote present"},
"mandatory": false, "requiresPhoto": false }
]
}
Response 201 with { id, version, publishedAt }. Publishing is immediate; no draft state.
GET /checklists?kind=turnover&active=true → list of latest active versions per kind.
4.5 Lost & found
POST /lost-and-found
{
"roomId": "rom_…",
"reservationId": "rsv_…",
"foundAt": "2026-04-22T12:20:00Z",
"description": "Black umbrella, brand 'Knirps'",
"photoMediaIds": ["med_…"],
"storageLocation": "front-desk-cabinet-3",
"finderStaffId": "stf_…"
}
POST /lost-and-found/{id}/match
{ "claimantName": "Ali R.", "claimantPhone": "+93 70 …",
"matchedAt": "2026-04-23T10:11:00Z", "matchedByStaffId": "stf_…" }
POST /lost-and-found/{id}/dispose
{ "method": "donated", "note": "Donated to staff lounge" }
4.6 Linen
GET /linen?propertyId=prp_… →
{ "items":[
{"id":"lin_…","line":"towel-large","onHand":42,"lowWatermark":20},
{"id":"lin_…","line":"sheet-double","onHand":11,"lowWatermark":15} ] }
POST /linen/{lineId}/issue — { taskId, count }.
POST /linen/{lineId}/return — { taskId, count }. May be < issued.
4.7 Board (composite read)
GET /board?propertyId=prp_…&shiftDate=2026-04-22 →
{
"rooms": [ { "roomId":"rom_…","number":"203","status":"cleaning",
"activeTaskId":"hkt_…","priority":"high" } ],
"tasks": [ { "id":"hkt_…","status":"in_progress","assigneeStaffId":"stf_…",
"checklistProgress":{"checked":3,"total":7} } ],
"shifts":[ { "staffId":"stf_…","name":"Mariam","languages":["ps-AF","fa-AF"],
"capacityMinutes":420,"loadMinutes":280,"activeTaskCount":2 } ],
"stats": { "pendingNext90Min":4, "linenRunwayMinutes":120 }
}
Designed to be a single round-trip for the desktop board on first load. Subsequent updates flow through the sync stream.
4.8 Stats
GET /stats/turnover?propertyId=prp_…&from=2026-04-15&to=2026-04-21 →
{ "windows":[
{"date":"2026-04-15","tasks":18,"avgTimeToReadyMinutes":42,"failedCount":1,"escalatedCount":0}
] }
5. WebSocket / SSE (board live updates)
Live board updates are not served from this service; they go through bff-backoffice-service's WebSocket which subscribes to relevant Pub/Sub topics and fans out to connected sessions. This service only exposes REST.
6. OpenAPI source of truth
openapi.yaml lives in the service repo at contracts/openapi.yaml and is published to the central API registry on merge to main. CI fails if the file is out of sync with controllers (verified by tests/integration/api-contract.spec.ts).
7. Rate limits
Per tenant:
| Bucket | Limit | Window |
|---|---|---|
Board reads (GET /board) | 600 | 60 s |
| Task mutations | 1200 | 60 s |
| Lost & found writes | 60 | 60 s |
| Linen ops | 240 | 60 s |
| Stats | 30 | 60 s |
Limits enforced by iam-service gateway upstream and by an in-process token bucket for defence-in-depth.
8. Internal endpoints (Pub/Sub push, OIDC-authenticated)
POST /internal/events/reservation.checked-out etc. — one route per consumed topic. All require Authorization: Bearer <gcp-oidc> from the configured Pub/Sub service account; rejected otherwise. Not exposed via the public load balancer.
9. Sync endpoints
Documented in SYNC_CONTRACT.md. Mounted under /sync/v1/*; they share auth with the REST endpoints but use separate request/response DTOs and are versioned independently.
10. Cross-link
| You want… | Read |
|---|---|
| Domain semantics behind these endpoints | DOMAIN_MODEL.md |
| Use-case flow per endpoint | APPLICATION_LOGIC.md |
| Event payloads emitted as side effects | EVENT_SCHEMAS.md |
| AuthZ matrix | SECURITY_MODEL.md |
| Failure surfaces by endpoint | FAILURE_MODES.md |