housekeeping-service — APPLICATION_LOGIC
Use-cases, ports, sagas, schedulers, and idempotency. The application layer orchestrates domain aggregates and adapters; it never holds business rules itself.
This document covers the runtime behaviour: how an event becomes a task, how a task becomes a flipped room, how the AI router and the desktop board affect the same aggregate without stepping on each other, and how every operation is repeatable safely.
1. Layering recap
src/
domain/ ← pure types, invariants, events (no I/O)
application/ ← use cases, ports, sagas (this doc)
infrastructure/ ← adapters: pg, pubsub, http, ai, clock, id-gen
presentation/ ← REST controllers, event handlers, DTOs
Application depends on Domain + Ports. Infrastructure implements ports. Wiring lives in src/main.ts (Nest module composition).
2. Ports (interfaces the application owns; infrastructure implements)
// Persistence
export interface HousekeepingTaskRepository {
findById(id: HousekeepingTaskId, t: TenantId): Promise<HousekeepingTask | null>;
save(task: HousekeepingTask): Promise<void>; // optimistic concurrency
listForBoard(q: BoardQuery): Promise<HousekeepingTask[]>;
}
export interface RoomStatusRepository { /* findOne, save (FOR UPDATE) */ }
export interface ChecklistRepository { /* current(kind, tenantId), byVersion(...) */ }
export interface InspectionRepository { /* save, latestForTask */ }
export interface LinenInventoryRepository { /* findByLine, save, decrement, increment */ }
export interface LostAndFoundRepository { /* save, find, listOpen */ }
export interface RoomBlockRepository { /* listForRoom, add, clear */ }
export interface ShiftAssignmentRepository { /* findByStaffAt, listActive */ }
// Outbound integration
export interface OutboxPort { append(eventEnvelope: EventEnvelope): Promise<void>; }
export interface ClockPort { now(): Date; }
export interface IdGenPort { newId(prefix: string): string; }
export interface RoutingSuggestionPort { // calls ai-orchestrator
suggestAssignments(input: RoutingInput): Promise<RoutingSuggestion>;
}
export interface StaffShiftPort { // calls staff-service for non-cached lookups
isOnDuty(staffId: StaffId, at: Date): Promise<boolean>;
}
export interface PropertyReadPort { // cached (Firestore mirror) for room metadata
readRoom(roomId: RoomId, tenantId: TenantId): Promise<RoomMeta | null>;
}
export interface NotificationPort { // emits to notification-service via outbox event
notify(channel: NotificationChannel, payload: NotificationPayload): Promise<void>;
}
All Postgres-backed repositories share a UnitOfWork that wraps a single transaction across the aggregate save + the outbox append. The OutboxPort is not allowed to write outside the active UoW — the relay is a separate process that drains the outbox table to Pub/Sub.
3. Use cases (one class per use case)
Naming: <Verb><Noun>UseCase. Each use case has a single execute(input, ctx): Promise<output> and is registered in a Nest module so it can be wired into both REST controllers and event handlers.
3.1 Task lifecycle use cases
| Use case | REST callers | Event callers |
|---|---|---|
CreateTaskUseCase | POST /tasks | ReservationCheckedOutHandler, ReservationModificationRequestedHandler |
AssignTaskUseCase | POST /tasks/:id/assign | RoutingSuggestionAppliedHandler |
StartTaskUseCase | POST /tasks/:id/start | — |
PauseTaskUseCase | POST /tasks/:id/pause | — |
ResumeTaskUseCase | POST /tasks/:id/resume | — |
CompleteTaskUseCase | POST /tasks/:id/complete | — |
FailTaskUseCase | POST /tasks/:id/fail | — |
CancelTaskUseCase | POST /tasks/:id/cancel | ReservationCancelledHandler, RoomArchivedHandler |
EscalateTaskUseCase | POST /tasks/:id/escalate | EscalationTimerTickHandler |
RequireMaintenanceUseCase | POST /tasks/:id/maintenance-required | — |
BumpPriorityUseCase | PATCH /tasks/:id/priority | EarlyCheckoutPriorityHandler |
3.2 Room/Status/Block
| FlipRoomStatusUseCase (manual) | POST /rooms/:roomId/status | — |
| BlockRoomUseCase | POST /rooms/:roomId/block | MaintenanceBlockHandler (echo) |
| UnblockRoomUseCase | DELETE /rooms/:roomId/block/:blockId | MaintenanceWorkOrderCompletedHandler |
3.3 Inspection / checklist
| CreateChecklistVersionUseCase | POST /checklists | — |
| RunInspectionUseCase | POST /tasks/:id/inspections | — |
3.4 Lost & found / linen
| RecordLostItemUseCase | POST /lost-and-found | — |
| MatchLostItemUseCase | POST /lost-and-found/:id/match | — |
| DisposeLostItemUseCase | POST /lost-and-found/:id/dispose | LostAndFoundRetentionTickHandler |
| IssueLinenUseCase | POST /linen/:lineId/issue | — |
| ReturnLinenUseCase | POST /linen/:lineId/return | — |
3.5 Read models
| GetBoardUseCase | GET /board | — |
| GetTurnoverStatsUseCase | GET /stats/turnover | — |
4. The turnover saga (canonical happy path)
Triggered by melmastoon.reservation.checked_out.v1. Implemented as a single ReservationCheckedOutHandler that calls CreateTaskUseCase and lets later events drive the rest — there is no centralized saga state because the task aggregate itself is the state machine.
reservation.checked_out.v1
│
▼ ┌─────────────────────────────────────────────────────┐
│ ReservationCheckedOutHandler │
│ 1. Idempotency: inbox lookup on (topic, msg_id) │
│ 2. CreateTaskUseCase(kind=Turnover, prio=byEta()) │
│ 3. RoomStatus: dirty (FOR UPDATE) │
│ 4. Emit task.created.v1 + room.status_changed.v1 │
└─────────────────────────────────────────────────────┘
│
▼ (router suggests assignment, supervisor or HITL approves)
AssignTaskUseCase ─emits─▶ task.assigned.v1
│
▼ (housekeeper presses Start on the desktop)
StartTaskUseCase ─emits─▶ task.started.v1, room.status_changed.v1 (cleaning)
│
▼ (clean → pause/resume? → complete)
CompleteTaskUseCase ─emits─▶ task.completed.v1, room.status_changed.v1 (cleaned)
│
▼ (if tenant requires inspection)
RunInspectionUseCase ─emits─▶ inspection.passed.v1, room.status_changed.v1 (inspected → ready)
│
▼
front-desk arrivals board lights up via search-aggregation room-readiness facet
Failure branches:
task.failed.v1→ router re-suggests; newassigncycle on a different staff.requireMaintenance→task.requires_maintenance(terminal) +room.maintenance_required.v1→maintenance-serviceopens a work order; onmaintenance.work_order.completed.v1,MaintenanceWorkOrderCompletedHandlercallsCreateTaskUseCase(kind=PostMaintenance).
5. Event handlers (incoming)
Each handler:
- Reads the message; verifies the OIDC token; loads the inbox entry by
(topic, message_id). - If already processed → acks (idempotent).
- Opens a UoW, executes one or more use cases, appends outbox events, commits.
- Acks Pub/Sub on commit; nacks on transient errors (DB unavailable, conflict). Permanent errors go to DLQ after
max_delivery_attempts(10).
| Topic | Handler | Action |
|---|---|---|
melmastoon.reservation.checked_out.v1 | ReservationCheckedOutHandler | Create turnover task; flip room → dirty; cancel any older open task for same room. |
melmastoon.reservation.early_checkout.v1 | EarlyCheckoutHandler | Create or bump-priority of turnover task. |
melmastoon.reservation.modification.requested.v1 | ReservationModificationRequestedHandler | If mid_stay_clean is among requested mods, create mid_stay_clean task. |
melmastoon.reservation.cancelled.v1 | ReservationCancelledHandler | Cancel pending tasks tied to that reservation. |
melmastoon.maintenance.work_order.completed.v1 | MaintenanceWorkOrderCompletedHandler | Unblock room, create post_maintenance task. |
melmastoon.staff.shift.started.v1 | ShiftStartedHandler | Project StaffShiftAssignment. |
melmastoon.staff.shift.ended.v1 | ShiftEndedHandler | Tear down assignment; reassign open tasks (router + HITL gate). |
melmastoon.ai_orchestrator.suggestion.housekeeping_routing.v1 | RoutingSuggestionReceivedHandler | Persist suggestion; if HITL gate is auto_apply, call AssignTaskUseCase per row. |
melmastoon.property.room.archived.v1 | RoomArchivedHandler | Cancel pending tasks; clear RoomStatus row. |
melmastoon.tenant.settings.changed.v1 | TenantSettingsChangedHandler | Refresh in-memory settings cache (TTL 60 s). |
6. Schedulers / timers
Three out-of-band loops, all run as Cloud Run Jobs triggered by Cloud Scheduler:
| Job | Cadence | Purpose |
|---|---|---|
shift-staffing-gap-tick | every 60 s | Sum pending+assigned task minutes per active shift; if > capacity * 1.2, emit shift.staffing_gap_detected.v1. Debounced per (tenant, property, shiftId) — emits at most once per 15 min. |
escalation-tick | every 30 s | Find Urgent-priority tasks not started within 5 min of priority bump → call EscalateTaskUseCase. |
lost-found-retention-tick | daily 03:00 (per tenant TZ via fan-out) | Dispose items whose recorded_at + tenant.lostFoundRetentionDays < now. |
mid-stay-cadence-tick | hourly | For tenants opted into auto mid-stay (default in "full-service" config), enqueue mid_stay_clean tasks for active reservations whose last clean was > 24 h ago. |
7. Idempotency
- Every mutating REST endpoint accepts an
Idempotency-Keyheader (UUID). The application layer stores(tenant_id, route, key) → response_hashinidempotency_keysfor 24 h. Replays return the cached response. - Event handlers use the inbox table:
INSERT … ON CONFLICT DO NOTHINGon(topic, message_id). Already-seen messages ack immediately. - Outbox writes carry an
event_id(ULID) generated at append time; consumers dedupe on it.
8. Concurrency control
- Per-aggregate optimistic concurrency via
versioncolumn. On conflict the use case retries once for read-then-write flows (e.g.,BumpPriorityUseCase); otherwise surfaces409. RoomStatusupdates useSELECT … FOR UPDATEto serialise per-room flips.LinenInventorydecrement usesUPDATE … SET on_hand = on_hand - $delta WHERE on_hand >= $delta. Zero rows updated →MELMASTOON.HOUSEKEEPING.LINEN_OUT_OF_STOCK.
9. AI routing port (outline; full spec in AI_INTEGRATION.md)
The application layer never embeds optimization; it only:
- Builds a
RoutingInputsnapshot (open tasks, active shifts, room geometry). - Calls
RoutingSuggestionPort.suggestAssignments(...). - Routes the resulting suggestion through the HITL gate (per-tenant:
auto_applyfor low-stakes shifts,supervisor_approvalby default). - On approval, calls
AssignTaskUseCaseper row.
Suggestions arriving asynchronously over Pub/Sub (ai_orchestrator.suggestion.housekeeping_routing.v1) follow the same path.
10. Cross-cutting concerns
| Concern | Approach |
|---|---|
| Tenant context | TenantContext is set by an HTTP guard from the JWT; propagated via AsyncLocalStorage so repositories receive it without explicit threading. |
| Locale | Accept-Language header → LocaleHint value → persisted on tasks for downstream notifications. |
| Time | All Dates are UTC; tenant TZ is resolved in presentation only (read models for the board). |
| Money | Not relevant here (housekeeping does not handle money). |
| Audit | Each state-changing use case appends a row to audit_events (sidetable) keyed by (tenant_id, aggregate_id, event_id). |
| Locale-aware printing | The desktop renders checklists from labelI18n; we do not translate server-side. |
11. Failure handling within use cases
- Domain errors → propagate; presentation maps to RFC 7807.
- Concurrency conflict → retry once for safe read-then-write; otherwise 409.
- Repository unavailable → 503 with
Retry-After: 5; metricmelmastoon.housekeeping.repo.errors. - Outbox append failure → entire UoW rolls back; the use case fails and the caller retries.
- Routing port timeout (default 1.5 s p99) → fall back to
manual_onlymode for that request; the supervisor sees the empty suggestion banner.
12. End-to-end example: drag-and-drop reassignment
- Supervisor drags task
hkt_…from staff A to staff B on the desktop board. - Electron renderer: optimistic local update, queues a
POST /tasks/{id}/assign { staffId: stf_B }withIdempotency-Key. AssignTaskController→AssignTaskUseCase:- Loads
task(FOR UPDATE), checks shift ofBis active. - Calls
task.assign(stf_B, actor); aggregate emitsTaskReassignedV1. - Saves task; appends outbox event
melmastoon.housekeeping.task.reassigned.v1. - Commits.
- Loads
- Outbox relay drains → Pub/Sub → consumers (
notification-servicepings B's WebSocket;analytics-serviceupdates assignment count). - Sync engine pushes the row down to all subscribed desktops on next pull.
13. Folder layout (application)
src/application/
use-cases/
tasks/
create-task.use-case.ts
assign-task.use-case.ts
start-task.use-case.ts
…
rooms/
inspections/
checklists/
lost-and-found/
linen/
read-models/
ports/
repositories.ts
outbox.port.ts
routing-suggestion.port.ts
staff-shift.port.ts
property-read.port.ts
notification.port.ts
clock.port.ts
id-gen.port.ts
sagas/
(handlers as classes; saga state lives on the task aggregate itself)
schedulers/
shift-staffing-gap.tick.ts
escalation.tick.ts
lost-and-found-retention.tick.ts
mid-stay-cadence.tick.ts