From f4b5926e1fe4754572ee9505c689e29b49bfd539 Mon Sep 17 00:00:00 2001 From: Lars Nolden Date: Fri, 10 Jul 2026 15:59:03 +0200 Subject: [PATCH] finish local dev setup --- frontend/src/supabase.ts | 2 +- nix/dev/process-compose.nix | 200 ++++++++++++++++- supabase/.temp/cli-latest | 1 + supabase/README.md | 39 +++- supabase/seed.sql | 412 ++++++++++++++++++++++++++++++++++++ 5 files changed, 638 insertions(+), 16 deletions(-) create mode 100644 supabase/.temp/cli-latest create mode 100644 supabase/seed.sql diff --git a/frontend/src/supabase.ts b/frontend/src/supabase.ts index d1ab1fe..dd1ddc6 100644 --- a/frontend/src/supabase.ts +++ b/frontend/src/supabase.ts @@ -7,7 +7,7 @@ import { createClient } from "@supabase/supabase-js"; // and VITE_SUPABASE_ANON_KEY set at `nix build .#frontend` time. const DEV_SUPABASE_URL = "http://127.0.0.1:8000"; const DEV_SUPABASE_ANON_KEY = - "dev-anon-key-placeholder-regenerate-with-supabase-self-host-script"; + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlLWRldiIsImlhdCI6MTc1MTMyODAwMCwiZXhwIjoyMDgyNzU4NDAwfQ.M6U-nZq9dySoUJEfWHsiB0qabhATWobGaTM1LF86HHU"; const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL ?? diff --git a/nix/dev/process-compose.nix b/nix/dev/process-compose.nix index 43f38fe..881f37c 100644 --- a/nix/dev/process-compose.nix +++ b/nix/dev/process-compose.nix @@ -15,17 +15,25 @@ let pgPassword = "dev"; pgHost = "127.0.0.1"; pgPort = 5432; + # Unix socket dir, bind-mounted into the compose network's `db` proxy + # (see composeDevOverride below). Relative to the process-compose cwd, + # like services-flake's ./data/db data dir. + pgSocketDir = "./data/db-socket"; mqttHost = "127.0.0.1"; mqttPort = 1883; - # Pre-generated dev JWT pair, signed with `jwt_secret = "dev-jwt-secret-32chars-minimum-xxxxx"`. - # Anyone with this token can read/write the local dev stack only — not prod. - jwtSecret = "dev-jwt-secret-32chars-minimum-xxxxx"; - anonKey = "dev-anon-key-placeholder-regenerate-with-supabase-self-host-script"; - serviceRoleKey = "dev-service-role-key-placeholder-regenerate-with-supabase-self-host-script"; + # Pre-generated dev JWT pair, signed HS256 with jwtSecret. Regenerate + # with any JWT tool if jwtSecret ever changes; payload is + # {"role":"","iss":"supabase-dev","iat":1751328000,"exp":2082758400}. + # Anyone with these tokens can read/write the local dev stack only — not prod. + jwtSecret = "dev-jwt-secret-32chars-minimum-xxxxx"; + anonKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlLWRldiIsImlhdCI6MTc1MTMyODAwMCwiZXhwIjoyMDgyNzU4NDAwfQ.M6U-nZq9dySoUJEfWHsiB0qabhATWobGaTM1LF86HHU"; + serviceRoleKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UtZGV2IiwiaWF0IjoxNzUxMzI4MDAwLCJleHAiOjIwODI3NTg0MDB9.dNeWNshQ9e1pIjxTDYseURlbSPPArVIKa9OuTRZyIH8"; supabaseUrl = "http://127.0.0.1:8000"; + + frontendPort = 5173; # vite default }; ingesterEnv = { @@ -45,6 +53,133 @@ let listener ${toString dev.mqttPort} ${dev.mqttHost} allow_anonymous true ''; + + # --------------------------------------------------------------------------- + # Supabase — the vendored prod compose bundle (nix/supabase/), unchanged, + # plus this dev overlay. + # + # The overlay's job is wiring the containers to the services-flake postgres. + # Bridge→host TCP is a dead end on a typical NixOS dev machine (the host + # firewall's INPUT chain rejects it), so instead a socat `db` service + # forwards TCP 5432 inside the compose network onto the host postgres unix + # socket, bind-mounted from pgSocketDir. Conveniently that restores the + # upstream compose convention of a service literally named `db`, so the + # vendored file works with just POSTGRES_HOST=db. Socket connections match + # pg_hba's `local … trust`, so the dev password is never actually checked. + # --------------------------------------------------------------------------- + composeDevOverride = pkgs.writeText "docker-compose.dev-override.yml" '' + services: + db: + container_name: supabase-dev-db + # Unpinned minor: dev-only TCP→unix-socket shim, nothing depends on + # socat internals. + image: alpine/socat:1.8.0.3 + restart: unless-stopped + command: TCP-LISTEN:${toString dev.pgPort},fork,reuseaddr UNIX-CONNECT:/host-postgres/.s.PGSQL.${toString dev.pgPort} + volumes: + - ''${PG_SOCKET_DIR:?set by gebos-dev-supabase-compose}:/host-postgres + + studio: + environment: + SUPABASE_PUBLIC_URL: ${dev.supabaseUrl} + + auth: + environment: + API_EXTERNAL_URL: ${dev.supabaseUrl} + GOTRUE_SITE_URL: http://localhost:${toString dev.frontendPort} + GOTRUE_JWT_ISSUER: ${dev.supabaseUrl} + # No SMTP locally, so magic-link invites can't send. Allow plain + # self-signup with auto-confirm instead; prod keeps signup disabled. + GOTRUE_DISABLE_SIGNUP: "false" + GOTRUE_MAILER_AUTOCONFIRM: "true" + ''; + + # One wrapper for `up`/`down` so both resolve the socket-dir bind mount the + # same way (compose needs an absolute path). + supabaseCompose = pkgs.writeShellApplication { + name = "gebos-dev-supabase-compose"; + runtimeInputs = [ pkgs.docker pkgs.coreutils ]; + text = '' + PG_SOCKET_DIR="$(readlink -f ${dev.pgSocketDir})" + export PG_SOCKET_DIR + exec docker compose \ + --project-name gebos-supabase-dev \ + -f nix/supabase/docker-compose.yml \ + -f ${composeDevOverride} \ + "$@" + ''; + }; + + supabaseEnv = { + POSTGRES_HOST = "db"; # the socat proxy above + POSTGRES_PORT = toString dev.pgPort; + POSTGRES_DB = dev.pgDB; + POSTGRES_PASSWORD = dev.pgPassword; # unchecked — see overlay comment + JWT_SECRET = dev.jwtSecret; + ANON_KEY = dev.anonKey; + SERVICE_ROLE_KEY = dev.serviceRoleKey; + STUDIO_BIND = "127.0.0.1"; + }; + + # Everything prod splits across nix/supabase/init.sql (first initdb), + # the gebos-postgres-passwords oneshot, and a manual `supabase db push`, + # collapsed into one idempotent script that runs on every stack start. + dbMigrate = pkgs.writeShellApplication { + name = "gebos-dev-db-migrate"; + runtimeInputs = [ pkgs.supabase-cli pkgs.postgresql_17 ]; + text = '' + export PGHOST=${dev.pgHost} PGPORT=${toString dev.pgPort} PGDATABASE=${dev.pgDB} PGSSLMODE=disable + + # 1. Cluster bootstrap: Supabase roles/schemas the compose services + # expect (mirrors nix/supabase/init.sql, but idempotent because the + # dev data dir may predate this script). Password 'dev' everywhere. + psql -v ON_ERROR_STOP=1 --quiet <<'SQL' + DO $$ BEGIN CREATE ROLE anon NOLOGIN NOINHERIT; EXCEPTION WHEN duplicate_object THEN NULL; END $$; + DO $$ BEGIN CREATE ROLE authenticated NOLOGIN NOINHERIT; EXCEPTION WHEN duplicate_object THEN NULL; END $$; + DO $$ BEGIN CREATE ROLE service_role NOLOGIN NOINHERIT BYPASSRLS; EXCEPTION WHEN duplicate_object THEN NULL; END $$; + DO $$ BEGIN CREATE ROLE authenticator LOGIN NOINHERIT; EXCEPTION WHEN duplicate_object THEN NULL; END $$; + GRANT anon, authenticated, service_role TO authenticator; + DO $$ BEGIN CREATE ROLE supabase_admin LOGIN CREATEROLE CREATEDB BYPASSRLS; EXCEPTION WHEN duplicate_object THEN NULL; END $$; + DO $$ BEGIN CREATE ROLE supabase_auth_admin LOGIN CREATEROLE; EXCEPTION WHEN duplicate_object THEN NULL; END $$; + -- On db-host the NixOS postgresql module creates the `postgres` + -- superuser; the services-flake cluster's superuser is the OS user + -- instead, and GoTrue's own migrations GRANT to `postgres` by name. + DO $$ BEGIN CREATE ROLE postgres LOGIN SUPERUSER; EXCEPTION WHEN duplicate_object THEN NULL; END $$; + + ALTER ROLE postgres PASSWORD 'dev'; + ALTER ROLE supabase_admin SUPERUSER PASSWORD 'dev'; + ALTER ROLE supabase_auth_admin PASSWORD 'dev'; + ALTER ROLE authenticator PASSWORD 'dev'; + + -- GoTrue owns `auth` and runs its own migrations there on startup. Its + -- migrator creates tables in the role's default schema, so without the + -- search_path it lands in `public` and dies on PG15+'s revoked CREATE + -- (upstream's supabase/postgres image sets the same role search_path). + CREATE SCHEMA IF NOT EXISTS auth; + ALTER SCHEMA auth OWNER TO supabase_auth_admin; + ALTER ROLE supabase_auth_admin SET search_path TO auth, extensions; + + -- The upstream supabase/postgres image keeps shared extensions in an + -- `extensions` schema on the search path; the services assume that. + CREATE SCHEMA IF NOT EXISTS extensions; + CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA extensions; + CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA extensions; + GRANT USAGE ON SCHEMA extensions TO anon, authenticated, service_role, authenticator, supabase_auth_admin; + ALTER DATABASE gebos SET search_path TO "$user", public, extensions; + SQL + + # 2. Schema migrations — same mechanism as prod (supabase/README.md), + # non-interactive, as the local superuser ($USER). + supabase db push --yes --db-url "postgres://$USER@${dev.pgHost}:${toString dev.pgPort}/${dev.pgDB}" + + # 3. The schema-v1 migration created gebos_ingest; in prod its password + # comes from the gebos-postgres-passwords oneshot. + psql -v ON_ERROR_STOP=1 --quiet -c "ALTER ROLE ${dev.pgUser} PASSWORD '${dev.pgPassword}'" + + # 4. Fixture data — idempotent, dev/tests only (supabase/README.md). + psql -v ON_ERROR_STOP=1 --quiet -f supabase/seed.sql + ''; + }; in { imports = [ services-flake.processComposeModules.default ]; @@ -53,7 +188,17 @@ in postgres."db" = { enable = true; port = dev.pgPort; + # TCP stays loopback-only; the compose containers come in through the + # unix socket instead (see composeDevOverride). + socketDir = dev.pgSocketDir; initialDatabases = [{ name = dev.pgDB; }]; + # The schema-v1 migration calls create_hypertable; without the + # extension `supabase db push` rolls back entirely on the dev db. + # The apache variant lacks only TSL features (compression, CAggs + # policies) — hypertables are enough here, and it keeps dev free + # of unfree packages. + extensions = extensions: [ extensions.timescaledb-apache ]; + settings.shared_preload_libraries = "timescaledb"; }; }; @@ -67,20 +212,55 @@ in }; }; + # Oneshot: bootstrap roles/schemas, apply migrations, seed. Idempotent, + # so it simply re-runs on every stack start. + db-migrate = { + command = "${dbMigrate}/bin/gebos-dev-db-migrate"; + depends_on."db".condition = "process_healthy"; + }; + + # Auth (GoTrue) + REST (PostgREST) + Studio + postgres-meta behind Kong + # on ${dev.supabaseUrl} — the vendored prod bundle, see composeDevOverride. + # Needs a running docker daemon on the dev machine. + supabase = { + command = "${supabaseCompose}/bin/gebos-dev-supabase-compose up --remove-orphans"; + environment = supabaseEnv; + # `restart: unless-stopped` containers outlive a hard-killed + # process-compose; an explicit `down` covers the clean path. + shutdown.command = "${supabaseCompose}/bin/gebos-dev-supabase-compose down --remove-orphans"; + depends_on = { + # GoTrue's own migrations need the auth schema + roles from db-migrate. + "db".condition = "process_healthy"; + "db-migrate".condition = "process_completed_successfully"; + }; + readiness_probe = { + # Kong → key-auth → GoTrue → Postgres: healthy = the whole API path works. + exec.command = "${pkgs.curl}/bin/curl -fsS -H 'apikey: ${dev.anonKey}' ${dev.supabaseUrl}/auth/v1/health"; + initial_delay_seconds = 5; + period_seconds = 5; + failure_threshold = 60; # first start pulls the images + }; + }; + ingester = { - command = "${pkgs.go}/bin/go run ./ingester/cmd/gebos-ingester"; + # -C: the go module root is ingester/, not the repo root. + command = "${pkgs.go}/bin/go run -C ingester ./cmd/gebos-ingester"; environment = ingesterEnv; depends_on = { - "db".condition = "process_healthy"; + # db-migrate (implies db healthy) creates the gebos_ingest role and + # the telemetry tables. + "db-migrate".condition = "process_completed_successfully"; "broker".condition = "process_started"; }; }; + frontend = { command = "${pkgs.nodejs_24}/bin/npm --prefix frontend install && ${pkgs.nodejs_24}/bin/npm --prefix frontend run dev"; environment = frontendEnv; }; - # TODO: supabase compose (point POSTGRES_HOST at the services-flake db) - # TODO: kong (use the vendored kong.yml from nix/supabase/) - # TODO: caddy (mirror prod routes locally, no TLS) + + # No caddy locally: prod's Caddy config is generated by the gebos-caddy + # NixOS module, so a hand-mirrored dev caddyfile wouldn't test it — the + # frontend talks to Kong directly (dev.supabaseUrl). }; } diff --git a/supabase/.temp/cli-latest b/supabase/.temp/cli-latest new file mode 100644 index 0000000..8403edf --- /dev/null +++ b/supabase/.temp/cli-latest @@ -0,0 +1 @@ +v2.109.1 \ No newline at end of file diff --git a/supabase/README.md b/supabase/README.md index 6c7c461..b804478 100644 --- a/supabase/README.md +++ b/supabase/README.md @@ -44,12 +44,41 @@ Write plain SQL. Conventions: ## Local dev -`nix run .#dev` starts a plain Postgres via process-compose; apply migrations -to it the same way: +`nix run .#dev` applies everything automatically: its `db-migrate` process +bootstraps the Supabase roles/schemas, runs `supabase db push`, and loads +`seed.sql` on every start (all idempotent — see +`nix/dev/process-compose.nix`). To re-apply by hand against the running dev +Postgres, do it as the local superuser (your OS user — `gebos_ingest` is only +*created* by the migration and has no DDL rights). The CLI ignores `sslmode` +in the URL, so TLS must be disabled via the environment: ```sh -supabase db push --db-url "postgres://gebos_ingest:dev@127.0.0.1:5432/gebos" +PGSSLMODE=disable supabase db push --db-url "postgres://$USER@127.0.0.1:5432/gebos" ``` -(Note: the dev database has no TimescaleDB unless the process-compose postgres -gains the extension — `create_hypertable` calls will fail there until then.) +(The dev postgres ships TimescaleDB — the Apache edition, which covers +hypertables; TSL-only features like compression are absent locally.) + +## Seed / fixture data (dev and tests only) + +`seed.sql` fills the local database with a self-consistent fixture world: +2 buildings (gas / district heating), 8 apartments, tenants incl. a +Mieterwechsel and a vacancy, gateways, sensors (incl. one unassigned), ~13 +months of daily cumulative measurements with realistic seasonality, and +approximate reference data (thresholds, CO2 factors, prices, HDD). The header +comment in the file documents every scenario and the fixed-UUID namespaces. + +Apply it after the migrations, with a superuser (the seed writes tables the +`gebos_ingest` role may not): + +```sh +psql -h 127.0.0.1 -p 5432 -d gebos -f supabase/seed.sql +``` + +It is idempotent (fixed UUIDs + `ON CONFLICT DO NOTHING`, deterministic +measurement generation) — re-running on a later day only appends the new +days of measurements. + +**Never apply it to prod**: the tenants and devices are fake, and the +regulatory numbers are development approximations, not verified values from +the legal sources. Verified reference data belongs in its own migration. diff --git a/supabase/seed.sql b/supabase/seed.sql new file mode 100644 index 0000000..5fb7725 --- /dev/null +++ b/supabase/seed.sql @@ -0,0 +1,412 @@ +-- ============================================================================ +-- GebOS – Dev/test fixture data (supabase/seed.sql) +-- +-- DEV ONLY. Never apply to prod: it contains fake tenants and fake devices, +-- and the regulatory reference numbers (efficiency class thresholds, CO2 +-- factors, prices, HDD) are plausible APPROXIMATIONS for development, not +-- verified values from the legal sources. Real reference data ships as its +-- own migration when the numbers have been verified. +-- +-- Apply with psql against the local process-compose stack (see +-- supabase/README.md), AFTER the schema migration: +-- +-- psql -h 127.0.0.1 -p 5432 -d gebos -f supabase/seed.sql +-- +-- Idempotent by construction: fixture rows carry fixed UUIDs (so tests can +-- reference them) and every INSERT ends in ON CONFLICT DO NOTHING. The +-- measurement series is anchored at a fixed start date (2025-06-01) and +-- generated deterministically (md5-based jitter, no random()), so re-running +-- on a later day recomputes identical values for existing days (skipped via +-- the composite PK) and appends only the new days — cumulative meter totals +-- stay monotonic across re-runs. +-- +-- UUID namespaces (first group encodes the table, last digits the row): +-- dd... weather_station b0... building a0... apartment +-- e0... app_user 0c... occupancy 9e... gateway +-- d1... device_type f0... value_parser 5e... sensor +-- 7a... received_payload 5a... saving_tip +-- +-- Scenario coverage (what each fixture exists to exercise): +-- * Building 1 (Berlin, gas, 5 apartments) — normal in-house spectrum. +-- * Building 2 (Potsdam, district heating, 3 apartments) — small-building +-- anonymity rule (UVI-FUN-09), second fuel type, and NO weather station +-- (weather footnote must render "not available", UVI-FUN-15). +-- * Apartment 3 — Mieterwechsel on 2026-03-01: comparisons across that +-- date must be suppressed (both months must fall in ONE occupancy row). +-- * Apartment 5 — vacant since 2026-05-01 (occupancy ended, none current). +-- * Apartment 4 — real heat meter (energy + flow/return temperatures + +-- error flags), not a heat cost allocator. +-- * Apartment 1 — hot water metered as ENERGY; all others as VOLUME +-- (both measurement kinds must work, see measurement_kind comment). +-- * Sensor 5e..17 — provisioned but unassigned (apartment_id NULL) with +-- measurements: data stored, tenant-invisible until assigned. +-- * received_payload — one row per parse_status, incl. an unknown sender. +-- * Consumption profiles span thrifty (~45 kWh/m²a) to heavy (~190), +-- so the Bandtacho classes and the in-house comparison spread out. +-- +-- app_user note: in prod, app_user.id must equal auth.users.id (GoTrue). +-- The dev stack has no GoTrue yet, so these users have no auth identity — +-- when it lands, create auth users with these fixed UUIDs to log in as them. +-- ============================================================================ + +BEGIN; + +-- --------------------------------------------------------------------------- +-- Reference: weather station (assigned to building 1 only — building 2 +-- deliberately has none) +-- --------------------------------------------------------------------------- + +INSERT INTO weather_station (id, dwd_id, name, lat, lng) VALUES + ('dd000000-0000-0000-0000-000000000001', '00433', 'Berlin-Tempelhof', 52.467500, 13.402100) +ON CONFLICT DO NOTHING; + +-- --------------------------------------------------------------------------- +-- Buildings & apartments +-- --------------------------------------------------------------------------- + +INSERT INTO building (id, street, house_number, postal_code, city, lat, lng, + fuel_type, weather_station_id, note) VALUES + ('b0000000-0000-0000-0000-000000000001', + 'Fichtestraße', '12', '10967', 'Berlin', 52.489200, 13.417100, + 'natural_gas', 'dd000000-0000-0000-0000-000000000001', + 'Schlüsselkasten am Hoftor, Code 4711. Heizungskeller: Zugang über Hof.'), + ('b0000000-0000-0000-0000-000000000002', + 'Gartenweg', '3', '14482', 'Potsdam', 52.390800, 13.115600, + 'district_heating', NULL, + 'Kleine Anlage, 3 WE. Übergabestation im Keller rechts.') +ON CONFLICT DO NOTHING; + +INSERT INTO apartment (id, building_id, label, living_area_m2) VALUES + ('a0000000-0000-0000-0000-000000000001', 'b0000000-0000-0000-0000-000000000001', 'EG links', 54.00), + ('a0000000-0000-0000-0000-000000000002', 'b0000000-0000-0000-0000-000000000001', 'EG rechts', 72.00), + ('a0000000-0000-0000-0000-000000000003', 'b0000000-0000-0000-0000-000000000001', '1. OG links', 85.00), + ('a0000000-0000-0000-0000-000000000004', 'b0000000-0000-0000-0000-000000000001', '1. OG rechts', 96.00), + ('a0000000-0000-0000-0000-000000000005', 'b0000000-0000-0000-0000-000000000001', '2. OG', 110.00), + ('a0000000-0000-0000-0000-000000000006', 'b0000000-0000-0000-0000-000000000002', 'WE 01', 60.00), + ('a0000000-0000-0000-0000-000000000007', 'b0000000-0000-0000-0000-000000000002', 'WE 02', 75.00), + ('a0000000-0000-0000-0000-000000000008', 'b0000000-0000-0000-0000-000000000002', 'WE 03', 88.00) +ON CONFLICT DO NOTHING; + +-- --------------------------------------------------------------------------- +-- Users & occupancies +-- --------------------------------------------------------------------------- + +INSERT INTO app_user (id, role, display_name) VALUES + ('e0000000-0000-0000-0000-000000000001', 'admin', 'Anna Admin'), + ('e0000000-0000-0000-0000-000000000002', 'tenant', 'Max Mustermann'), + ('e0000000-0000-0000-0000-000000000003', 'tenant', 'Erika Beispiel'), + ('e0000000-0000-0000-0000-000000000004', 'tenant', 'Otto Vormieter'), + ('e0000000-0000-0000-0000-000000000005', 'tenant', 'Nina Neumieterin'), + ('e0000000-0000-0000-0000-000000000006', 'tenant', 'Karl Konstant'), + ('e0000000-0000-0000-0000-000000000007', 'tenant', 'Ferdinand Fortgezogen'), + ('e0000000-0000-0000-0000-000000000008', 'tenant', 'Gerda Gartenweg'), + ('e0000000-0000-0000-0000-000000000009', 'tenant', 'Hans Hofmann'), + ('e0000000-0000-0000-0000-00000000000a', 'tenant', 'Ines Iser') +ON CONFLICT DO NOTHING; + +INSERT INTO occupancy (id, apartment_id, user_id, valid_from, valid_to) VALUES + -- building 1 + ('0c000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', 'e0000000-0000-0000-0000-000000000002', '2023-09-01', NULL), + ('0c000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000002', 'e0000000-0000-0000-0000-000000000003', '2024-01-01', NULL), + -- Mieterwechsel in apartment 3: comparisons across 2026-03-01 are forbidden + ('0c000000-0000-0000-0000-000000000003', 'a0000000-0000-0000-0000-000000000003', 'e0000000-0000-0000-0000-000000000004', '2022-05-01', '2026-03-01'), + ('0c000000-0000-0000-0000-000000000004', 'a0000000-0000-0000-0000-000000000003', 'e0000000-0000-0000-0000-000000000005', '2026-03-01', NULL), + ('0c000000-0000-0000-0000-000000000005', 'a0000000-0000-0000-0000-000000000004', 'e0000000-0000-0000-0000-000000000006', '2021-11-01', NULL), + -- apartment 5 vacant since 2026-05-01 + ('0c000000-0000-0000-0000-000000000006', 'a0000000-0000-0000-0000-000000000005', 'e0000000-0000-0000-0000-000000000007', '2023-02-01', '2026-05-01'), + -- building 2 + ('0c000000-0000-0000-0000-000000000007', 'a0000000-0000-0000-0000-000000000006', 'e0000000-0000-0000-0000-000000000008', '2020-01-01', NULL), + ('0c000000-0000-0000-0000-000000000008', 'a0000000-0000-0000-0000-000000000007', 'e0000000-0000-0000-0000-000000000009', '2024-07-01', NULL), + ('0c000000-0000-0000-0000-000000000009', 'a0000000-0000-0000-0000-000000000008', 'e0000000-0000-0000-0000-00000000000a', '2023-03-15', NULL) +ON CONFLICT DO NOTHING; + +-- --------------------------------------------------------------------------- +-- Gateways (two in building 1: broadcast radio means overlapping reception) +-- --------------------------------------------------------------------------- + +INSERT INTO gateway (id, imei, imsi, building_id, label, installation_note) VALUES + ('9e000000-0000-0000-0000-000000000001', '861234050000011', '901405000000011', + 'b0000000-0000-0000-0000-000000000001', 'GW Keller', + 'Im Heizungskeller, über der Tür zum Anschlussraum.'), + ('9e000000-0000-0000-0000-000000000002', '861234050000012', '901405000000012', + 'b0000000-0000-0000-0000-000000000001', 'GW Dach', + 'Trockenboden, am Kaminschacht montiert.'), + ('9e000000-0000-0000-0000-000000000003', '861234050000013', '901405000000013', + 'b0000000-0000-0000-0000-000000000002', 'GW Keller', + 'Neben der Fernwärme-Übergabestation.') +ON CONFLICT DO NOTHING; + +-- --------------------------------------------------------------------------- +-- Device types & value parsers +-- --------------------------------------------------------------------------- + +INSERT INTO device_type (id, manufacturer, model, medium) VALUES + ('d1000000-0000-0000-0000-000000000001', 'QDS', 'Q caloric 5.5', '08'), -- heat cost allocator + ('d1000000-0000-0000-0000-000000000002', 'EFE', 'SensoStar U', '04'), -- heat meter + ('d1000000-0000-0000-0000-000000000003', 'EFE', 'SensoStar E', '06'), -- warm-water heat meter + ('d1000000-0000-0000-0000-000000000004', 'ZRI', 'Minomess', '06') -- warm-water volume meter +ON CONFLICT DO NOTHING; + +INSERT INTO value_parser (id, device_type_id, json_path, parser_instructions, kind, unit) VALUES + ('f0000000-0000-0000-0000-000000000001', 'd1000000-0000-0000-0000-000000000001', + '$.records[0].value', '{"scale": 1}', 'heating_energy', 'kWh'), + ('f0000000-0000-0000-0000-000000000002', 'd1000000-0000-0000-0000-000000000002', + '$.records[?(@.type=="energy")].value', '{"scale": 1}', 'heating_energy', 'kWh'), + ('f0000000-0000-0000-0000-000000000003', 'd1000000-0000-0000-0000-000000000002', + '$.records[?(@.type=="flow_temp")].value', '{"scale": 1}', 'flow_temperature', '°C'), + ('f0000000-0000-0000-0000-000000000004', 'd1000000-0000-0000-0000-000000000002', + '$.records[?(@.type=="return_temp")].value', '{"scale": 1}', 'return_temperature', '°C'), + ('f0000000-0000-0000-0000-000000000005', 'd1000000-0000-0000-0000-000000000002', + '$.status', NULL, 'error_flags', 'flags'), + ('f0000000-0000-0000-0000-000000000006', 'd1000000-0000-0000-0000-000000000003', + '$.records[?(@.type=="energy")].value', '{"scale": 1}', 'hot_water_energy', 'kWh'), + ('f0000000-0000-0000-0000-000000000007', 'd1000000-0000-0000-0000-000000000004', + '$.records[0].value', '{"scale": 0.001}', 'hot_water_volume', 'm3') +ON CONFLICT DO NOTHING; + +-- --------------------------------------------------------------------------- +-- Sensors +-- 01–08: heating, apartments 1–8 (apartment 4 has the real heat meter) +-- 09–16: hot water, apartments 1–8 (apartment 1 energy, the rest volume) +-- 17: provisioned but unassigned +-- --------------------------------------------------------------------------- + +INSERT INTO sensor (id, oms_id, device_type_id, apartment_id, decrypt_key, installed_at, installation_note) VALUES + ('5e000000-0000-0000-0000-000000000001', '71000001', 'd1000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', '000102030405060708090a0b0c0d0e01', '2025-04-14', 'HKV Wohnzimmer, einziger Heizkörper.'), + ('5e000000-0000-0000-0000-000000000002', '71000002', 'd1000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000002', '000102030405060708090a0b0c0d0e02', '2025-04-14', 'HKV Wohnzimmer.'), + ('5e000000-0000-0000-0000-000000000003', '71000003', 'd1000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000003', '000102030405060708090a0b0c0d0e03', '2025-04-14', 'HKV Schlafzimmer.'), + ('5e000000-0000-0000-0000-000000000004', '71000004', 'd1000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000004', '000102030405060708090a0b0c0d0e04', '2025-04-15', 'Wärmemengenzähler im Flurschacht.'), + ('5e000000-0000-0000-0000-000000000005', '71000005', 'd1000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000005', '000102030405060708090a0b0c0d0e05', '2025-04-15', 'HKV Küche.'), + ('5e000000-0000-0000-0000-000000000006', '71000006', 'd1000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000006', '000102030405060708090a0b0c0d0e06', '2025-05-06', 'HKV Wohnzimmer.'), + ('5e000000-0000-0000-0000-000000000007', '71000007', 'd1000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000007', '000102030405060708090a0b0c0d0e07', '2025-05-06', 'HKV Wohnzimmer.'), + ('5e000000-0000-0000-0000-000000000008', '71000008', 'd1000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000008', '000102030405060708090a0b0c0d0e08', '2025-05-06', 'HKV Bad.'), + ('5e000000-0000-0000-0000-000000000009', '71000009', 'd1000000-0000-0000-0000-000000000003', 'a0000000-0000-0000-0000-000000000001', '000102030405060708090a0b0c0d0e09', '2025-04-14', 'WW-Wärmezähler unter der Spüle.'), + ('5e000000-0000-0000-0000-000000000010', '71000010', 'd1000000-0000-0000-0000-000000000004', 'a0000000-0000-0000-0000-000000000002', '000102030405060708090a0b0c0d0e10', '2025-04-14', 'WW-Zähler Bad, Vorwandinstallation.'), + ('5e000000-0000-0000-0000-000000000011', '71000011', 'd1000000-0000-0000-0000-000000000004', 'a0000000-0000-0000-0000-000000000003', '000102030405060708090a0b0c0d0e11', '2025-04-14', 'WW-Zähler Bad.'), + ('5e000000-0000-0000-0000-000000000012', '71000012', 'd1000000-0000-0000-0000-000000000004', 'a0000000-0000-0000-0000-000000000004', '000102030405060708090a0b0c0d0e12', '2025-04-15', 'WW-Zähler Küche.'), + ('5e000000-0000-0000-0000-000000000013', '71000013', 'd1000000-0000-0000-0000-000000000004', 'a0000000-0000-0000-0000-000000000005', '000102030405060708090a0b0c0d0e13', '2025-04-15', 'WW-Zähler Bad.'), + ('5e000000-0000-0000-0000-000000000014', '71000014', 'd1000000-0000-0000-0000-000000000004', 'a0000000-0000-0000-0000-000000000006', '000102030405060708090a0b0c0d0e14', '2025-05-06', 'WW-Zähler Bad.'), + ('5e000000-0000-0000-0000-000000000015', '71000015', 'd1000000-0000-0000-0000-000000000004', 'a0000000-0000-0000-0000-000000000007', '000102030405060708090a0b0c0d0e15', '2025-05-06', 'WW-Zähler Bad.'), + ('5e000000-0000-0000-0000-000000000016', '71000016', 'd1000000-0000-0000-0000-000000000004', 'a0000000-0000-0000-0000-000000000008', '000102030405060708090a0b0c0d0e16', '2025-05-06', 'WW-Zähler Bad.'), + -- provisioned but not yet assigned to an apartment (normal pilot state) + ('5e000000-0000-0000-0000-000000000017', '71000017', 'd1000000-0000-0000-0000-000000000001', NULL, '000102030405060708090a0b0c0d0e17', '2025-04-15', 'Lager: noch nicht montiert / zugeordnet.') +ON CONFLICT DO NOTHING; + +-- --------------------------------------------------------------------------- +-- Raw frame log: one row per parse_status, including a frame from a meter +-- that is not ours (unknown_sensor — normal on a broadcast band). +-- --------------------------------------------------------------------------- + +INSERT INTO received_payload (id, gateway_id, sensor_id, timestamp_sent, timestamp_received, raw_payload, parse_status) VALUES + ('7a000000-0000-0000-0000-000000000001', '9e000000-0000-0000-0000-000000000001', '5e000000-0000-0000-0000-000000000001', + now() - interval '2 hours', now() - interval '2 hours', + '{"telegram": "2e44685071000001080c7a...", "decoded": {"manufacturer": "QDS", "serial": "71000001", "records": [{"type": "energy", "unit": "kWh", "value": 2841.7}]}}', + 'parsed'), + ('7a000000-0000-0000-0000-000000000002', '9e000000-0000-0000-0000-000000000002', '5e000000-0000-0000-0000-000000000002', + now() - interval '30 minutes', now() - interval '29 minutes', + '{"telegram": "2e44685071000002080c7a...", "decoded": {"manufacturer": "QDS", "serial": "71000002", "records": [{"type": "energy", "unit": "kWh", "value": 7404.2}]}}', + 'pending'), + ('7a000000-0000-0000-0000-000000000003', '9e000000-0000-0000-0000-000000000001', NULL, + now() - interval '1 hour', now() - interval '59 minutes', + '{"telegram": "2e44a51199887766060c7a...", "decoded": {"manufacturer": "LSE", "serial": "99887766", "records": [{"type": "volume", "unit": "m3", "value": 481.2}]}}', + 'unknown_sensor'), + ('7a000000-0000-0000-0000-000000000004', '9e000000-0000-0000-0000-000000000003', '5e000000-0000-0000-0000-000000000014', + now() - interval '3 hours', now() - interval '1 hour', -- buffered upload: sent ≪ received + '{"telegram": "1e44e5c871000014060c7a...", "decoded": null, "error": "decryption failed: wrong key length"}', + 'error') +ON CONFLICT DO NOTHING; + +-- --------------------------------------------------------------------------- +-- Measurements: daily cumulative meter readings from 2025-06-01 to today. +-- +-- Values are cumulative totals (meters broadcast counters, consumption is +-- computed by differencing at query time). Daily increment = annual total +-- × month weight ÷ days-in-month, ± 10% deterministic jitter (md5 of +-- sensor/kind/day — NOT random(), so re-runs are byte-identical). Heating +-- follows a seasonal curve; hot water is flat across the year. +-- +-- Annual heating totals encode the per-m² profiles the Bandtacho needs: +-- apt1 45 kWh/m²a (thrifty) … apt4 140 … apt5 190 kWh/m²a (heavy). +-- --------------------------------------------------------------------------- + +WITH weights (m, wt) AS ( + VALUES (1, 0.170), (2, 0.140), (3, 0.120), (4, 0.080), (5, 0.040), (6, 0.020), + (7, 0.015), (8, 0.015), (9, 0.040), (10, 0.080), (11, 0.120), (12, 0.160) +), +profiles (sensor_id, kind, unit, annual, start_value, seasonal) AS ( + VALUES + -- heating (kWh/yr = profile kWh/m²a × living area) + ('5e000000-0000-0000-0000-000000000001'::uuid, 'heating_energy'::measurement_kind, 'kWh', 2430.0, 3210.0, true), + ('5e000000-0000-0000-0000-000000000002'::uuid, 'heating_energy'::measurement_kind, 'kWh', 6120.0, 5480.0, true), + ('5e000000-0000-0000-0000-000000000003'::uuid, 'heating_energy'::measurement_kind, 'kWh', 8500.0, 9120.0, true), + ('5e000000-0000-0000-0000-000000000004'::uuid, 'heating_energy'::measurement_kind, 'kWh', 13440.0, 21930.0, true), + ('5e000000-0000-0000-0000-000000000005'::uuid, 'heating_energy'::measurement_kind, 'kWh', 20900.0, 15040.0, true), + ('5e000000-0000-0000-0000-000000000006'::uuid, 'heating_energy'::measurement_kind, 'kWh', 4200.0, 2660.0, true), + ('5e000000-0000-0000-0000-000000000007'::uuid, 'heating_energy'::measurement_kind, 'kWh', 7125.0, 4310.0, true), + ('5e000000-0000-0000-0000-000000000008'::uuid, 'heating_energy'::measurement_kind, 'kWh', 10560.0, 7750.0, true), + -- hot water: apartment 1 as energy, the rest as volume + ('5e000000-0000-0000-0000-000000000009'::uuid, 'hot_water_energy'::measurement_kind, 'kWh', 1620.0, 1890.0, false), + ('5e000000-0000-0000-0000-000000000010'::uuid, 'hot_water_volume'::measurement_kind, 'm3', 28.8, 214.3, false), + ('5e000000-0000-0000-0000-000000000011'::uuid, 'hot_water_volume'::measurement_kind, 'm3', 34.0, 131.9, false), + ('5e000000-0000-0000-0000-000000000012'::uuid, 'hot_water_volume'::measurement_kind, 'm3', 38.4, 356.0, false), + ('5e000000-0000-0000-0000-000000000013'::uuid, 'hot_water_volume'::measurement_kind, 'm3', 44.0, 102.4, false), + ('5e000000-0000-0000-0000-000000000014'::uuid, 'hot_water_volume'::measurement_kind, 'm3', 24.0, 188.7, false), + ('5e000000-0000-0000-0000-000000000015'::uuid, 'hot_water_volume'::measurement_kind, 'm3', 30.0, 77.5, false), + ('5e000000-0000-0000-0000-000000000016'::uuid, 'hot_water_volume'::measurement_kind, 'm3', 35.2, 240.1, false), + -- unassigned sensor: measured and stored regardless + ('5e000000-0000-0000-0000-000000000017'::uuid, 'heating_energy'::measurement_kind, 'kWh', 5000.0, 0.0, true) +), +days AS ( + SELECT d::date AS d + FROM generate_series(date '2025-06-01', current_date, interval '1 day') AS d +), +daily AS ( + SELECT p.sensor_id, p.kind, p.unit, dy.d, p.start_value, + p.annual + * CASE WHEN p.seasonal THEN w.wt ELSE 1.0 / 12 END + / extract(day FROM date_trunc('month', dy.d) + interval '1 month - 1 day') + * (0.9 + 0.2 * ((('x' || substr(md5(p.sensor_id::text || p.kind::text || dy.d::text), 1, 8))::bit(32)::int & 1023) / 1023.0)) + AS increment + FROM profiles p + CROSS JOIN days dy + JOIN weights w ON w.m = extract(month FROM dy.d)::int +) +INSERT INTO measurement (sensor_id, measured_at, kind, value, unit) +SELECT sensor_id, + -- per-sensor stable time-of-day, so series don't all tick in lockstep + d::timestamp + interval '6 hours' + + make_interval(mins => (('x' || substr(md5(sensor_id::text), 1, 4))::bit(16)::int % 50)), + kind, + round((start_value + sum(increment) OVER (PARTITION BY sensor_id, kind ORDER BY d))::numeric, 3), + unit +FROM daily +ON CONFLICT DO NOTHING; + +-- Diagnostics from the apartment-4 heat meter: flow/return temperatures +-- (seasonal: ~69/50 °C in January, ~44/32 °C in July) and an error register +-- that is 0 except for one flagged day. + +WITH weights (m, wt) AS ( + VALUES (1, 0.170), (2, 0.140), (3, 0.120), (4, 0.080), (5, 0.040), (6, 0.020), + (7, 0.015), (8, 0.015), (9, 0.040), (10, 0.080), (11, 0.120), (12, 0.160) +), +days AS ( + SELECT d::date AS d + FROM generate_series(date '2025-06-01', current_date, interval '1 day') AS d +) +INSERT INTO measurement (sensor_id, measured_at, kind, value, unit) +SELECT '5e000000-0000-0000-0000-000000000004'::uuid, + dy.d::timestamp + interval '6 hours 15 minutes', + k.kind, + round((k.base + k.span * w.wt + + 2 * ((('x' || substr(md5(k.kind::text || dy.d::text), 1, 8))::bit(32)::int & 255) / 255.0))::numeric, 1), + '°C' +FROM days dy +JOIN weights w ON w.m = extract(month FROM dy.d)::int +CROSS JOIN (VALUES ('flow_temperature'::measurement_kind, 42.0, 160.0), + ('return_temperature'::measurement_kind, 30.0, 120.0)) AS k (kind, base, span) +ON CONFLICT DO NOTHING; + +INSERT INTO measurement (sensor_id, measured_at, kind, value, unit) VALUES + ('5e000000-0000-0000-0000-000000000004', date '2026-02-10' + interval '6 hours 15 minutes', 'error_flags', 0, 'flags'), + ('5e000000-0000-0000-0000-000000000004', date '2026-02-11' + interval '6 hours 15 minutes', 'error_flags', 16, 'flags'), -- e.g. air in flow sensor + ('5e000000-0000-0000-0000-000000000004', date '2026-02-12' + interval '6 hours 15 minutes', 'error_flags', 0, 'flags') +ON CONFLICT DO NOTHING; + +-- --------------------------------------------------------------------------- +-- Reference data — DEV APPROXIMATIONS, not verified legal values. +-- --------------------------------------------------------------------------- + +-- Efficiency class thresholds: GEG Anlage 10 annual bounds split into monthly +-- bounds using the same seasonal weights as the heating fixtures. The real +-- UBA CC 69/2021 Tabelle 1 values differ — replace via a verified migration. +WITH classes (class, annual_max) AS ( + VALUES ('A+', 30.0), ('A', 50.0), ('B', 75.0), ('C', 100.0), + ('D', 130.0), ('E', 160.0), ('F', 200.0), ('G', 250.0) +), +weights (m, wt) AS ( + VALUES (1, 0.170), (2, 0.140), (3, 0.120), (4, 0.080), (5, 0.040), (6, 0.020), + (7, 0.015), (8, 0.015), (9, 0.040), (10, 0.080), (11, 0.120), (12, 0.160) +) +INSERT INTO efficiency_class_threshold (class, month, max_kwh_per_m2, source) +SELECT c.class, w.m, round((c.annual_max * w.wt)::numeric, 3), + 'DEV FIXTURE — approximation of UBA CC 69/2021 Tab. 1 / GEG Anl. 10' +FROM classes c CROSS JOIN weights w +UNION ALL +SELECT 'H', w.m, 999.999, -- open-ended class: sentinel, not a real bound + 'DEV FIXTURE — approximation of UBA CC 69/2021 Tab. 1 / GEG Anl. 10' +FROM weights w +ON CONFLICT DO NOTHING; + +-- CO2 factors: one superseded natural-gas row to exercise versioning. +INSERT INTO co2_emission_factor (fuel_type, g_co2_per_kwh, valid_from, valid_to, source) VALUES + ('natural_gas', 202.00, '2021-01-01', '2023-12-31', 'DEV FIXTURE — approximates GEG Anlage 9'), + ('natural_gas', 240.00, '2024-01-01', NULL, 'DEV FIXTURE — approximates GEG Anlage 9'), + ('heating_oil', 310.00, '2024-01-01', NULL, 'DEV FIXTURE — approximates GEG Anlage 9'), + ('district_heating', 180.00, '2024-01-01', NULL, 'DEV FIXTURE — approximates GEG Anlage 9'), + ('heat_pump_electricity', 560.00, '2024-01-01', NULL, 'DEV FIXTURE — approximates GEG Anlage 9'), + ('wood_pellets', 20.00, '2024-01-01', NULL, 'DEV FIXTURE — approximates GEG Anlage 9'), + ('other', 300.00, '2024-01-01', NULL, 'DEV FIXTURE — placeholder') +ON CONFLICT DO NOTHING; + +-- Energy prices: one superseded natural-gas row to exercise versioning. +INSERT INTO energy_price (fuel_type, ct_per_kwh, valid_from, valid_to, source, created_by) VALUES + ('natural_gas', 11.200, '2024-01-01', '2025-12-31', 'DEV FIXTURE', 'e0000000-0000-0000-0000-000000000001'), + ('natural_gas', 12.400, '2026-01-01', NULL, 'DEV FIXTURE', 'e0000000-0000-0000-0000-000000000001'), + ('heating_oil', 10.900, '2026-01-01', NULL, 'DEV FIXTURE', 'e0000000-0000-0000-0000-000000000001'), + ('district_heating', 15.800, '2026-01-01', NULL, 'DEV FIXTURE', 'e0000000-0000-0000-0000-000000000001'), + ('heat_pump_electricity', 27.500, '2026-01-01', NULL, 'DEV FIXTURE', 'e0000000-0000-0000-0000-000000000001'), + ('wood_pellets', 8.200, '2026-01-01', NULL, 'DEV FIXTURE', 'e0000000-0000-0000-0000-000000000001') +ON CONFLICT DO NOTHING; + +-- Heating degree days: the 15 complete months before the current month. +-- The CURRENT month is deliberately absent — the freshest footnote renders +-- "not available" until the (future) monthly DWD import fills it, exactly +-- like prod. Deterministic per (year, month), so re-runs insert nothing new +-- for existing months and exactly one row when a month completes. +WITH base (m, hdd0) AS ( + VALUES (1, 550), (2, 480), (3, 400), (4, 280), (5, 140), (6, 40), + (7, 15), (8, 20), (9, 110), (10, 280), (11, 420), (12, 520) +), +months AS ( + SELECT (date_trunc('month', current_date) - make_interval(months => n))::date AS mon + FROM generate_series(1, 15) AS n +) +INSERT INTO heating_degree_days (station_id, year, month, hdd) +SELECT 'dd000000-0000-0000-0000-000000000001', + extract(year FROM mon)::smallint, + extract(month FROM mon)::smallint, + round((b.hdd0 * (0.90 + 0.2 * ((('x' || substr(md5(mon::text), 1, 8))::bit(32)::int & 255) / 255.0)))::numeric, 1) +FROM months +JOIN base b ON b.m = extract(month FROM mon)::int +ON CONFLICT DO NOTHING; + +-- --------------------------------------------------------------------------- +-- Savings tips (Module 4) — one retired tip to exercise active = false. +-- --------------------------------------------------------------------------- + +INSERT INTO saving_tip (id, category, title, body, link_url, active) VALUES + ('5a000000-0000-0000-0000-000000000001', 'heating', 'Stoßlüften statt Kipplüften', + 'Mehrmals täglich 5–10 Minuten mit weit geöffnetem Fenster lüften, statt das Fenster dauerhaft zu kippen. So bleibt die Wärme in Wänden und Möbeln erhalten.', + 'https://www.verbraucherzentrale.de/wissen/energie/heizen-und-warmwasser', true), + ('5a000000-0000-0000-0000-000000000002', 'heating', 'Raumtemperatur um 1 °C senken', + 'Ein Grad weniger spart rund 6 % Heizenergie. 20 °C im Wohnzimmer und 17–18 °C im Schlafzimmer reichen in der Regel aus.', + 'https://www.co2online.de/energie-sparen/heizenergie-sparen/', true), + ('5a000000-0000-0000-0000-000000000003', 'heating', 'Heizkörper entlüften', + 'Gluckert der Heizkörper oder wird er nur teilweise warm, ist Luft im System. Entlüften stellt die volle Heizleistung wieder her.', + NULL, true), + ('5a000000-0000-0000-0000-000000000004', 'heating', 'Heizkörper nicht zustellen', + 'Möbel und Vorhänge vor dem Heizkörper stauen die Wärme und erhöhen den Verbrauch.', + NULL, false), -- retired tip: must remain resolvable for past months + ('5a000000-0000-0000-0000-000000000005', 'hot_water', 'Duschen statt Baden', + 'Ein Vollbad benötigt etwa dreimal so viel warmes Wasser wie eine kurze Dusche.', + 'https://www.verbraucherzentrale.de/wissen/energie/heizen-und-warmwasser', true), + ('5a000000-0000-0000-0000-000000000006', 'hot_water', 'Sparduschkopf einbauen', + 'Ein Sparduschkopf halbiert den Warmwasserverbrauch beim Duschen — bei gleichem Komfort.', + 'https://www.co2online.de/energie-sparen/strom-sparen/', true), + ('5a000000-0000-0000-0000-000000000007', 'hot_water', 'Warmwasser nicht laufen lassen', + 'Beim Einseifen, Zähneputzen und Spülen das warme Wasser abstellen.', + NULL, true) +ON CONFLICT DO NOTHING; + +COMMIT;