Skip to main content

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 caseREST callersEvent callers
CreateTaskUseCasePOST /tasksReservationCheckedOutHandler, ReservationModificationRequestedHandler
AssignTaskUseCasePOST /tasks/:id/assignRoutingSuggestionAppliedHandler
StartTaskUseCasePOST /tasks/:id/start
PauseTaskUseCasePOST /tasks/:id/pause
ResumeTaskUseCasePOST /tasks/:id/resume
CompleteTaskUseCasePOST /tasks/:id/complete
FailTaskUseCasePOST /tasks/:id/fail
CancelTaskUseCasePOST /tasks/:id/cancelReservationCancelledHandler, RoomArchivedHandler
EscalateTaskUseCasePOST /tasks/:id/escalateEscalationTimerTickHandler
RequireMaintenanceUseCasePOST /tasks/:id/maintenance-required
BumpPriorityUseCasePATCH /tasks/:id/priorityEarlyCheckoutPriorityHandler

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; new assign cycle on a different staff.
  • requireMaintenancetask.requires_maintenance (terminal) + room.maintenance_required.v1maintenance-service opens a work order; on maintenance.work_order.completed.v1, MaintenanceWorkOrderCompletedHandler calls CreateTaskUseCase(kind=PostMaintenance).

5. Event handlers (incoming)

Each handler:

  1. Reads the message; verifies the OIDC token; loads the inbox entry by (topic, message_id).
  2. If already processed → acks (idempotent).
  3. Opens a UoW, executes one or more use cases, appends outbox events, commits.
  4. Acks Pub/Sub on commit; nacks on transient errors (DB unavailable, conflict). Permanent errors go to DLQ after max_delivery_attempts (10).
TopicHandlerAction
melmastoon.reservation.checked_out.v1ReservationCheckedOutHandlerCreate turnover task; flip room → dirty; cancel any older open task for same room.
melmastoon.reservation.early_checkout.v1EarlyCheckoutHandlerCreate or bump-priority of turnover task.
melmastoon.reservation.modification.requested.v1ReservationModificationRequestedHandlerIf mid_stay_clean is among requested mods, create mid_stay_clean task.
melmastoon.reservation.cancelled.v1ReservationCancelledHandlerCancel pending tasks tied to that reservation.
melmastoon.maintenance.work_order.completed.v1MaintenanceWorkOrderCompletedHandlerUnblock room, create post_maintenance task.
melmastoon.staff.shift.started.v1ShiftStartedHandlerProject StaffShiftAssignment.
melmastoon.staff.shift.ended.v1ShiftEndedHandlerTear down assignment; reassign open tasks (router + HITL gate).
melmastoon.ai_orchestrator.suggestion.housekeeping_routing.v1RoutingSuggestionReceivedHandlerPersist suggestion; if HITL gate is auto_apply, call AssignTaskUseCase per row.
melmastoon.property.room.archived.v1RoomArchivedHandlerCancel pending tasks; clear RoomStatus row.
melmastoon.tenant.settings.changed.v1TenantSettingsChangedHandlerRefresh 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:

JobCadencePurpose
shift-staffing-gap-tickevery 60 sSum 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-tickevery 30 sFind Urgent-priority tasks not started within 5 min of priority bump → call EscalateTaskUseCase.
lost-found-retention-tickdaily 03:00 (per tenant TZ via fan-out)Dispose items whose recorded_at + tenant.lostFoundRetentionDays < now.
mid-stay-cadence-tickhourlyFor 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-Key header (UUID). The application layer stores (tenant_id, route, key) → response_hash in idempotency_keys for 24 h. Replays return the cached response.
  • Event handlers use the inbox table: INSERT … ON CONFLICT DO NOTHING on (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 version column. On conflict the use case retries once for read-then-write flows (e.g., BumpPriorityUseCase); otherwise surfaces 409.
  • RoomStatus updates use SELECT … FOR UPDATE to serialise per-room flips.
  • LinenInventory decrement uses UPDATE … 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:

  1. Builds a RoutingInput snapshot (open tasks, active shifts, room geometry).
  2. Calls RoutingSuggestionPort.suggestAssignments(...).
  3. Routes the resulting suggestion through the HITL gate (per-tenant: auto_apply for low-stakes shifts, supervisor_approval by default).
  4. On approval, calls AssignTaskUseCase per row.

Suggestions arriving asynchronously over Pub/Sub (ai_orchestrator.suggestion.housekeeping_routing.v1) follow the same path.

10. Cross-cutting concerns

ConcernApproach
Tenant contextTenantContext is set by an HTTP guard from the JWT; propagated via AsyncLocalStorage so repositories receive it without explicit threading.
LocaleAccept-Language header → LocaleHint value → persisted on tasks for downstream notifications.
TimeAll Dates are UTC; tenant TZ is resolved in presentation only (read models for the board).
MoneyNot relevant here (housekeeping does not handle money).
AuditEach state-changing use case appends a row to audit_events (sidetable) keyed by (tenant_id, aggregate_id, event_id).
Locale-aware printingThe 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; metric melmastoon.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_only mode for that request; the supervisor sees the empty suggestion banner.

12. End-to-end example: drag-and-drop reassignment

  1. Supervisor drags task hkt_… from staff A to staff B on the desktop board.
  2. Electron renderer: optimistic local update, queues a POST /tasks/{id}/assign { staffId: stf_B } with Idempotency-Key.
  3. AssignTaskControllerAssignTaskUseCase:
    • Loads task (FOR UPDATE), checks shift of B is active.
    • Calls task.assign(stf_B, actor); aggregate emits TaskReassignedV1.
    • Saves task; appends outbox event melmastoon.housekeeping.task.reassigned.v1.
    • Commits.
  4. Outbox relay drains → Pub/Sub → consumers (notification-service pings B's WebSocket; analytics-service updates assignment count).
  5. 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