-- api: shall-issue tenancy. Owners, keys, usage.
--
-- Most sensitive schema in the database. hammy_readonly is deliberately NOT
-- granted USAGE on it - a reporting query has no business reading key material.

-- ---------------------------------------------------------------------------
-- owners
-- ---------------------------------------------------------------------------

CREATE TABLE api.owners (
    id                  bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,

    -- The abuse contact, and the only field that is genuinely required. It is
    -- what makes shall-issue workable: a channel to say "your key is doing
    -- something odd, fix it or I revoke".
    email               core.email NOT NULL UNIQUE,
    email_verified_at   timestamptz,

    -- Optional, and a scope multiplier rather than a gate. An unlicensed
    -- developer building a study tool against the exam endpoints has no
    -- callsign and no reason to get one yet.
    callsign            core.callsign,
    callsign_verified_at timestamptz,

    status              text NOT NULL DEFAULT 'active'
        CHECK (status IN ('active', 'suspended', 'closed')),

    created_at          timestamptz NOT NULL DEFAULT now(),
    updated_at          timestamptz NOT NULL DEFAULT now()
);

COMMENT ON TABLE api.owners IS 'Registrants for API keys. Holds PII from an international user base, so the deletion path in 005_privacy.sql applies here too, not just to Discord users.';

CREATE UNIQUE INDEX owners_verified_callsign_uniq
    ON api.owners (callsign)
    WHERE callsign_verified_at IS NOT NULL;

CREATE TRIGGER owners_touch
    BEFORE UPDATE ON api.owners
    FOR EACH ROW EXECUTE FUNCTION core.touch_updated_at();

-- ---------------------------------------------------------------------------
-- keys
-- ---------------------------------------------------------------------------

CREATE TABLE api.keys (
    id              bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    owner_id        bigint NOT NULL REFERENCES api.owners(id) ON DELETE CASCADE,

    -- SHA-256 of the key, computed in the application. The key itself is shown
    -- once at issue and never stored: a database dump must not be a set of
    -- working credentials. Hashing in the app rather than via pgcrypto keeps
    -- the plaintext out of the query log.
    key_hash        bytea NOT NULL UNIQUE,
    CONSTRAINT keys_hash_is_sha256 CHECK (octet_length(key_hash) = 32),

    -- Enough of the key to identify it in a UI and in a leak report, e.g.
    -- 'hmy_live_a1b2c3d4'. Not secret and not sufficient to authenticate.
    key_prefix      text NOT NULL,
    label           text,

    -- Tier 0 reference data can be near-unlimited; callsign lookup and 44net
    -- probing need much tighter ceilings. Scoping from day one means a scope
    -- can be tightened later without breaking every key.
    scopes          text[] NOT NULL DEFAULT '{}',
    CONSTRAINT keys_scopes_known CHECK (
        scopes <@ ARRAY[
            'reference',    -- band plans, prefixes, calculators
            'callsign',     -- callsign lookup, rate limited
            'spots',        -- POTA/SOTA/cluster/RBN feeds
            'propagation',
            'logbook',      -- read/write a user's own log
            'net44',        -- reachability probes from the 44net allocation
            'admin'
        ]::text[]
    ),

    quota_tier      text NOT NULL DEFAULT 'default'
        CHECK (quota_tier IN ('default', 'verified', 'trusted', 'internal')),

    status          text NOT NULL DEFAULT 'active'
        CHECK (status IN ('active', 'revoked', 'suspended')),

    created_at      timestamptz NOT NULL DEFAULT now(),
    expires_at      timestamptz,
    last_used_at    timestamptz,
    revoked_at      timestamptz,
    revoked_reason  text,

    CONSTRAINT keys_revoked_consistent CHECK (
        (status = 'revoked') = (revoked_at IS NOT NULL)
    )
);

COMMENT ON TABLE api.keys IS 'Several active keys per owner is intentional: people leak keys into public repositories, and rotating needs an overlap window where both the old and new key work.';

CREATE INDEX keys_owner_idx ON api.keys (owner_id);

-- No separate index on key_hash: the UNIQUE constraint above already builds a
-- btree over exactly that column, and the planner uses it for the lookup.

CREATE INDEX keys_expiring_idx
    ON api.keys (expires_at)
    WHERE status = 'active' AND expires_at IS NOT NULL;

-- ---------------------------------------------------------------------------
-- usage
-- ---------------------------------------------------------------------------

-- Rolled up from Redis, which is authoritative for the live rate-limit window.
-- Redis holds the current counters; this holds completed days. A Redis flush
-- loses at most the window in progress, which for abuse detection is fine.
CREATE TABLE api.usage_daily (
    key_id      bigint NOT NULL REFERENCES api.keys(id) ON DELETE CASCADE,
    day         date NOT NULL,
    endpoint    text NOT NULL,
    calls       bigint NOT NULL DEFAULT 0,
    errors      bigint NOT NULL DEFAULT 0,

    -- Distinct end users seen, not who they were. Counting them is the
    -- enumeration signal - one Discord user driving 90% of a key's traffic
    -- looks very different from fifty users sharing it - but the identities
    -- themselves are not needed and so are not kept.
    distinct_users integer NOT NULL DEFAULT 0,

    PRIMARY KEY (key_id, day, endpoint)
);

-- The flush job is an upsert:
--   INSERT INTO api.usage_daily (key_id, day, endpoint, calls)
--   VALUES ($1, $2, $3, $4)
--   ON CONFLICT (key_id, day, endpoint)
--   DO UPDATE SET calls = api.usage_daily.calls + EXCLUDED.calls;
CREATE INDEX usage_daily_day_idx ON api.usage_daily (day);
