-- ============================================================================
-- LEAF Platform — Cloud Deployment Script
-- ============================================================================
--
-- Target: TimescaleDB 2.17.x (Timescale Cloud or self-hosted)
--
-- BEFORE RUNNING:
--   1. The `timescaledb` extension is pre-installed on Timescale Cloud;
--      the CREATE EXTENSION line is a safe no-op.
--   2. Do NOT run 07-seed.sql — use the portal /setup page to create
--      the first superadmin account.
--
-- NOTE: user_organisation and user_department are retained for schema compatibility
--       and organisational tracking. Data access is exclusively via user_management.
-- ============================================================================

-- ============================================================================
-- LEAF Platform - Extensions
-- ============================================================================

CREATE EXTENSION IF NOT EXISTS timescaledb;
-- ============================================================================
-- LEAF Platform - Roles
-- ============================================================================
--
-- readers     — internal systems (Grafana, portal): email-based SECURITY
--               DEFINER functions + SELECT on management tables.
-- writers     — full DML on all tables (Node-RED for sensor_data, portal for
--               management tables).
-- api_readers — open external access: token-based SECURITY DEFINER functions
--               only. No email functions, no direct table access.
--               The API token is the sole authentication mechanism.
-- ============================================================================

DO $$ BEGIN
  IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'readers') THEN
    CREATE ROLE readers;
  END IF;
  EXECUTE 'GRANT CONNECT ON DATABASE ' || current_database() || ' TO readers';
END $$;
GRANT USAGE   ON SCHEMA public TO readers;

DO $$ BEGIN
  IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'writers') THEN
    CREATE ROLE writers;
  END IF;
  EXECUTE 'GRANT CONNECT ON DATABASE ' || current_database() || ' TO writers';
END $$;
GRANT USAGE   ON SCHEMA public TO writers;

DO $$ BEGIN
  IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'api_readers') THEN
    CREATE ROLE api_readers;
  END IF;
  EXECUTE 'GRANT CONNECT ON DATABASE ' || current_database() || ' TO api_readers';
END $$;
GRANT USAGE   ON SCHEMA public TO api_readers;
-- ============================================================================
-- LEAF Platform - Tables
-- ============================================================================
--
-- Schema overview:
--   organisation          — top-level tenant (WUR, VIB, ...)
--   department         — sub-tenant within an organisation (SSB, MBP, ...)
--   user_account       — platform user (email-identified)
--   user_organisation     — schema compatibility only; NOT used for data access
--   user_department    — organisational tracking (admin roles, portal UI) only;
--                        NOT used for data access. Data access is exclusively
--                        via user_management.
--   management            — named, scoped data slice (for sharing / publications)
--                        scope: department → entity → time window
--   user_management       — grants a user access to a management's scoped data
--   sensor_data        — EAV hypertable, one row per metric reading
--
-- Access model:
--   All data access is management-based. A user must have at least one user_management
--   entry whose management scope covers the requested data. user_department is for
--   organisational tracking only and does not grant data access.
--
-- FK design notes:
--   - organisation/department use RESTRICT: cannot delete if children exist
--   - user_* junction tables use CASCADE on user_id: deleting a user cleans up memberships
--   - sensor_data has NO FK constraints: avoids lookup overhead on every high-volume insert;
--     application validates organisation_id/department_id before writing
-- ============================================================================


-- ----------------------------------------------------------------------------
-- Lookup tables
-- ----------------------------------------------------------------------------

CREATE TABLE IF NOT EXISTS organisation (
    id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name       TEXT NOT NULL UNIQUE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE IF NOT EXISTS department (
    id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    organisation_id UUID NOT NULL REFERENCES organisation(id) ON DELETE RESTRICT,
    name         TEXT NOT NULL,
    created_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE (organisation_id, name)
);
CREATE INDEX IF NOT EXISTS idx_department_organisation ON department (organisation_id);


-- ----------------------------------------------------------------------------
-- User management
-- ----------------------------------------------------------------------------

CREATE TABLE IF NOT EXISTS user_account (
    id             UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email          TEXT NOT NULL UNIQUE,
    name           TEXT NOT NULL,
    password_hash  TEXT,           -- bcrypt hash; NULL until password is set
    is_superadmin  BOOLEAN NOT NULL DEFAULT false,
    created_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
    last_login_at  TIMESTAMPTZ
);

-- Organisation-level membership: full access to all sensor data in that organisation.
CREATE TABLE IF NOT EXISTS user_organisation (
    user_id      UUID NOT NULL REFERENCES user_account(id) ON DELETE CASCADE,
    organisation_id UUID NOT NULL REFERENCES organisation(id)    ON DELETE RESTRICT,
    role         TEXT NOT NULL DEFAULT 'member',   -- 'admin', 'member'
    granted_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (user_id, organisation_id)
);
CREATE INDEX IF NOT EXISTS idx_user_organisation_inst ON user_organisation (organisation_id);

-- Department-level membership: access to sensor data within one department only.
CREATE TABLE IF NOT EXISTS user_department (
    user_id       UUID NOT NULL REFERENCES user_account(id) ON DELETE CASCADE,
    department_id UUID NOT NULL REFERENCES department(id)   ON DELETE RESTRICT,
    role          TEXT NOT NULL DEFAULT 'member',   -- 'admin', 'member'
    granted_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (user_id, department_id)
);
CREATE INDEX IF NOT EXISTS idx_user_department_dept ON user_department (department_id);


-- Password reset tokens (single-use, 1-hour expiry)
CREATE TABLE IF NOT EXISTS password_reset_token (
    id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id    UUID NOT NULL REFERENCES user_account(id) ON DELETE CASCADE,
    token      TEXT NOT NULL UNIQUE,
    expires_at TIMESTAMPTZ NOT NULL,
    used_at    TIMESTAMPTZ,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_prt_token ON password_reset_token (token);

-- API tokens — long-lived secrets that external systems can use instead of email addresses.
-- Each token is scoped to one user; access is identical to what that user can see.
-- Tokens can be named, optionally time-limited, and individually revoked.
CREATE TABLE IF NOT EXISTS api_token (
    id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id    UUID NOT NULL REFERENCES user_account(id) ON DELETE CASCADE,
    name       TEXT NOT NULL,                    -- user-assigned label (e.g. "My R script")
    token      TEXT NOT NULL UNIQUE,             -- cryptographically random secret (never reused)
    expires_at TIMESTAMPTZ,                      -- NULL = never expires
    revoked_at TIMESTAMPTZ,                      -- NULL = still active
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_api_token_token ON api_token (token);
CREATE INDEX IF NOT EXISTS idx_api_token_user  ON api_token (user_id);


-- ----------------------------------------------------------------------------
-- Access grants
-- ----------------------------------------------------------------------------
--
-- A management is a named, optionally scoped window into an organisation's data.
-- Scope narrows from left to right; skipping a level is not allowed:
--
--   organisation → department → entity → time window
--
-- Examples:
--   Full organisation      organisation=WUR  department=NULL  entity=NULL  time=NULL..NULL
--   All SSB data        organisation=WUR  department=SSB   entity=NULL  time=NULL..NULL
--   Summer 2025 batch   organisation=WUR  department=SSB   entity=R1    2025-06-01..2025-09-01

CREATE TABLE IF NOT EXISTS management (
    id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    organisation_id  UUID NOT NULL REFERENCES organisation(id)   ON DELETE RESTRICT,
    department_id UUID          REFERENCES department(id)  ON DELETE RESTRICT,  -- NULL = all departments
    entity        TEXT,          -- NULL = all entities in scope
    time_start    TIMESTAMPTZ,   -- NULL = no lower bound
    time_end      TIMESTAMPTZ,   -- NULL = open-ended
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),

    CONSTRAINT management_entity_requires_department
        CHECK (entity IS NULL OR department_id IS NOT NULL)
);
-- Drop legacy columns from older installs
ALTER TABLE management DROP COLUMN IF EXISTS name;
ALTER TABLE management DROP COLUMN IF EXISTS description;
CREATE UNIQUE INDEX IF NOT EXISTS idx_management_unique_grant
    ON management (organisation_id, department_id, entity, time_start, time_end) NULLS NOT DISTINCT;
CREATE INDEX IF NOT EXISTS idx_management_organisation  ON management (organisation_id);
CREATE INDEX IF NOT EXISTS idx_management_department ON management (department_id);

-- Access grant membership: grants a user read access to a management's scoped data.
CREATE TABLE IF NOT EXISTS user_management (
    user_id    UUID NOT NULL REFERENCES user_account(id) ON DELETE CASCADE,
    management_id UUID NOT NULL REFERENCES management(id)      ON DELETE CASCADE,
    role       TEXT NOT NULL DEFAULT 'viewer',   -- 'admin', 'viewer'
    granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (user_id, management_id)
);
CREATE INDEX IF NOT EXISTS idx_user_management_proj ON user_management (management_id);
CREATE INDEX IF NOT EXISTS idx_user_management_user ON user_management (user_id);


-- ----------------------------------------------------------------------------
-- Mapper tables
-- ----------------------------------------------------------------------------

CREATE TABLE IF NOT EXISTS mapper_language (
    key   TEXT NOT NULL,
    lang  TEXT NOT NULL,
    value TEXT NOT NULL,
    PRIMARY KEY (key, lang)
);

CREATE TABLE IF NOT EXISTS mapper_semantics (
    key   TEXT PRIMARY KEY,
    value TEXT NOT NULL
);


-- ----------------------------------------------------------------------------
-- Core table: sensor_data (converted to hypertable in 04-hypertable.sql)
-- ----------------------------------------------------------------------------

CREATE TABLE IF NOT EXISTS sensor_data (
    time          TIMESTAMPTZ      NOT NULL,
    entity        TEXT             NOT NULL,   -- reactor, animal, station, ...
    metric        TEXT             NOT NULL,   -- temperature, pressure, pH, ...
    value         DOUBLE PRECISION NOT NULL,
    tags          JSONB,                       -- units, quality flags, location, ...
    department_id UUID             NOT NULL,   -- references department(id)
    organisation_id  UUID             NOT NULL,   -- references organisation(id)

    UNIQUE (time, entity, metric, department_id, organisation_id)
);


-- ----------------------------------------------------------------------------
-- Alarms
-- ----------------------------------------------------------------------------

CREATE TABLE IF NOT EXISTS alarm_rule (
    id               UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name             TEXT NOT NULL,
    department_id    UUID NOT NULL REFERENCES department(id) ON DELETE CASCADE,
    organisation_id     UUID NOT NULL REFERENCES organisation(id)  ON DELETE CASCADE,
    entity           TEXT NOT NULL,
    metric           TEXT,
    operator         TEXT NOT NULL CHECK (operator IN ('<', '>', '<=', '>=', '=', 'no_data')),
    CONSTRAINT alarm_rule_metric_required CHECK (operator = 'no_data' OR metric IS NOT NULL),
    threshold        DOUBLE PRECISION NOT NULL,
    check_interval   INT NOT NULL DEFAULT 300,   -- seconds between evaluations
    enabled          BOOLEAN NOT NULL DEFAULT TRUE,
    created_at       TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    created_by       UUID REFERENCES user_account(id) ON DELETE SET NULL,
    last_checked_at    TIMESTAMPTZ,
    last_checked_value DOUBLE PRECISION
);
CREATE INDEX IF NOT EXISTS idx_alarm_rule_department ON alarm_rule (department_id);

CREATE TABLE IF NOT EXISTS alarm_recipient (
    rule_id  UUID NOT NULL REFERENCES alarm_rule(id) ON DELETE CASCADE,
    user_id  UUID NOT NULL REFERENCES user_account(id) ON DELETE CASCADE,
    PRIMARY KEY (rule_id, user_id)
);

-- status: 'triggered' | 'resolved'
-- Last event per rule_id determines current state (no re-notify until resolved)
CREATE TABLE IF NOT EXISTS alarm_event (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    rule_id     UUID NOT NULL REFERENCES alarm_rule(id) ON DELETE CASCADE,
    status      TEXT NOT NULL CHECK (status IN ('triggered', 'resolved')),
    value       DOUBLE PRECISION,
    occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_alarm_event_rule ON alarm_event (rule_id, occurred_at DESC);

CREATE TABLE IF NOT EXISTS setting (
    namespace  TEXT NOT NULL DEFAULT 'general',
    key        TEXT NOT NULL,
    value      TEXT,
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (namespace, key)
);

-- ============================================================================
-- LEAF Platform - Hypertable, Compression & Continuous Aggregates
-- ============================================================================
--
-- Note: TimescaleDB does not support RLS on compressed hypertables or on
-- hypertables that have continuous aggregates. Access control is enforced
-- at the application layer via SECURITY DEFINER functions (see 05-functions.sql).
-- ============================================================================


-- ----------------------------------------------------------------------------
-- Hypertable
-- ----------------------------------------------------------------------------

SELECT create_hypertable('sensor_data', 'time', if_not_exists => TRUE);


-- ----------------------------------------------------------------------------
-- Indexes on sensor_data
--
-- TimescaleDB auto-creates an index on the partition key (time).
-- We add explicit indexes on the access-control columns so the SECURITY
-- DEFINER functions (sensor_data_for_organisation / _department / _recent) can
-- filter without a full chunk scan.
-- ----------------------------------------------------------------------------

-- Used by sensor_data_for_department and the EXISTS join in all functions
CREATE INDEX IF NOT EXISTS idx_sensor_data_department
    ON sensor_data (department_id, time DESC);

-- Used by sensor_data_for_organisation
CREATE INDEX IF NOT EXISTS idx_sensor_data_organisation
    ON sensor_data (organisation_id, time DESC);


-- ----------------------------------------------------------------------------
-- Compression
--
-- segment_by includes department_id so compressed chunks are split per
-- department — queries that filter by department skip irrelevant segments
-- entirely instead of decompressing whole chunks.
-- entity is kept as a secondary segment dimension (one time-series per entity).
-- ----------------------------------------------------------------------------

ALTER TABLE sensor_data SET (
    timescaledb.compress,
    timescaledb.compress_segmentby = 'department_id, entity',
    timescaledb.compress_orderby   = 'time DESC'
);

SELECT add_compression_policy('sensor_data', INTERVAL '7 days', if_not_exists => TRUE);


-- ============================================================================
-- LEAF Platform - SECURITY DEFINER Functions
-- ============================================================================
--
-- All query functions use SECURITY DEFINER so they run as the function owner
-- (postgres), not the calling role. This allows readers to query sensor_data
-- without having direct SELECT on the table.
--
-- Access model:
--   - Data access is exclusively management-based (user_management + management table).
--   - user_department is for organisational tracking only and does NOT grant
--     data access. A user sees only sensor_data rows covered by at least one
--     of their managements.
--   - A management covers a row when all non-null scope columns match:
--       department_id — NULL means all departments in the organisation
--       entity        — NULL means all entities
--       time_start    — NULL means no lower bound
--       time_end      — NULL means no upper bound
--   - Superadmins still need a management assignment to access sensor data.
--
-- Overloads:
--   (email, UUID)        — UUID-based, for programmatic use
--   (email, TEXT, ...)   — name-based, for human/interactive use
-- ============================================================================


-- ----------------------------------------------------------------------------
-- Lookup helpers
-- ----------------------------------------------------------------------------

-- Organisations the user has at least one management in
CREATE OR REPLACE FUNCTION organisations_for_user(user_email TEXT)
RETURNS TABLE (id UUID, name TEXT, role TEXT)
LANGUAGE sql STABLE SECURITY DEFINER AS $$
    SELECT i.id, i.name,
           CASE WHEN bool_or(up.role = 'admin') THEN 'admin' ELSE 'member' END AS role
    FROM   user_management up
    JOIN   management      p  ON p.id  = up.management_id
    JOIN   organisation    i  ON i.id  = p.organisation_id
    JOIN   user_account ua ON ua.id = up.user_id
    WHERE  ua.email = user_email
    GROUP  BY i.id, i.name
    ORDER  BY i.name;
$$;

-- Managements the user has access to (via user_management membership)
CREATE OR REPLACE FUNCTION management_for_user(user_email TEXT)
RETURNS TABLE (
    id              UUID,
    organisation_id    UUID,
    organisation_name  TEXT,
    department_id   UUID,
    department_name TEXT,
    entity          TEXT,
    time_start      TIMESTAMPTZ,
    time_end        TIMESTAMPTZ
)
LANGUAGE sql STABLE SECURITY DEFINER AS $$
    SELECT
        p.id,
        p.organisation_id,
        i.name AS organisation_name,
        p.department_id,
        d.name AS department_name,
        p.entity,
        p.time_start,
        p.time_end
    FROM   management      p
    JOIN   organisation    i  ON i.id  = p.organisation_id
    LEFT JOIN department d ON d.id  = p.department_id
    JOIN   user_management up ON up.management_id = p.id
    JOIN   user_account ua ON ua.id = up.user_id
    WHERE  ua.email = user_email
    ORDER  BY p.created_at DESC;
$$;


-- ----------------------------------------------------------------------------
-- Raw sensor data — organisation level
--
-- Returns sensor_data rows for the given organisation that belong to departments
-- the user is a member of. A user only sees data from their own departments,
-- even when querying at organisation scope.
-- ----------------------------------------------------------------------------

CREATE OR REPLACE FUNCTION sensor_data_for_organisation(
    user_email     TEXT,
    p_organisation_id UUID
)
RETURNS TABLE (
    "time"        TIMESTAMPTZ,
    entity        TEXT,
    metric        TEXT,
    value         DOUBLE PRECISION,
    tags          JSONB,
    department_id UUID,
    organisation_id  UUID
)
LANGUAGE sql STABLE SECURITY DEFINER AS $$
    SELECT sd.time, sd.entity, sd.metric, sd.value, sd.tags,
           sd.department_id, sd.organisation_id
    FROM   sensor_data sd
    WHERE  sd.organisation_id = p_organisation_id
    AND    EXISTS (
        SELECT 1
        FROM   user_management up2
        JOIN   management      p2  ON p2.id  = up2.management_id
        JOIN   user_account ua2 ON ua2.id = up2.user_id
        WHERE  ua2.email          = user_email
        AND    p2.organisation_id    = sd.organisation_id
        AND    (p2.department_id IS NULL OR p2.department_id = sd.department_id)
        AND    (p2.entity        IS NULL OR p2.entity        = sd.entity)
        AND    (p2.time_start    IS NULL OR sd.time >= p2.time_start)
        AND    (p2.time_end      IS NULL OR sd.time <  p2.time_end)
    );
$$;

-- Name-based overload
CREATE OR REPLACE FUNCTION sensor_data_for_organisation(
    user_email  TEXT,
    p_organisation TEXT
)
RETURNS TABLE (
    "time"        TIMESTAMPTZ,
    entity        TEXT,
    metric        TEXT,
    value         DOUBLE PRECISION,
    tags          JSONB,
    department_id UUID,
    organisation_id  UUID
)
LANGUAGE sql STABLE SECURITY DEFINER AS $$
    SELECT sd.time, sd.entity, sd.metric, sd.value, sd.tags,
           sd.department_id, sd.organisation_id
    FROM   sensor_data sd
    JOIN   organisation   i  ON i.id = sd.organisation_id
    WHERE  i.name = p_organisation
    AND    EXISTS (
        SELECT 1
        FROM   user_management up2
        JOIN   management      p2  ON p2.id  = up2.management_id
        JOIN   user_account ua2 ON ua2.id = up2.user_id
        WHERE  ua2.email          = user_email
        AND    p2.organisation_id    = sd.organisation_id
        AND    (p2.department_id IS NULL OR p2.department_id = sd.department_id)
        AND    (p2.entity        IS NULL OR p2.entity        = sd.entity)
        AND    (p2.time_start    IS NULL OR sd.time >= p2.time_start)
        AND    (p2.time_end      IS NULL OR sd.time <  p2.time_end)
    );
$$;


-- ----------------------------------------------------------------------------
-- Raw sensor data — department level
--
-- Returns sensor_data rows for the given department that are covered by at
-- least one of the user's managements.
-- ----------------------------------------------------------------------------

CREATE OR REPLACE FUNCTION sensor_data_for_department(
    user_email      TEXT,
    p_department_id UUID
)
RETURNS TABLE (
    "time"        TIMESTAMPTZ,
    entity        TEXT,
    metric        TEXT,
    value         DOUBLE PRECISION,
    tags          JSONB,
    department_id UUID,
    organisation_id  UUID
)
LANGUAGE sql STABLE SECURITY DEFINER AS $$
    SELECT sd.time, sd.entity, sd.metric, sd.value, sd.tags,
           sd.department_id, sd.organisation_id
    FROM   sensor_data sd
    WHERE  sd.department_id = p_department_id
    AND    EXISTS (
        SELECT 1
        FROM   user_management up2
        JOIN   management      p2  ON p2.id  = up2.management_id
        JOIN   user_account ua2 ON ua2.id = up2.user_id
        WHERE  ua2.email          = user_email
        AND    p2.organisation_id    = sd.organisation_id
        AND    (p2.department_id IS NULL OR p2.department_id = sd.department_id)
        AND    (p2.entity        IS NULL OR p2.entity        = sd.entity)
        AND    (p2.time_start    IS NULL OR sd.time >= p2.time_start)
        AND    (p2.time_end      IS NULL OR sd.time <  p2.time_end)
    );
$$;

-- UUID-based alias used by the portal (explorer page passes department UUID directly)
CREATE OR REPLACE FUNCTION sensor_data_for_department_per_uuid(
    p_user_email    TEXT,
    p_department_id UUID
)
RETURNS TABLE (
    "time"          TIMESTAMPTZ,
    entity          TEXT,
    metric          TEXT,
    value           DOUBLE PRECISION,
    tags            JSONB,
    department_id   UUID,
    organisation_id UUID
)
LANGUAGE sql STABLE SECURITY DEFINER AS $$
    SELECT sd.time, sd.entity, sd.metric, sd.value, sd.tags,
           sd.department_id, sd.organisation_id
    FROM   sensor_data sd
    WHERE  sd.department_id = p_department_id
    AND    EXISTS (
        SELECT 1
        FROM   user_management um
        JOIN   management      m  ON m.id  = um.management_id
        JOIN   user_account    ua ON ua.id = um.user_id
        WHERE  ua.email                 = p_user_email
        AND    m.organisation_id        = sd.organisation_id
        AND    (m.department_id IS NULL OR m.department_id = sd.department_id)
        AND    (m.entity        IS NULL OR m.entity        = sd.entity)
        AND    (m.time_start    IS NULL OR sd.time >= m.time_start)
        AND    (m.time_end      IS NULL OR sd.time <  m.time_end)
    );
$$;

-- Name-based overload
CREATE OR REPLACE FUNCTION sensor_data_for_department(
    user_email   TEXT,
    p_organisation  TEXT,
    p_department TEXT
)
RETURNS TABLE (
    "time"        TIMESTAMPTZ,
    entity        TEXT,
    metric        TEXT,
    value         DOUBLE PRECISION,
    tags          JSONB,
    department_id UUID,
    organisation_id  UUID
)
LANGUAGE sql STABLE SECURITY DEFINER AS $$
    SELECT sd.time, sd.entity, sd.metric, sd.value, sd.tags,
           sd.department_id, sd.organisation_id
    FROM   sensor_data sd
    JOIN   department  d ON d.id  = sd.department_id
    JOIN   organisation   i ON i.id  = sd.organisation_id
    WHERE  i.name = p_organisation
    AND    d.name = p_department
    AND    EXISTS (
        SELECT 1
        FROM   user_management up2
        JOIN   management      p2  ON p2.id  = up2.management_id
        JOIN   user_account ua2 ON ua2.id = up2.user_id
        WHERE  ua2.email          = user_email
        AND    p2.organisation_id    = sd.organisation_id
        AND    (p2.department_id IS NULL OR p2.department_id = sd.department_id)
        AND    (p2.entity        IS NULL OR p2.entity        = sd.entity)
        AND    (p2.time_start    IS NULL OR sd.time >= p2.time_start)
        AND    (p2.time_end      IS NULL OR sd.time <  p2.time_end)
    );
$$;


-- ----------------------------------------------------------------------------
-- Raw sensor data — cross-organisation (most recent N rows)
--
-- Returns the most recent p_limit rows covered by at least one of the user's
-- managements, across all organisations.
-- ----------------------------------------------------------------------------

CREATE OR REPLACE FUNCTION sensor_data_recent(
    user_email TEXT,
    p_limit    INT DEFAULT 20
)
RETURNS TABLE (
    "time"        TIMESTAMPTZ,
    entity        TEXT,
    metric        TEXT,
    value         DOUBLE PRECISION,
    tags          JSONB,
    department_id UUID,
    organisation_id  UUID
)
LANGUAGE sql STABLE SECURITY DEFINER AS $$
    SELECT sd.time, sd.entity, sd.metric, sd.value, sd.tags,
           sd.department_id, sd.organisation_id
    FROM   sensor_data sd
    WHERE  EXISTS (
        SELECT 1
        FROM   user_management up2
        JOIN   management      p2  ON p2.id  = up2.management_id
        JOIN   user_account ua2 ON ua2.id = up2.user_id
        WHERE  ua2.email          = user_email
        AND    p2.organisation_id    = sd.organisation_id
        AND    (p2.department_id IS NULL OR p2.department_id = sd.department_id)
        AND    (p2.entity        IS NULL OR p2.entity        = sd.entity)
        AND    (p2.time_start    IS NULL OR sd.time >= p2.time_start)
        AND    (p2.time_end      IS NULL OR sd.time <  p2.time_end)
    )
    ORDER BY sd.time DESC
    LIMIT p_limit;
$$;


-- ----------------------------------------------------------------------------
-- Raw sensor data — management level (internal helper)
--
-- Called by sensor_data_for_management_by_token; not granted to any role.
-- External callers always go through the token wrapper, which resolves the
-- user identity internally. Email-based callers (Grafana, portal) use the
-- department-scoped functions — management memberships are a transparent security
-- gate and the caller never needs to know a management UUID.
-- ----------------------------------------------------------------------------

CREATE OR REPLACE FUNCTION sensor_data_for_management(
    user_email   TEXT,
    p_management_id UUID
)
RETURNS TABLE (
    "time"        TIMESTAMPTZ,
    entity        TEXT,
    metric        TEXT,
    value         DOUBLE PRECISION,
    tags          JSONB,
    department_id UUID,
    organisation_id  UUID
)
LANGUAGE sql STABLE SECURITY DEFINER AS $$
    SELECT sd.time, sd.entity, sd.metric, sd.value, sd.tags,
           sd.department_id, sd.organisation_id
    FROM   sensor_data  sd
    JOIN   management      p  ON p.organisation_id = sd.organisation_id
    JOIN   user_management up ON up.management_id  = p.id
    JOIN   user_account ua ON ua.id          = up.user_id
    WHERE  p.id          = p_management_id
    AND    ua.email      = user_email
    AND    (p.department_id IS NULL OR p.department_id = sd.department_id)
    AND    (p.entity        IS NULL OR p.entity        = sd.entity)
    AND    (p.time_start    IS NULL OR sd.time >= p.time_start)
    AND    (p.time_end      IS NULL OR sd.time <  p.time_end);
$$;


-- ============================================================================
-- Token-based access functions
-- ============================================================================
--
-- These are the external-facing equivalents of the email-based functions above.
-- They resolve the token to the owning user internally (SECURITY DEFINER), so
-- callers never need to know or guess a user's email address.
--
-- A token is invalid (returns no rows) if it is revoked or past its expiry.
-- The email-based functions remain available for internal systems (portal,
-- Grafana) that already authenticate the caller by other means.
-- ============================================================================

-- Helper: resolve a valid token to its owner's email.
-- Returns NULL if the token does not exist, is revoked, or has expired.
CREATE OR REPLACE FUNCTION _email_for_api_token(p_token TEXT)
RETURNS TEXT
LANGUAGE sql STABLE SECURITY DEFINER AS $$
    SELECT ua.email
    FROM   api_token    tk
    JOIN   user_account ua ON ua.id = tk.user_id
    WHERE  tk.token      = p_token
    AND    tk.revoked_at IS NULL
    AND    (tk.expires_at IS NULL OR tk.expires_at > now());
$$;

-- Discovery: organisations the token owner belongs to
CREATE OR REPLACE FUNCTION organisations_for_token(p_token TEXT)
RETURNS TABLE (id UUID, name TEXT, role TEXT)
LANGUAGE plpgsql STABLE SECURITY DEFINER AS $$
DECLARE v_email TEXT;
BEGIN
    SELECT _email_for_api_token(p_token) INTO v_email;
    IF v_email IS NULL THEN RETURN; END IF;
    RETURN QUERY SELECT * FROM organisations_for_user(v_email);
END;
$$;

-- Discovery: managements the token owner has access to
CREATE OR REPLACE FUNCTION management_for_token(p_token TEXT)
RETURNS TABLE (
    id              UUID,
    organisation_name  TEXT,
    department_name TEXT,
    entity          TEXT,
    time_start      TIMESTAMPTZ,
    time_end        TIMESTAMPTZ
)
LANGUAGE plpgsql STABLE SECURITY DEFINER AS $$
DECLARE v_email TEXT;
BEGIN
    SELECT _email_for_api_token(p_token) INTO v_email;
    IF v_email IS NULL THEN RETURN; END IF;
    RETURN QUERY
        SELECT m.id, m.organisation_name, m.department_name, m.entity, m.time_start, m.time_end
        FROM management_for_user(v_email) m;
END;
$$;

-- Data: organisation-level query (UUID overload)
CREATE OR REPLACE FUNCTION sensor_data_for_organisation_by_token(
    p_token        TEXT,
    p_organisation_id UUID
)
RETURNS TABLE (
    "time"        TIMESTAMPTZ,
    entity        TEXT,
    metric        TEXT,
    value         DOUBLE PRECISION,
    tags          JSONB,
    department_id UUID,
    organisation_id  UUID
)
LANGUAGE plpgsql STABLE SECURITY DEFINER AS $$
DECLARE v_email TEXT;
BEGIN
    SELECT _email_for_api_token(p_token) INTO v_email;
    IF v_email IS NULL THEN RETURN; END IF;
    RETURN QUERY SELECT * FROM sensor_data_for_organisation(v_email, p_organisation_id);
END;
$$;

-- Data: organisation-level query (name overload)
CREATE OR REPLACE FUNCTION sensor_data_for_organisation_by_token(
    p_token     TEXT,
    p_organisation TEXT
)
RETURNS TABLE (
    "time"        TIMESTAMPTZ,
    entity        TEXT,
    metric        TEXT,
    value         DOUBLE PRECISION,
    tags          JSONB,
    department_id UUID,
    organisation_id  UUID
)
LANGUAGE plpgsql STABLE SECURITY DEFINER AS $$
DECLARE v_email TEXT;
BEGIN
    SELECT _email_for_api_token(p_token) INTO v_email;
    IF v_email IS NULL THEN RETURN; END IF;
    RETURN QUERY SELECT * FROM sensor_data_for_organisation(v_email, p_organisation);
END;
$$;

-- Data: department-level query (UUID overload)
CREATE OR REPLACE FUNCTION sensor_data_for_department_by_token(
    p_token         TEXT,
    p_department_id UUID
)
RETURNS TABLE (
    "time"        TIMESTAMPTZ,
    entity        TEXT,
    metric        TEXT,
    value         DOUBLE PRECISION,
    tags          JSONB,
    department_id UUID,
    organisation_id  UUID
)
LANGUAGE plpgsql STABLE SECURITY DEFINER AS $$
DECLARE v_email TEXT;
BEGIN
    SELECT _email_for_api_token(p_token) INTO v_email;
    IF v_email IS NULL THEN RETURN; END IF;
    RETURN QUERY SELECT * FROM sensor_data_for_department(v_email, p_department_id);
END;
$$;

-- Data: department-level query (name overload)
CREATE OR REPLACE FUNCTION sensor_data_for_department_by_token(
    p_token      TEXT,
    p_organisation  TEXT,
    p_department TEXT
)
RETURNS TABLE (
    "time"        TIMESTAMPTZ,
    entity        TEXT,
    metric        TEXT,
    value         DOUBLE PRECISION,
    tags          JSONB,
    department_id UUID,
    organisation_id  UUID
)
LANGUAGE plpgsql STABLE SECURITY DEFINER AS $$
DECLARE v_email TEXT;
BEGIN
    SELECT _email_for_api_token(p_token) INTO v_email;
    IF v_email IS NULL THEN RETURN; END IF;
    RETURN QUERY SELECT * FROM sensor_data_for_department(v_email, p_organisation, p_department);
END;
$$;

-- Data: most-recent rows across all accessible departments
CREATE OR REPLACE FUNCTION sensor_data_recent_by_token(
    p_token TEXT,
    p_limit INT DEFAULT 20
)
RETURNS TABLE (
    "time"        TIMESTAMPTZ,
    entity        TEXT,
    metric        TEXT,
    value         DOUBLE PRECISION,
    tags          JSONB,
    department_id UUID,
    organisation_id  UUID
)
LANGUAGE plpgsql STABLE SECURITY DEFINER AS $$
DECLARE v_email TEXT;
BEGIN
    SELECT _email_for_api_token(p_token) INTO v_email;
    IF v_email IS NULL THEN RETURN; END IF;
    RETURN QUERY SELECT * FROM sensor_data_recent(v_email, p_limit);
END;
$$;

-- Data: management-scoped query
CREATE OR REPLACE FUNCTION sensor_data_for_management_by_token(
    p_token      TEXT,
    p_management_id UUID
)
RETURNS TABLE (
    "time"        TIMESTAMPTZ,
    entity        TEXT,
    metric        TEXT,
    value         DOUBLE PRECISION,
    tags          JSONB,
    department_id UUID,
    organisation_id  UUID
)
LANGUAGE plpgsql STABLE SECURITY DEFINER AS $$
DECLARE v_email TEXT;
BEGIN
    SELECT _email_for_api_token(p_token) INTO v_email;
    IF v_email IS NULL THEN RETURN; END IF;
    RETURN QUERY SELECT * FROM sensor_data_for_management(v_email, p_management_id);
END;
$$;

-- ============================================================================
-- LEAF Platform - Grants
-- ============================================================================
--
-- readers  — service accounts that query data (e.g. Grafana)
-- writers  — service accounts that write data (Node-RED → sensor_data; portal → management tables)
--            Note: Telegraf publishes to MQTT (VerneMQ); Node-RED subscribes and writes to DB.
--
-- sensor_data: readers have NO direct SELECT — they must use SECURITY DEFINER
-- functions which enforce access control checks.
-- ============================================================================


-- ----------------------------------------------------------------------------
-- sensor_data + aggregates
-- ----------------------------------------------------------------------------

-- writers: full DML
GRANT SELECT, INSERT, UPDATE, DELETE ON sensor_data TO writers;

-- Management tables
GRANT SELECT
    ON organisation, department, user_account, user_organisation, user_department, management, user_management, password_reset_token, api_token, mapper_language, mapper_semantics, alarm_rule, alarm_recipient, alarm_event, setting
    TO readers;
GRANT SELECT, INSERT, UPDATE, DELETE
    ON organisation, department, user_account, user_organisation, user_department, management, user_management, password_reset_token, api_token, mapper_language, mapper_semantics, alarm_rule, alarm_recipient, alarm_event, setting
    TO writers;

-- SECURITY DEFINER functions — internal (readers only)
-- Callers are already authenticated via Grafana/portal session.
GRANT EXECUTE ON FUNCTION organisations_for_user(TEXT)                           TO readers;
GRANT EXECUTE ON FUNCTION management_for_user(TEXT)                             TO readers;
GRANT EXECUTE ON FUNCTION sensor_data_for_organisation(TEXT, UUID)               TO readers;
GRANT EXECUTE ON FUNCTION sensor_data_for_organisation(TEXT, TEXT)               TO readers;
GRANT EXECUTE ON FUNCTION sensor_data_for_department(TEXT, UUID)              TO readers;
GRANT EXECUTE ON FUNCTION sensor_data_for_department(TEXT, TEXT, TEXT)        TO readers;
GRANT EXECUTE ON FUNCTION sensor_data_for_department_per_uuid(TEXT, UUID)     TO readers;
GRANT EXECUTE ON FUNCTION sensor_data_recent(TEXT, INT)                       TO readers;
-- sensor_data_for_management(TEXT, UUID) is intentionally NOT granted to readers.
-- It is an internal helper called by sensor_data_for_management_by_token (SECURITY
-- DEFINER, runs as postgres). Email-based callers use the department functions.

-- SECURITY DEFINER functions — token-based (api_readers only)
-- The token is the authentication; no email or session required.
-- _email_for_api_token is an internal helper called within SECURITY DEFINER
-- functions running as postgres; it is not granted to any role.
GRANT EXECUTE ON FUNCTION organisations_for_token(TEXT)                                  TO api_readers;
GRANT EXECUTE ON FUNCTION management_for_token(TEXT)                                    TO api_readers;
GRANT EXECUTE ON FUNCTION sensor_data_for_organisation_by_token(TEXT, UUID)              TO api_readers;
GRANT EXECUTE ON FUNCTION sensor_data_for_organisation_by_token(TEXT, TEXT)              TO api_readers;
GRANT EXECUTE ON FUNCTION sensor_data_for_department_by_token(TEXT, UUID)             TO api_readers;
GRANT EXECUTE ON FUNCTION sensor_data_for_department_by_token(TEXT, TEXT, TEXT)       TO api_readers;
GRANT EXECUTE ON FUNCTION sensor_data_recent_by_token(TEXT, INT)                      TO api_readers;
GRANT EXECUTE ON FUNCTION sensor_data_for_management_by_token(TEXT, UUID)                TO api_readers;

-- Default privileges for future migrations
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO readers;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO writers;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO writers;


-- ----------------------------------------------------------------------------
-- leaf_portal_user — portal service account (member of writers + readers)
-- ----------------------------------------------------------------------------

DO $$ BEGIN
  IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'leaf_portal_user') THEN
    CREATE USER leaf_portal_user WITH PASSWORD 'change-me';
  END IF;
END $$;

DO $$ BEGIN
  EXECUTE 'GRANT CONNECT ON DATABASE ' || current_database() || ' TO leaf_portal_user';
END $$;
GRANT USAGE ON SCHEMA public TO leaf_portal_user;
GRANT readers TO leaf_portal_user;
GRANT writers TO leaf_portal_user;


-- ----------------------------------------------------------------------------
-- leaf_api_user — open service account for external systems (api_readers only)
--
-- External scripts / notebooks connect as this user and pass an API token to
-- every function call. They can only execute token-based functions and have
-- no direct table access and no access to email-based functions.
-- Change the password before exposing this account externally.
-- ----------------------------------------------------------------------------

DO $$ BEGIN
  IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'leaf_api_user') THEN
    CREATE USER leaf_api_user WITH PASSWORD 'change-me';
  END IF;
END $$;

DO $$ BEGIN
  EXECUTE 'GRANT CONNECT ON DATABASE ' || current_database() || ' TO leaf_api_user';
END $$;
GRANT USAGE ON SCHEMA public TO leaf_api_user;
GRANT api_readers TO leaf_api_user;


-- ----------------------------------------------------------------------------
DO $$
BEGIN
  RAISE NOTICE 'LEAF TimescaleDB initialization complete.';
  RAISE NOTICE '  Tables     : organisation, department, user_account, user_organisation, user_department, management, user_management, api_token, sensor_data';
  RAISE NOTICE '  Hypertable : sensor_data — compressed after 7 days, segmented by entity';
  RAISE NOTICE '  Functions  : sensor_data_for_organisation/department/management (UUID + name overloads)';
  RAISE NOTICE '  Access     : readers via SECURITY DEFINER functions only; writers full DML';
END $$;
