-- ============================================================================
-- GebOS – Schema V1 (initial Supabase migration)
-- Target: Postgres 17 + TimescaleDB
--
-- Applied MANUALLY via `supabase db push` — see supabase/README.md. There is
-- deliberately no automated migration runner. The CLI records applied
-- migrations in supabase_migrations.schema_migrations, so re-running push is
-- safe; the file itself is NOT idempotent (CREATE TYPE has no IF NOT EXISTS).
--
-- Cluster-level bootstrap (Supabase schemas/roles, extensions requiring
-- shared_preload_libraries) lives in nix/supabase/init.sql, not here.
--
-- 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.
-- ===========================================================================


-- ===========================================================================
-- 6. INGEST ROLE & GRANTS
--
-- gebos_ingest is the MQTT ingester's login role — gebos-specific, NOT a
-- Supabase role. It bypasses RLS because at write time no end user is
-- authenticated; the trust boundary is the broker ACL ("who may publish to
-- which tenant's topic"), not the database. Created here rather than in
-- init.sql because its grants are coupled to the tables above, and because
-- init.sql only runs at first initdb while migrations always apply.
--
-- Its password is set out-of-band by the gebos-postgres-passwords oneshot
-- on db-host (sops secret: ingester_postgres_password).
-- ===========================================================================

DO $$ BEGIN
    CREATE ROLE gebos_ingest LOGIN BYPASSRLS;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;

GRANT USAGE ON SCHEMA public TO gebos_ingest;

-- Lookups the ingest/parse pipeline performs: gateway by IMEI, sensor by
-- oms_id, parser configuration by device type.
GRANT SELECT ON gateway, sensor, device_type, value_parser TO gebos_ingest;

-- Frame log: INSERT on arrival; SELECT + UPDATE for the parse worker's
-- queue query and its parse_status transitions.
GRANT SELECT, INSERT, UPDATE ON received_payload TO gebos_ingest;

-- Measurements are append-only from the ingester's point of view — dedup is
-- INSERT .. ON CONFLICT DO NOTHING against the composite PK, which needs no
-- UPDATE privilege.
GRANT SELECT, INSERT ON measurement TO gebos_ingest;