From b2a1056255948aae4fb77b4b2f4660a96409e017 Mon Sep 17 00:00:00 2001 From: Lars Nolden Date: Fri, 3 Jul 2026 17:05:43 +0200 Subject: [PATCH] schema v1 --- db/schema.sql | 600 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 600 insertions(+) create mode 100644 db/schema.sql diff --git a/db/schema.sql b/db/schema.sql new file mode 100644 index 0000000..6a696f1 --- /dev/null +++ b/db/schema.sql @@ -0,0 +1,600 @@ +-- ============================================================================ +-- GebOS – Schema V1 +-- Target: Postgres 17 + TimescaleDB +-- +-- Scope: UVI pilot per § 6a HeizkostenV, methodology per UBA-Leitfaden +-- CLIMATE CHANGE 69/2021 (all six modules). Annual billing is a separate +-- epic and deliberately not modeled here. +-- +-- Requirement IDs (UVI-DAT-*, UVI-FUN-*, UVI-NFR-*, UVI-MET-*) refer to the +-- Notion page "UVI – Anforderungen (Pilot)", which is the source of truth. +-- +-- Conventions +-- * lowercase snake_case everywhere — no quoted identifiers, ever. +-- * Surrogate UUID primary keys, except time-series tables where the +-- composite natural key carries meaning (see measurement). +-- * timestamptz for all instants; date for civil dates (tenancy, prices). +-- * jsonb, never json (binary, indexable, deduplicated keys). +-- * created_at on every table for ops forensics. +-- * ON DELETE RESTRICT as default policy: telemetry and tenancy data must +-- never disappear as a side effect of deleting a parent row. Deletions +-- are rare, deliberate, admin-level operations. +-- ============================================================================ + +CREATE EXTENSION IF NOT EXISTS timescaledb; +CREATE EXTENSION IF NOT EXISTS pgcrypto; -- gen_random_uuid() +CREATE EXTENSION IF NOT EXISTS btree_gist; -- exclusion constraint on occupancy + +-- --------------------------------------------------------------------------- +-- Enums +-- +-- Enums are used where the value set is fixed by law or by the product +-- itself and changes require a deliberate migration (which is a feature: +-- a new fuel type SHOULD force someone to also add its CO2 factor and +-- price rows). Where the value set is operational content that admins +-- curate at runtime (saving tips), a table is used instead of an enum. +-- --------------------------------------------------------------------------- + +-- Drives CO2 emission factors (GEG Anlage 9) and cost estimation (Module 5). +-- One fuel per building is the pilot assumption. District heating fuel-mix +-- disclosure (§ 6a Abs. 3) belongs to annual billing; if it later requires +-- per-network fuel compositions, promote this enum to a table with a +-- composition child table — the FK sites below are the only touch points. +CREATE TYPE fuel_type AS ENUM ( + 'natural_gas', + 'heating_oil', + 'district_heating', + 'heat_pump_electricity', + 'wood_pellets', + 'other' +); + +-- The SEMANTIC kind of a reading. This is deliberately separate from the +-- physical unit: heating energy and hot-water energy are both kWh, yet the +-- UVI must never mix them (Modules 1 and 2 render them separately, and +-- UVI-FUN-14 treats them differently outside the heating period). The unit +-- says how a number is measured; the kind says what it means. +-- +-- Diagnostic kinds (temperatures, error flags) are included because OMS +-- telegrams carry them anyway and they are the raw material for device +-- health monitoring — cheap to store now, expensive to backfill later. +CREATE TYPE measurement_kind AS ENUM ( + 'heating_energy', -- kWh, heat cost allocator / heat meter + 'hot_water_energy', -- kWh + 'hot_water_volume', -- m3; some installations meter volume and + -- convert to energy downstream + 'cold_water_volume', -- m3; not needed for the UVI but commonly on + -- the same radio and trivially captured + 'flow_temperature', -- °C, diagnostics + 'return_temperature', -- °C, diagnostics + 'error_flags', -- OMS status/error register + 'other' +); + +-- Two roles are enough for the pilot: tenants see exactly one apartment +-- dashboard (UVI-NFR-03), admins see the read-only overview of everything +-- (UVI-FUN-17). A role enum rather than a boolean because the role set is +-- known to grow (landlord read-only view is an announced post-pilot epic) +-- and because UVI-NFR-07 already treats "admin" as a first-class concept +-- with its own audit requirements. +CREATE TYPE user_role AS ENUM ('tenant', 'admin'); + +CREATE TYPE saving_tip_category AS ENUM ('heating', 'hot_water'); + + +-- =========================================================================== +-- 1. BUILDING & TENANCY +-- +-- The physical and legal world: buildings contain apartments, apartments +-- are occupied by tenants over time periods. Everything the UVI displays +-- is ultimately scoped by this layer — a measurement only becomes a +-- "tenant's consumption" through sensor -> apartment -> occupancy. +-- =========================================================================== + +CREATE TABLE building ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Address is structured (not one text blob) because the admin + -- dashboard groups by building address (UVI-FUN-17) and because + -- postal_code is the likely input for automatic weather-station + -- assignment if that open decision lands on "auto". + street text NOT NULL, + house_number text NOT NULL, + postal_code text NOT NULL, + city text NOT NULL, + + -- Coordinates exist for exactly one consumer: nearest-weather-station + -- resolution. They are NOT used to locate devices — device placement + -- is human-readable installer text on the device rows themselves. + lat numeric(9,6), + lng numeric(9,6), + + -- The building's energy carrier. Chosen at building level (not per + -- apartment, not per measurement) because the boiler / district + -- heating connection is a building-level fact, and both the CO2 + -- module and the cost module resolve their factors through it. + fuel_type fuel_type NOT NULL, + + -- UVI-MET-03. The UBA/GEG comparison metrics (Bandtacho thresholds, + -- in-house kWh/m²) are defined against Gebäudenutzfläche (AN), a + -- technical energy-balance quantity nobody in this business has on + -- file. The sanctioned approximation is AN ≈ Wohnfläche × 1.2. + -- Stored per building, not hardcoded, so that a building with a + -- known real AN (e.g. from an energy certificate) can override it: + -- set area_factor = real_AN / sum(living_area_m2). Every specific + -- consumption in the app divides by (living_area_m2 * area_factor); + -- omitting the factor would inflate every building's apparent + -- consumption ~20% and shift Bandtacho classes. + area_factor numeric(4,2) NOT NULL DEFAULT 1.20 + CHECK (area_factor > 0), + + -- Which DWD station's heating degree days contextualize this + -- building's consumption (Module 1 footnote, UVI-FUN-07). Nullable + -- and manually assigned in the pilot: the auto-vs-manual assignment + -- question is an explicitly open decision, and the schema must not + -- presume its answer. A NULL here means the weather footnote renders + -- as "not available" (UVI-FUN-15) — never as a silent default station. + weather_station_id uuid, -- FK added after weather_station is defined + + -- Building-level operational remarks: key-box code, basement access, + -- contact person. Device mounting locations do NOT belong here — + -- they live on the device rows (see gateway/sensor.installation_note). + note text, + + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE apartment ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + building_id uuid NOT NULL REFERENCES building(id), + + -- Human-readable unit designation ("2. OG links", "WE 04"). Unique + -- within a building so admin views and installer workflows have an + -- unambiguous handle. + label text NOT NULL, + + -- UVI-DAT-01, and NOT NULL on purpose: the in-house comparison + -- (Module 2) is kWh per m² — an apartment without an area cannot + -- participate in the product's core comparison at all, so it is + -- invalid data, not missing data. + living_area_m2 numeric(7,2) NOT NULL CHECK (living_area_m2 > 0), + + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (building_id, label) +); + +-- Application-level user profile. Identity — email address, magic-link +-- issuance, session state — is owned entirely by Supabase Auth (GoTrue) +-- in the auth schema (UVI-NFR-08: passwordless only). This table stores +-- only what the application adds on top. The PK IS the auth.users id +-- (same uuid, no separate surrogate) so that auth.uid() in RLS policies +-- joins directly without a mapping table. +CREATE TABLE app_user ( + id uuid PRIMARY KEY, -- = auth.users.id + role user_role NOT NULL DEFAULT 'tenant', + display_name text, + created_at timestamptz NOT NULL DEFAULT now() +); + +-- Tenancy periods (UVI-DAT-04). This table is the legal heart of the +-- data model, for two reasons: +-- +-- 1. Mieterwechsel correctness: § 6a comparisons to the previous month / +-- previous-year month are only permitted against the SAME tenant's +-- consumption. The dashboard decides whether to render a comparison +-- by checking that both months fall inside one occupancy row. Without +-- periods, a new tenant would be shown (and judged against) their +-- predecessor's behavior. +-- +-- 2. Privacy scope: a tenant may only ever see measurements taken during +-- their own tenancy (UVI-NFR-03). The RLS sketch at the bottom of this +-- file enforces that by joining measurement timestamps against this +-- table's date range — history before move-in is structurally +-- invisible, not just filtered in application code. +-- +-- valid_to IS NULL means "current tenant". The exclusion constraint makes +-- overlapping tenancies for one apartment a constraint violation rather +-- than a bug class: the database itself guarantees that any timestamp +-- maps to at most one responsible tenant. +CREATE TABLE occupancy ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + apartment_id uuid NOT NULL REFERENCES apartment(id), + user_id uuid NOT NULL REFERENCES app_user(id), + valid_from date NOT NULL, + valid_to date, + created_at timestamptz NOT NULL DEFAULT now(), + CHECK (valid_to IS NULL OR valid_to > valid_from), + EXCLUDE USING gist ( + apartment_id WITH =, + daterange(valid_from, COALESCE(valid_to, 'infinity'::date), '[)') WITH && + ) +); + + +-- =========================================================================== +-- 2. DEVICES & INGEST +-- +-- The radio world: gateways listen, meters broadcast, frames arrive. +-- Design stance: wM-Bus at 868 MHz is a BROADCAST medium. A gateway does +-- not "own" the sensors it hears — two gateways in one building may both +-- receive the same meter, and every gateway will receive meters that are +-- not ours (neighbors, unprovisioned devices). The schema therefore: +-- * ties gateways to buildings (where they are installed), never to +-- sensors; +-- * records which gateway heard a frame on the frame itself, because +-- that is a fact about the reception event, not about the meter; +-- * accepts and stores frames from unknown senders instead of +-- dropping them at the door. +-- =========================================================================== + +CREATE TABLE gateway ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + + -- IMEI identifies the cellular modem and is unique per device, but it + -- is a hardware serial, not an identity: a defective gateway swapped + -- in the field is, to the business, "the same listening post" with a + -- new IMEI. A surrogate PK keeps history intact across swaps; the + -- unique index still supports lookup-by-IMEI at ingest time. + imei varchar(15) NOT NULL UNIQUE, + imsi varchar(15), -- SIM identity; changes with + -- provider swaps, so informative + -- only, never a key + + building_id uuid NOT NULL REFERENCES building(id), + + label text, -- short admin handle, "GW Keller" + + -- Free-text installer note answering "where exactly is this thing?": + -- e.g. "mounted above the first ceiling panel on level 1". Kept per + -- device (not on a shared location entity) so that editing one + -- device's note can never silently rewrite another's. + installation_note text, + + created_at timestamptz NOT NULL DEFAULT now() +); + +-- A device profile: one row per (manufacturer, model) of meter we know +-- how to decode. Exists so that parsing knowledge attaches to the KIND +-- of device rather than to each physical unit — provisioning meter #500 +-- of a known model requires zero new parser configuration. This is the +-- schema-level expression of the product strategy "works with any open +-- OMS sensor": supporting a new vendor means inserting rows here and in +-- value_parser, not shipping code. +CREATE TABLE device_type ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + manufacturer text NOT NULL, -- OMS M-field, e.g. 'QDS' + model text NOT NULL, + medium text, -- OMS medium byte, informative + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (manufacturer, model) +); + +-- Extraction rules: how to pull ONE value out of a decoded telegram of a +-- given device type. A telegram routinely carries several values we care +-- about (energy total, volume, temperatures, error register), hence +-- 1 device_type : N parsers. Each parser declares: +-- * where the value sits (json_path into the decoded telegram), +-- * how to normalize it (parser_instructions: scaling, offsets), +-- * what it MEANS (kind) and in what unit it is expressed. +-- The kind declared here is what stamps every measurement row with its +-- heating/hot-water semantics — the parser is the single place where +-- raw vendor bytes acquire domain meaning. +-- +-- unit is plain text ('kWh', 'm3', '°C') rather than a lookup table: +-- a three-row table adds a join and an id without adding information. +-- Reintroduce a unit table only if units ever need behavior or metadata +-- (conversion factors, display localization). +CREATE TABLE value_parser ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + device_type_id uuid NOT NULL REFERENCES device_type(id), + json_path text NOT NULL, + parser_instructions jsonb, + kind measurement_kind NOT NULL, + unit text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (device_type_id, kind, json_path) +); + +CREATE TABLE sensor ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + + -- The meter's own identity as broadcast in its telegrams (OMS + -- secondary address / serial). This is the value ingest matches on + -- to attribute an incoming frame to a known sensor. + oms_id text NOT NULL UNIQUE, + + device_type_id uuid NOT NULL REFERENCES device_type(id), + + -- The device-to-apartment assignment (UVI-FUN-02). Direction matters: + -- an apartment has MANY devices (a heat cost allocator per radiator + -- plus a hot-water meter is the normal case), a device sits in at + -- most one apartment. NULLable by design — in the pilot this + -- assignment is performed manually in the database after physical + -- installation, so a sensor legitimately exists in a provisioned- + -- but-unassigned state. Its measurements are stored regardless and + -- become tenant-visible once the assignment is made. + apartment_id uuid REFERENCES apartment(id), + + -- AES-128 key for OMS telegram decryption. Plaintext here is a + -- pilot-grade decision to keep moving; before production, move key + -- material to a dedicated mechanism (pgsodium / Vault) — the column + -- is the interface, the storage hardening is an ops task. + decrypt_key text, + + installed_at date, + + -- Same rationale as gateway.installation_note: "which radiator, + -- which room, how to reach it" is per-device installer knowledge. + installation_note text, + + created_at timestamptz NOT NULL DEFAULT now() +); + +-- Note what sensor deliberately does NOT have: a gateway reference (the +-- radio is broadcast; reception routing lives on received_payload) and a +-- location/building reference (derived via apartment -> building; a +-- second, independent path would allow the two to contradict each other). + +-- Raw reception log: every frame any of our gateways heard, verbatim. +-- This table exists for auditability and reprocessing — if a parser had +-- a bug, or a new device_type is added for meters we were already +-- hearing, history can be re-parsed from here. Storage is the price of +-- never losing data to a software mistake. +CREATE TABLE received_payload ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + + gateway_id uuid NOT NULL REFERENCES gateway(id), + + -- Attribution to a known sensor happens at parse time and is + -- NULLABLE: on a shared radio band we routinely receive frames from + -- meters that are not ours. Those are stored (they may become ours + -- — e.g. a meter provisioned after installation) and flagged via + -- parse_status rather than rejected at ingest. + sensor_id uuid REFERENCES sensor(id), + + -- Both ends of the transport hop. sent = gateway clock at upload, + -- received = server clock at arrival; their divergence is the + -- cheapest possible gateway-health signal (clock drift, buffering + -- backlog after connectivity loss). + timestamp_sent timestamptz, + timestamp_received timestamptz NOT NULL DEFAULT now(), + + raw_payload jsonb NOT NULL, + + -- Ingest pipeline state machine. 'unknown_sensor' is a normal, + -- expected state, not an error — see attribution note above. + parse_status text NOT NULL DEFAULT 'pending' + CHECK (parse_status IN ('pending','parsed','unknown_sensor','error')), + + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX ON received_payload (sensor_id, timestamp_received DESC); +-- Partial index: the parser worker's queue query ("give me unprocessed +-- frames") stays fast no matter how large the historical log grows. +CREATE INDEX ON received_payload (parse_status) + WHERE parse_status IN ('pending','error'); + +-- Raw-frame retention is an open decision. When made, implement it as a +-- scheduled purge (or convert this table to a hypertable and attach a +-- Timescale retention policy) — do NOT cascade-delete from measurement; +-- measurement.raw_payload_id is nullable precisely so lineage can be +-- severed by retention without touching the measurements themselves. + + +-- =========================================================================== +-- 3. MEASUREMENT (time series) +-- +-- The product's ground truth: one row per (meter, kind, instant) reading. +-- Everything the UVI shows is computed from these rows at query time — +-- no persisted aggregates (UVI-DAT-06). That rule keeps a single source +-- of truth and makes bug fixes retroactive for free: correct a parser, +-- re-parse, and every dashboard number is correct. +-- =========================================================================== + +CREATE TABLE measurement ( + sensor_id uuid NOT NULL REFERENCES sensor(id), + + -- The METER's own reading time, extracted from the telegram — not + -- the reception time. wM-Bus meters repeat frames and gateways + -- buffer during connectivity loss, so arrival time can trail reading + -- time by minutes to days; all consumption math (monthly windows, + -- year-over-year) must bind to when the meter measured. + measured_at timestamptz NOT NULL, + + kind measurement_kind NOT NULL, + + -- NOT NULL: a reading without a value is not a reading. Missing data + -- is represented by the ABSENCE of rows for a period, and the UI + -- renders that as "not available" (UVI-FUN-15) — never by storing 0 + -- or NULL, both of which would poison aggregations silently. + value numeric NOT NULL, + + -- Denormalized from value_parser at write time so a row is + -- self-describing even if parser configuration later changes. + unit text NOT NULL, + + -- Lineage to the exact raw frame this value was parsed from. + -- Nullable so raw-frame retention can delete old frames without + -- invalidating measurements (see received_payload). + raw_payload_id uuid REFERENCES received_payload(id), + + created_at timestamptz NOT NULL DEFAULT now(), + + -- Composite natural key, doing three jobs at once: + -- 1. TimescaleDB requires the partitioning column in any unique + -- constraint — a bare uuid PK is incompatible with a hypertable. + -- 2. Dedup: meters broadcast the same reading repeatedly by design. + -- Ingest uses INSERT .. ON CONFLICT DO NOTHING against this key, + -- so repeats collapse to no-ops instead of duplicate rows that + -- would double-count consumption. + -- 3. It IS the dominant access path (this sensor, this kind, this + -- time range), so the PK index serves the dashboard queries and + -- no secondary index is needed for the pilot. + PRIMARY KEY (sensor_id, kind, measured_at) +); + +-- Monthly chunks match the product's natural query grain (the UVI is a +-- monthly report over a 13-month window). +SELECT create_hypertable('measurement', by_range('measured_at', INTERVAL '1 month')); + +-- If the 13-month dashboard aggregations ever get slow at scale, the +-- sanctioned escape hatch is a Timescale continuous aggregate: it is +-- machine-maintained derived state over this table, which arguably +-- honors the intent of "no persisted aggregates" (no hand-maintained +-- second truth). Adopt it when measured, not preemptively. + + +-- =========================================================================== +-- 4. REFERENCE DATA +-- +-- External regulatory and environmental facts the UVI computes against +-- (UVI-DAT-07..11). Two shared design rules: +-- +-- * Versioning by validity range wherever the source can change +-- (CO2 factors, prices): the question "what number was shown to the +-- tenant in March?" must remain answerable after an update, both for +-- tenant trust and for the § 7 Kürzungsrecht audit scenario. +-- * source columns record provenance, because every one of these +-- numbers is a claim about a legal document or an external dataset. +-- =========================================================================== + +-- Bandtacho class boundaries (Module 3). GEG Anlage 10 defines ANNUAL +-- kWh/m² class limits; the UBA-Leitfaden (Tabelle 1) splits them into +-- per-calendar-month thresholds so a single month's consumption can be +-- classified. Hence one row per (class, month): 9 classes × 12 months. +CREATE TABLE efficiency_class_threshold ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + class text NOT NULL CHECK (class IN + ('A+','A','B','C','D','E','F','G','H')), + month smallint NOT NULL CHECK (month BETWEEN 1 AND 12), + -- Upper bound of the class for that month. 'H' is open-ended in the + -- source; store a sentinel high value rather than NULL so that + -- classification is a simple "first class whose bound >= value" + -- scan with no NULL special-casing. + max_kwh_per_m2 numeric(8,3) NOT NULL, + source text NOT NULL DEFAULT 'UBA CC 69/2021 Tab. 1 / GEG Anl. 10', + UNIQUE (class, month) +); + +-- CO2 factors per fuel (Module 6), GEG Anlage 9. Versioned because the +-- annex gets amended; emissions shown for a given month use the factor +-- valid in that month. +CREATE TABLE co2_emission_factor ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + fuel_type fuel_type NOT NULL, + g_co2_per_kwh numeric(8,2) NOT NULL, + valid_from date NOT NULL, + valid_to date, -- NULL = currently valid + source text NOT NULL DEFAULT 'GEG Anlage 9', + UNIQUE (fuel_type, valid_from) +); + +-- Energy prices per fuel (Module 5: cost estimate = kWh × price). +-- Versioned for the same auditability reason, plus created_by: price +-- maintenance is a manual admin process (owner and cadence still an +-- open decision), and attributing each price row makes that process +-- accountable from day one regardless of how the decision lands. +CREATE TABLE energy_price ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + fuel_type fuel_type NOT NULL, + ct_per_kwh numeric(8,3) NOT NULL, + valid_from date NOT NULL, + valid_to date, + source text, + created_by uuid REFERENCES app_user(id), + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (fuel_type, valid_from) +); + +-- DWD stations we import heating-degree-day data for. Modeled as its own +-- entity (not columns on building) because many buildings share a +-- station and the HDD import job iterates stations, not buildings. +CREATE TABLE weather_station ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + dwd_id text NOT NULL UNIQUE, -- DWD's station identifier + name text NOT NULL, + lat numeric(9,6), + lng numeric(9,6), + created_at timestamptz NOT NULL DEFAULT now() +); + +ALTER TABLE building + ADD CONSTRAINT building_weather_station_fk + FOREIGN KEY (weather_station_id) REFERENCES weather_station(id); + +-- Monthly heating degree days per station (VDI 3807, DWD open data). +-- Consumed ONLY as display context — the Module 1 footnote "this month +-- was N% colder/warmer than the same month last year". Actual weather +-- normalization of consumption values is explicitly out of scope for +-- the UVI (it belongs to annual billing) and measurement values are +-- never adjusted by this data. +-- Operational note: a monthly DWD import fills this table; if a month +-- is missing, the footnote renders "not available" (UVI-FUN-15). The +-- import's timing sits inside the NFR-05 chain (data visible by the +-- 5th working day). +CREATE TABLE heating_degree_days ( + station_id uuid NOT NULL REFERENCES weather_station(id), + year smallint NOT NULL, + month smallint NOT NULL CHECK (month BETWEEN 1 AND 12), + hdd numeric(7,1) NOT NULL, + PRIMARY KEY (station_id, year, month) +); + +-- Savings tips library (Module 4, UVI-FUN-11). A table, not content in +-- code, because tips are editorial material admins curate. The category +-- drives seasonal selection (UVI-FUN-14): heating tips during the +-- heating period, hot-water tips outside it. active supports retiring +-- tips without deleting them (a tip already shown in a past month +-- should remain resolvable). +CREATE TABLE saving_tip ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + category saving_tip_category NOT NULL, + title text NOT NULL, + body text NOT NULL, + link_url text, -- deep link to advisory content + -- (verbraucherzentrale, co2online) + active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now() +); + + +-- =========================================================================== +-- 5. TENANT ISOLATION (UVI-NFR-03) — enforcement sketch +-- +-- Isolation is enforced IN the database via row-level security, not only +-- in the Go backend: with Supabase Auth issuing JWTs, auth.uid() is +-- available inside policies, and a backend bug can then never leak a +-- neighbor's data — the worst case becomes an empty result. +-- +-- Example policy shape for measurement (enable analogously per table): +-- +-- ALTER TABLE measurement ENABLE ROW LEVEL SECURITY; +-- CREATE POLICY tenant_reads_own ON measurement FOR SELECT +-- USING ( +-- sensor_id IN ( +-- SELECT s.id +-- FROM sensor s +-- JOIN occupancy o ON o.apartment_id = s.apartment_id +-- WHERE o.user_id = auth.uid() +-- AND daterange(o.valid_from, +-- COALESCE(o.valid_to, 'infinity'::date), +-- '[)') @> measured_at::date +-- ) +-- ); +-- +-- The occupancy-window predicate is the important part: a tenant sees +-- only measurements from their own tenancy period, so pre-move-in +-- history is structurally invisible (the Mieterwechsel rule enforced at +-- the storage layer). Admins get a separate permissive policy scoped to +-- role = 'admin'; admin reads are additionally logged at the application +-- layer (UVI-NFR-07). +-- +-- Deliberately NOT in the schema: +-- * The small-building anonymity rule (UVI-FUN-09: with ≤ 4 units, +-- show the mean of the three thriftiest households instead of the +-- spectrum) — that is presentation logic in the in-house comparison +-- query, driven by count(apartment) per building at query time. +-- * Heating-period vs. off-season behavior (UVI-FUN-14) — pure display +-- logic; the data model is season-agnostic. +-- =========================================================================== \ No newline at end of file