Skip to main content

housekeeping-service — DATA_MODEL

Storage: Cloud SQL Postgres 15 (regional HA), single shared schema housekeeping, tenant_id column on every row, RLS enforced. Outbox/inbox/audit colocated. Monthly partitioning on housekeeping_tasks.

This document is the canonical Postgres schema. DDL here MUST match migrations/*.sql in the service repo. Any drift is caught by tests/integration/db-schema.spec.ts.


1. ID prefixes

AggregatePrefixExample
HousekeepingTaskhkt_hkt_01J0AB…
CleaningChecklistchl_chl_01J0AB…
Inspectionins_ins_01J0AB…
LinenInventorylin_lin_01J0AB…
LostAndFoundlaf_laf_01J0AB…
RoomBlockblk_blk_01J0AB…
StaffShiftAssignmentsft_sft_01J0AB…
Outbox eventevt_evt_01J0AB…

All IDs are ULIDs with the prefix prepended (NAMING §6). Stored as TEXT NOT NULL.

2. Storage placement

DataWhereWhy
All aggregatesPostgres housekeeping schemaACID with the outbox
Inspection photosGCS via media-serviceObject store, lifecycle policy
Tenant settings cacheIn-process LRU (TTL 60 s); source of truth = tenant-serviceHot read
Property/room metadataRead-through cache to property-service Firestore mirrorHot read
Staff/shift metadataRead-through cache to staff-serviceHot read
Auditaudit_events table here + fan-out to audit-serviceLocal fast access + central archive

3. Schema

CREATE SCHEMA IF NOT EXISTS housekeeping;
SET search_path = housekeeping, public;

-- Common helpers
CREATE OR REPLACE FUNCTION current_tenant_id() RETURNS TEXT AS $$
SELECT current_setting('app.tenant_id', true)
$$ LANGUAGE SQL STABLE;

3.1 housekeeping_tasks (partitioned monthly)

CREATE TABLE housekeeping.housekeeping_tasks (
id TEXT NOT NULL,
tenant_id TEXT NOT NULL,
property_id TEXT NOT NULL,
room_id TEXT NOT NULL,
reservation_id TEXT,
kind TEXT NOT NULL CHECK (kind IN
('turnover','mid_stay_clean','deep_clean','post_maintenance','post_renovation','inspection')),
status TEXT NOT NULL CHECK (status IN
('pending','assigned','in_progress','paused','completed','failed','cancelled','requires_maintenance')),
priority TEXT NOT NULL CHECK (priority IN ('low','normal','high','urgent')),
assignee_staff_id TEXT,
checklist_id TEXT,
checklist_version INTEGER,
scheduled_for TIMESTAMPTZ,
started_at TIMESTAMPTZ,
paused_at TIMESTAMPTZ,
pause_reason TEXT,
completed_at TIMESTAMPTZ,
duration_minutes INTEGER,
failure_reason TEXT,
failure_note TEXT,
outcome_results JSONB, -- ChecklistItemResult[]
linen_issued INTEGER,
linen_returned INTEGER,
locale_hint TEXT,
source TEXT NOT NULL DEFAULT 'manual',
source_event_id TEXT,
version INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);

-- Monthly partitions managed by pg_partman
SELECT partman.create_parent('housekeeping.housekeeping_tasks',
'created_at', 'native', 'monthly');

CREATE INDEX hkt_tenant_property_status_idx
ON housekeeping.housekeeping_tasks (tenant_id, property_id, status, priority, scheduled_for);
CREATE INDEX hkt_tenant_room_open_idx
ON housekeeping.housekeeping_tasks (tenant_id, room_id)
WHERE status IN ('pending','assigned','in_progress','paused');
CREATE INDEX hkt_tenant_assignee_open_idx
ON housekeeping.housekeeping_tasks (tenant_id, assignee_staff_id)
WHERE status IN ('assigned','in_progress','paused');
CREATE INDEX hkt_reservation_idx
ON housekeeping.housekeeping_tasks (tenant_id, reservation_id)
WHERE reservation_id IS NOT NULL;

-- RLS
ALTER TABLE housekeeping.housekeeping_tasks ENABLE ROW LEVEL SECURITY;
CREATE POLICY hkt_tenant_isolation ON housekeeping.housekeeping_tasks
USING (tenant_id = current_tenant_id())
WITH CHECK (tenant_id = current_tenant_id());

Hot reads MUST include created_at >= … (partition pruning); the API derives this from scheduledFor/updatedSince filters.

3.2 room_status (singleton per room)

CREATE TABLE housekeeping.room_status (
tenant_id TEXT NOT NULL,
property_id TEXT NOT NULL,
room_id TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN
('clean','dirty','cleaning','cleaned','inspected','ready','out_of_order','out_of_service')),
last_task_id TEXT,
last_flipped_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_flipped_by JSONB NOT NULL, -- { type, id }
last_cause TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (tenant_id, property_id, room_id)
);
ALTER TABLE housekeeping.room_status ENABLE ROW LEVEL SECURITY;
CREATE POLICY rs_tenant_isolation ON housekeeping.room_status
USING (tenant_id = current_tenant_id()) WITH CHECK (tenant_id = current_tenant_id());

CREATE INDEX rs_tenant_status_idx
ON housekeeping.room_status (tenant_id, property_id, status);

3.3 room_status_audit (append-only)

CREATE TABLE housekeeping.room_status_audit (
id BIGSERIAL PRIMARY KEY,
tenant_id TEXT NOT NULL,
property_id TEXT NOT NULL,
room_id TEXT NOT NULL,
previous_status TEXT NOT NULL,
status TEXT NOT NULL,
cause TEXT NOT NULL,
task_id TEXT,
flipped_by JSONB NOT NULL,
flipped_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX rsa_tenant_room_idx
ON housekeeping.room_status_audit (tenant_id, property_id, room_id, flipped_at DESC);
ALTER TABLE housekeeping.room_status_audit ENABLE ROW LEVEL SECURITY;
CREATE POLICY rsa_tenant_isolation ON housekeeping.room_status_audit
USING (tenant_id = current_tenant_id()) WITH CHECK (tenant_id = current_tenant_id());

3.4 cleaning_checklists (versioned, immutable)

CREATE TABLE housekeeping.cleaning_checklists (
id TEXT NOT NULL,
tenant_id TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN
('turnover','mid_stay_clean','deep_clean','post_maintenance','post_renovation','inspection')),
version INTEGER NOT NULL,
items JSONB NOT NULL, -- ChecklistItem[]
published_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published_by JSONB NOT NULL,
PRIMARY KEY (id),
UNIQUE (tenant_id, kind, version)
);
ALTER TABLE housekeeping.cleaning_checklists ENABLE ROW LEVEL SECURITY;
CREATE POLICY cl_tenant_isolation ON housekeeping.cleaning_checklists
USING (tenant_id = current_tenant_id()) WITH CHECK (tenant_id = current_tenant_id());

CREATE INDEX cl_tenant_kind_latest_idx
ON housekeeping.cleaning_checklists (tenant_id, kind, version DESC);

3.5 inspections

CREATE TABLE housekeeping.inspections (
id TEXT NOT NULL PRIMARY KEY,
tenant_id TEXT NOT NULL,
task_id TEXT NOT NULL,
inspector_staff_id TEXT NOT NULL,
checklist_id TEXT NOT NULL,
checklist_version INTEGER NOT NULL,
results JSONB NOT NULL,
outcome TEXT NOT NULL CHECK (outcome IN ('pass','fail')),
reason TEXT,
failed_item_keys TEXT[],
performed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ins_task_idx ON housekeeping.inspections (tenant_id, task_id);
ALTER TABLE housekeeping.inspections ENABLE ROW LEVEL SECURITY;
CREATE POLICY ins_tenant_isolation ON housekeeping.inspections
USING (tenant_id = current_tenant_id()) WITH CHECK (tenant_id = current_tenant_id());

3.6 linen_inventory

CREATE TABLE housekeeping.linen_inventory (
id TEXT NOT NULL PRIMARY KEY,
tenant_id TEXT NOT NULL,
property_id TEXT NOT NULL,
line TEXT NOT NULL,
on_hand INTEGER NOT NULL DEFAULT 0 CHECK (on_hand >= 0),
low_watermark INTEGER NOT NULL DEFAULT 0 CHECK (low_watermark >= 0),
version INTEGER NOT NULL DEFAULT 1,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_id, property_id, line)
);
ALTER TABLE housekeeping.linen_inventory ENABLE ROW LEVEL SECURITY;
CREATE POLICY li_tenant_isolation ON housekeeping.linen_inventory
USING (tenant_id = current_tenant_id()) WITH CHECK (tenant_id = current_tenant_id());

3.7 linen_movements (append-only)

CREATE TABLE housekeeping.linen_movements (
id BIGSERIAL PRIMARY KEY,
tenant_id TEXT NOT NULL,
line_id TEXT NOT NULL REFERENCES housekeeping.linen_inventory(id),
task_id TEXT,
direction TEXT NOT NULL CHECK (direction IN ('issue','return','adjust')),
count INTEGER NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
actor JSONB NOT NULL
);
CREATE INDEX lm_line_idx ON housekeeping.linen_movements (tenant_id, line_id, occurred_at DESC);
ALTER TABLE housekeeping.linen_movements ENABLE ROW LEVEL SECURITY;
CREATE POLICY lm_tenant_isolation ON housekeeping.linen_movements
USING (tenant_id = current_tenant_id()) WITH CHECK (tenant_id = current_tenant_id());

3.8 lost_and_found

CREATE TABLE housekeeping.lost_and_found (
id TEXT NOT NULL PRIMARY KEY,
tenant_id TEXT NOT NULL,
property_id TEXT NOT NULL,
room_id TEXT NOT NULL,
reservation_id TEXT,
description TEXT NOT NULL,
photo_media_ids TEXT[],
storage_location TEXT,
finder_staff_id TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('recorded','matched','disposed','returned'))
DEFAULT 'recorded',
matched_at TIMESTAMPTZ,
matched_by_staff TEXT,
claimant_name TEXT,
claimant_phone TEXT,
disposed_at TIMESTAMPTZ,
dispose_method TEXT,
recorded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
version INTEGER NOT NULL DEFAULT 1
);
CREATE INDEX laf_tenant_status_idx ON housekeeping.lost_and_found
(tenant_id, property_id, status, recorded_at DESC);
CREATE INDEX laf_reservation_idx ON housekeeping.lost_and_found
(tenant_id, reservation_id) WHERE reservation_id IS NOT NULL;
ALTER TABLE housekeeping.lost_and_found ENABLE ROW LEVEL SECURITY;
CREATE POLICY laf_tenant_isolation ON housekeeping.lost_and_found
USING (tenant_id = current_tenant_id()) WITH CHECK (tenant_id = current_tenant_id());

3.9 room_blocks

CREATE TABLE housekeeping.room_blocks (
id TEXT NOT NULL PRIMARY KEY,
tenant_id TEXT NOT NULL,
property_id TEXT NOT NULL,
room_id TEXT NOT NULL,
reason TEXT NOT NULL CHECK (reason IN ('cleaning','inspection','maintenance','oos_echo','oos')),
owner TEXT NOT NULL CHECK (owner IN ('housekeeping','maintenance','property')),
blocked_until TIMESTAMPTZ,
note TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
cleared_at TIMESTAMPTZ
);
CREATE INDEX rb_tenant_room_open_idx ON housekeeping.room_blocks
(tenant_id, property_id, room_id) WHERE cleared_at IS NULL;
ALTER TABLE housekeeping.room_blocks ENABLE ROW LEVEL SECURITY;
CREATE POLICY rb_tenant_isolation ON housekeeping.room_blocks
USING (tenant_id = current_tenant_id()) WITH CHECK (tenant_id = current_tenant_id());

3.10 staff_shift_assignments (read-side projection)

CREATE TABLE housekeeping.staff_shift_assignments (
id TEXT NOT NULL PRIMARY KEY,
tenant_id TEXT NOT NULL,
property_id TEXT NOT NULL,
staff_id TEXT NOT NULL,
shift_id TEXT NOT NULL,
shift_starts_at TIMESTAMPTZ NOT NULL,
shift_ends_at TIMESTAMPTZ NOT NULL,
capacity_minutes INTEGER NOT NULL,
languages TEXT[],
skills TEXT[],
active_task_count INTEGER NOT NULL DEFAULT 0,
load_minutes INTEGER NOT NULL DEFAULT 0,
ended_at TIMESTAMPTZ
);
CREATE INDEX ssa_tenant_active_idx ON housekeeping.staff_shift_assignments
(tenant_id, property_id, shift_starts_at, shift_ends_at) WHERE ended_at IS NULL;
ALTER TABLE housekeeping.staff_shift_assignments ENABLE ROW LEVEL SECURITY;
CREATE POLICY ssa_tenant_isolation ON housekeeping.staff_shift_assignments
USING (tenant_id = current_tenant_id()) WITH CHECK (tenant_id = current_tenant_id());

3.11 outbox, inbox, idempotency_keys, audit_events

CREATE TABLE housekeeping.outbox (
id BIGSERIAL PRIMARY KEY,
event_id TEXT NOT NULL UNIQUE,
tenant_id TEXT NOT NULL,
subject TEXT NOT NULL,
payload JSONB NOT NULL,
envelope_meta JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published_at TIMESTAMPTZ
);
CREATE INDEX outbox_unpublished_idx
ON housekeeping.outbox (id) WHERE published_at IS NULL;

CREATE TABLE housekeeping.inbox (
topic TEXT NOT NULL,
message_id TEXT NOT NULL,
tenant_id TEXT NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (topic, message_id)
);

CREATE TABLE housekeeping.idempotency_keys (
tenant_id TEXT NOT NULL,
route TEXT NOT NULL,
key TEXT NOT NULL,
response_hash TEXT NOT NULL,
response JSONB NOT NULL,
status_code INTEGER NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, route, key)
);

CREATE TABLE housekeeping.audit_events (
id BIGSERIAL PRIMARY KEY,
tenant_id TEXT NOT NULL,
aggregate_id TEXT NOT NULL,
aggregate_kind TEXT NOT NULL,
event_id TEXT NOT NULL,
subject TEXT NOT NULL,
payload JSONB NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
actor JSONB NOT NULL
);
CREATE INDEX audit_aggregate_idx ON housekeeping.audit_events
(tenant_id, aggregate_kind, aggregate_id, occurred_at DESC);

RLS policies on outbox, inbox, idempotency_keys, audit_events mirror the others.

4. Read-side views

4.1 room_blocks_view

Composite view across our room_blocks rows and maintenance-service's rows (replicated via the inbox handler that maintains a local mirror table). Used by the board read.

4.2 board_snapshot_mat (refreshed on demand)

Materialised projection used by GET /board first paint. Refreshed:

  • on every room.status_changed.v1, task.*, linen_inventory change for the affected (tenant, property).
  • by a cron-ticker every 60 s as a safety net.

Backed by a per-(tenant, property) cached JSON in Redis (5-second TTL) for hot board polling; falls back to live SQL if cache miss.

5. Migration policy

  • node-pg-migrate with expand → contract in two PRs.
  • Backfills for partition-touching changes are run as Cloud Run Jobs with throttling (1k rows/s default).
  • See MIGRATION_PLAN.md.

6. Capacity & retention

TableRetentionNotes
housekeeping_tasks18 months hot, archive to GCS Parquet via export job18-month-old partitions detached + dropped after export
room_status_audit24 monthsPartition by quarter (added later)
inspections24 monthsPhoto refs only; photos lifecycled by media-service
linen_movements12 monthsOld months archived to GCS
lost_and_foundper tenant policy (default 12 months after disposed_at)
room_blocksindefinite while open; cleared rows archived after 90 days
outbox7 days hot (delete after publish + 7d)
inbox30 daysVacuumed weekly
idempotency_keys24 hoursCron job
audit_events24 months hot, then GCS ParquetMirrored to audit-service real-time

Estimated row counts for a 50-room tenant doing 25 turnovers/day: 9k tasks/year, ~30k room-status-audit/year, ~9k inspections/year — well within Postgres comfort with monthly partitioning.

7. Tenant-isolation acceptance test

tests/integration/tenant-isolation.spec.ts runs every CI build:

SET app.tenant_id = 'tnt_A';
INSERT INTO housekeeping.housekeeping_tasks (...) VALUES (...);
SET app.tenant_id = 'tnt_B';
SELECT count(*) FROM housekeeping.housekeeping_tasks; -- expect 0
UPDATE housekeeping.housekeeping_tasks SET priority='urgent'; -- expect 0 rows