﻿-- =============================================================================
-- Spanlens — full database initialisation script
-- =============================================================================
-- Run this once against your Supabase project to create all tables, functions,
-- triggers, RLS policies, and seed data required by Spanlens.
--
-- How to run:
--   Option A (Supabase Dashboard):
--     1. Open your project → SQL Editor → New query
--     2. Paste the entire contents of this file and click Run
--
--   Option B (psql / CI):
--     psql "postgresql://postgres:<password>@db.<ref>.supabase.co:5432/postgres" \
--       -f supabase/init.sql
--
-- This file is auto-generated from supabase/migrations/ — do not edit directly.
-- Regenerate with: node scripts/generate-init-sql.mjs
-- =============================================================================

-- -----------------------------------------------------------------------------
-- Migration: 20260420000000_initial_schema.sql
-- -----------------------------------------------------------------------------
-- Migration: initial_schema
-- Tables: organizations, projects, api_keys, provider_keys,
--         model_prices, requests, usage_daily, audit_logs

-- ────────────────────────────────────────────────────────────
-- Trigger helper: keep updated_at current
-- ────────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
  NEW.updated_at = now();
  RETURN NEW;
END;
$$;

-- ────────────────────────────────────────────────────────────
-- 1. organizations
-- ────────────────────────────────────────────────────────────
CREATE TABLE organizations (
  id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name       TEXT NOT NULL,
  owner_id   UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
  plan       TEXT NOT NULL DEFAULT 'free'
               CHECK (plan IN ('free', 'starter', 'team', 'enterprise')),
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE organizations ENABLE ROW LEVEL SECURITY;

CREATE POLICY "org_select"  ON organizations FOR SELECT USING (owner_id = auth.uid());
CREATE POLICY "org_insert"  ON organizations FOR INSERT WITH CHECK (owner_id = auth.uid());
CREATE POLICY "org_update"  ON organizations FOR UPDATE
  USING (owner_id = auth.uid()) WITH CHECK (owner_id = auth.uid());

CREATE TRIGGER organizations_updated_at
  BEFORE UPDATE ON organizations
  FOR EACH ROW EXECUTE FUNCTION update_updated_at();

-- ────────────────────────────────────────────────────────────
-- Helper: org membership check (used by RLS policies below)
-- Must be created after `organizations` exists because PG 15
-- validates function bodies at creation (check_function_bodies=on).
-- SECURITY DEFINER so it can bypass RLS on organizations itself.
-- ────────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION is_org_member(org_id UUID)
RETURNS BOOLEAN
LANGUAGE sql
SECURITY DEFINER
STABLE
AS $$
  SELECT EXISTS (
    SELECT 1 FROM organizations
    WHERE id = org_id AND owner_id = auth.uid()
  )
$$;

-- ────────────────────────────────────────────────────────────
-- 2. projects
-- ────────────────────────────────────────────────────────────
CREATE TABLE projects (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
  name            TEXT NOT NULL,
  description     TEXT,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

CREATE POLICY "project_select" ON projects
  FOR SELECT USING (is_org_member(organization_id));
CREATE POLICY "project_insert" ON projects
  FOR INSERT WITH CHECK (is_org_member(organization_id));
CREATE POLICY "project_update" ON projects
  FOR UPDATE USING (is_org_member(organization_id))
  WITH CHECK (is_org_member(organization_id));
CREATE POLICY "project_delete" ON projects
  FOR DELETE USING (is_org_member(organization_id));

CREATE TRIGGER projects_updated_at
  BEFORE UPDATE ON projects
  FOR EACH ROW EXECUTE FUNCTION update_updated_at();

-- ────────────────────────────────────────────────────────────
-- 3. api_keys  (Spanlens API keys issued to users)
-- ────────────────────────────────────────────────────────────
CREATE TABLE api_keys (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  project_id   UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
  name         TEXT NOT NULL,
  key_hash     TEXT NOT NULL UNIQUE,   -- SHA-256(raw_key)
  key_prefix   TEXT NOT NULL,          -- first 12 chars for display
  is_active    BOOLEAN NOT NULL DEFAULT true,
  last_used_at TIMESTAMPTZ,
  created_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE api_keys ENABLE ROW LEVEL SECURITY;

CREATE POLICY "api_key_select" ON api_keys FOR SELECT
  USING (
    EXISTS (
      SELECT 1 FROM projects p
      WHERE p.id = api_keys.project_id
        AND is_org_member(p.organization_id)
    )
  );
CREATE POLICY "api_key_insert" ON api_keys FOR INSERT
  WITH CHECK (
    EXISTS (
      SELECT 1 FROM projects p
      WHERE p.id = api_keys.project_id
        AND is_org_member(p.organization_id)
    )
  );
CREATE POLICY "api_key_update" ON api_keys FOR UPDATE
  USING (
    EXISTS (
      SELECT 1 FROM projects p
      WHERE p.id = api_keys.project_id
        AND is_org_member(p.organization_id)
    )
  );
CREATE POLICY "api_key_delete" ON api_keys FOR DELETE
  USING (
    EXISTS (
      SELECT 1 FROM projects p
      WHERE p.id = api_keys.project_id
        AND is_org_member(p.organization_id)
    )
  );

CREATE TRIGGER api_keys_updated_at
  BEFORE UPDATE ON api_keys
  FOR EACH ROW EXECUTE FUNCTION update_updated_at();

-- ────────────────────────────────────────────────────────────
-- 4. provider_keys  (encrypted actual OpenAI/Anthropic/Gemini keys)
-- ────────────────────────────────────────────────────────────
CREATE TABLE provider_keys (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
  provider        TEXT NOT NULL
                    CHECK (provider IN ('openai', 'anthropic', 'gemini')),
  name            TEXT NOT NULL,
  encrypted_key   TEXT NOT NULL,   -- AES-256-GCM via lib/crypto.ts
  is_active       BOOLEAN NOT NULL DEFAULT true,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE provider_keys ENABLE ROW LEVEL SECURITY;

CREATE POLICY "provider_key_select" ON provider_keys FOR SELECT
  USING (is_org_member(organization_id));
CREATE POLICY "provider_key_insert" ON provider_keys FOR INSERT
  WITH CHECK (is_org_member(organization_id));
CREATE POLICY "provider_key_update" ON provider_keys FOR UPDATE
  USING (is_org_member(organization_id));
CREATE POLICY "provider_key_delete" ON provider_keys FOR DELETE
  USING (is_org_member(organization_id));

CREATE TRIGGER provider_keys_updated_at
  BEFORE UPDATE ON provider_keys
  FOR EACH ROW EXECUTE FUNCTION update_updated_at();

-- ────────────────────────────────────────────────────────────
-- 5. model_prices  (reference table; updated via seed or admin)
-- ────────────────────────────────────────────────────────────
CREATE TABLE model_prices (
  id                      UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  provider                TEXT NOT NULL,
  model                   TEXT NOT NULL,
  prompt_price_per_1m     NUMERIC(10, 6) NOT NULL,
  completion_price_per_1m NUMERIC(10, 6) NOT NULL,
  created_at              TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at              TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (provider, model)
);

ALTER TABLE model_prices ENABLE ROW LEVEL SECURITY;

-- Public read; writes only via service_role
CREATE POLICY "model_prices_public_select" ON model_prices
  FOR SELECT USING (true);

CREATE TRIGGER model_prices_updated_at
  BEFORE UPDATE ON model_prices
  FOR EACH ROW EXECUTE FUNCTION update_updated_at();

-- ────────────────────────────────────────────────────────────
-- 6. requests  (immutable log; INSERT via supabaseAdmin only)
-- ────────────────────────────────────────────────────────────
CREATE TABLE requests (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  organization_id UUID NOT NULL REFERENCES organizations(id),
  project_id      UUID NOT NULL REFERENCES projects(id),
  api_key_id      UUID NOT NULL REFERENCES api_keys(id),
  provider        TEXT NOT NULL,
  model           TEXT NOT NULL,
  prompt_tokens   INTEGER NOT NULL DEFAULT 0,
  completion_tokens INTEGER NOT NULL DEFAULT 0,
  total_tokens    INTEGER NOT NULL DEFAULT 0,
  cost_usd        NUMERIC(12, 8),
  latency_ms      INTEGER NOT NULL,
  status_code     INTEGER NOT NULL,
  request_body    JSONB,
  response_body   JSONB,
  error_message   TEXT,
  trace_id        TEXT,
  span_id         TEXT,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE requests ENABLE ROW LEVEL SECURITY;

-- Composite indexes for dashboard queries
CREATE INDEX requests_org_created_idx     ON requests (organization_id, created_at DESC);
CREATE INDEX requests_project_created_idx ON requests (project_id, created_at DESC);

CREATE POLICY "requests_org_member_select" ON requests
  FOR SELECT USING (is_org_member(organization_id));
-- No INSERT policy → only service_role (supabaseAdmin) can write

-- ────────────────────────────────────────────────────────────
-- 7. usage_daily  (aggregates; populated by cron in Phase 2A)
-- ────────────────────────────────────────────────────────────
CREATE TABLE usage_daily (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
  project_id      UUID NOT NULL REFERENCES projects(id)      ON DELETE CASCADE,
  date            DATE NOT NULL,
  provider        TEXT NOT NULL,
  model           TEXT NOT NULL,
  request_count   INTEGER  NOT NULL DEFAULT 0,
  prompt_tokens   BIGINT   NOT NULL DEFAULT 0,
  completion_tokens BIGINT NOT NULL DEFAULT 0,
  total_tokens    BIGINT   NOT NULL DEFAULT 0,
  cost_usd        NUMERIC(14, 8) NOT NULL DEFAULT 0,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (organization_id, project_id, date, provider, model)
);

ALTER TABLE usage_daily ENABLE ROW LEVEL SECURITY;

CREATE POLICY "usage_daily_org_member_select" ON usage_daily
  FOR SELECT USING (is_org_member(organization_id));
-- INSERT/UPDATE via service_role only (cron job, Phase 2A)

CREATE TRIGGER usage_daily_updated_at
  BEFORE UPDATE ON usage_daily
  FOR EACH ROW EXECUTE FUNCTION update_updated_at();

-- ────────────────────────────────────────────────────────────
-- 8. audit_logs  (INSERT via service_role only)
-- ────────────────────────────────────────────────────────────
CREATE TABLE audit_logs (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
  user_id         UUID REFERENCES auth.users(id),
  action          TEXT NOT NULL,   -- e.g. 'api_key.create', 'provider_key.add'
  resource_type   TEXT NOT NULL,
  resource_id     TEXT,
  metadata        JSONB,
  ip_address      INET,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE audit_logs ENABLE ROW LEVEL SECURITY;

CREATE POLICY "audit_logs_org_member_select" ON audit_logs
  FOR SELECT USING (is_org_member(organization_id));
-- No INSERT policy → service_role only

-- -----------------------------------------------------------------------------
-- Migration: 20260421000000_agent_tracing.sql
-- -----------------------------------------------------------------------------
-- Migration: agent_tracing
-- Tables: traces, spans
--
-- 에이전트 실행 트레이싱용. trace = 하나의 논리적 사용자 인터랙션
-- (예: "질문 → 에이전트 실행 → 응답"), spans = 그 안의 개별 단계
-- (LLM 호출 1회, 툴 호출 1회, retrieval 1회 등).
--
-- CLAUDE.md Known Gotcha #4에 따라 spans.parent_span_id는 FK 제약 없음
-- (의도적) — LangGraph 스타일 병렬 fan-out에서 span이 순서 없이 도착해도
-- INSERT가 실패하지 않아야 함.

-- ────────────────────────────────────────────────────────────
-- 9. traces
-- ────────────────────────────────────────────────────────────
CREATE TABLE traces (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
  project_id      UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
  api_key_id      UUID REFERENCES api_keys(id) ON DELETE SET NULL,

  name            TEXT NOT NULL,
  status          TEXT NOT NULL DEFAULT 'running'
                    CHECK (status IN ('running', 'completed', 'error')),

  started_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  ended_at        TIMESTAMPTZ,
  duration_ms    INT,

  metadata        JSONB,
  error_message   TEXT,

  -- Aggregate counters refreshed by a DB trigger when spans update
  span_count         INT NOT NULL DEFAULT 0,
  total_tokens       INT NOT NULL DEFAULT 0,
  total_cost_usd     NUMERIC(12, 6) NOT NULL DEFAULT 0,

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

CREATE INDEX traces_project_created_idx
  ON traces (project_id, created_at DESC);
CREATE INDEX traces_org_started_idx
  ON traces (organization_id, started_at DESC);

ALTER TABLE traces ENABLE ROW LEVEL SECURITY;

CREATE POLICY "traces_select" ON traces
  FOR SELECT USING (is_org_member(organization_id));
CREATE POLICY "traces_insert" ON traces
  FOR INSERT WITH CHECK (is_org_member(organization_id));
CREATE POLICY "traces_update" ON traces
  FOR UPDATE USING (is_org_member(organization_id))
  WITH CHECK (is_org_member(organization_id));
CREATE POLICY "traces_delete" ON traces
  FOR DELETE USING (is_org_member(organization_id));

CREATE TRIGGER traces_updated_at
  BEFORE UPDATE ON traces
  FOR EACH ROW EXECUTE FUNCTION update_updated_at();

-- ────────────────────────────────────────────────────────────
-- 10. spans
-- ────────────────────────────────────────────────────────────
-- parent_span_id에 FK 제약을 걸지 않음 — 병렬 fan-out 지원 (의도적).
-- organization_id는 denormalized — RLS 정책이 traces를 역참조하지 않도록.
CREATE TABLE spans (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  trace_id        UUID NOT NULL REFERENCES traces(id) ON DELETE CASCADE,
  parent_span_id  UUID,  -- NO FK (by design, Known Gotcha #4)
  organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,

  name            TEXT NOT NULL,
  span_type       TEXT NOT NULL DEFAULT 'custom'
                    CHECK (span_type IN ('llm', 'tool', 'retrieval', 'embedding', 'custom')),
  status          TEXT NOT NULL DEFAULT 'running'
                    CHECK (status IN ('running', 'completed', 'error')),

  started_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  ended_at        TIMESTAMPTZ,
  duration_ms     INT,

  input           JSONB,
  output          JSONB,
  metadata        JSONB,
  error_message   TEXT,

  -- Optional link to a proxy request row — populated when span_type = 'llm'
  -- and the span was recorded via the Spanlens proxy (auto-instrumentation).
  request_id      UUID REFERENCES requests(id) ON DELETE SET NULL,

  -- Denormalized for quick span-level aggregation without joining requests
  prompt_tokens      INT NOT NULL DEFAULT 0,
  completion_tokens  INT NOT NULL DEFAULT 0,
  total_tokens       INT NOT NULL DEFAULT 0,
  cost_usd           NUMERIC(12, 6),

  created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX spans_trace_started_idx
  ON spans (trace_id, started_at);
CREATE INDEX spans_parent_idx
  ON spans (parent_span_id);
CREATE INDEX spans_request_idx
  ON spans (request_id) WHERE request_id IS NOT NULL;

ALTER TABLE spans ENABLE ROW LEVEL SECURITY;

CREATE POLICY "spans_select" ON spans
  FOR SELECT USING (is_org_member(organization_id));
CREATE POLICY "spans_insert" ON spans
  FOR INSERT WITH CHECK (is_org_member(organization_id));
CREATE POLICY "spans_update" ON spans
  FOR UPDATE USING (is_org_member(organization_id))
  WITH CHECK (is_org_member(organization_id));
CREATE POLICY "spans_delete" ON spans
  FOR DELETE USING (is_org_member(organization_id));

-- ────────────────────────────────────────────────────────────
-- 11. refresh_trace_aggregates trigger
-- ────────────────────────────────────────────────────────────
-- spans가 INSERT/UPDATE/DELETE 될 때마다 부모 trace의 집계 컬럼
-- (span_count, total_tokens, total_cost_usd, duration_ms)을 갱신.
-- 대시보드가 traces 한 번만 SELECT하면 되도록 — spans를 매번 집계하지 않게.
CREATE OR REPLACE FUNCTION refresh_trace_aggregates()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
  target_trace_id UUID;
BEGIN
  target_trace_id := COALESCE(NEW.trace_id, OLD.trace_id);

  UPDATE traces t
  SET
    span_count       = (SELECT COUNT(*) FROM spans WHERE trace_id = target_trace_id),
    total_tokens     = (SELECT COALESCE(SUM(total_tokens), 0) FROM spans WHERE trace_id = target_trace_id),
    total_cost_usd   = (SELECT COALESCE(SUM(cost_usd), 0) FROM spans WHERE trace_id = target_trace_id),
    updated_at       = now()
  WHERE t.id = target_trace_id;

  RETURN NULL;
END;
$$;

CREATE TRIGGER spans_refresh_trace_aggregates
  AFTER INSERT OR UPDATE OR DELETE ON spans
  FOR EACH ROW EXECUTE FUNCTION refresh_trace_aggregates();

-- -----------------------------------------------------------------------------
-- Migration: 20260421010000_aggregate_usage_daily_fn.sql
-- -----------------------------------------------------------------------------
-- Migration: aggregate_usage_daily_fn
-- RPC function that rolls up `requests` rows into `usage_daily` for a given date.
-- Called hourly by the Vercel cron at /cron/aggregate-usage.
--
-- Safe to call multiple times per day — ON CONFLICT on the usage_daily
-- UNIQUE(organization_id, project_id, date, provider, model) makes the
-- upsert idempotent. Later hourly runs simply overwrite with the latest
-- totals.

CREATE OR REPLACE FUNCTION aggregate_usage_daily(target_date DATE)
RETURNS INTEGER
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
  row_count INTEGER;
BEGIN
  INSERT INTO usage_daily (
    organization_id, project_id, date, provider, model,
    request_count, prompt_tokens, completion_tokens, total_tokens, cost_usd
  )
  SELECT
    organization_id,
    project_id,
    target_date AS date,
    provider,
    model,
    COUNT(*) AS request_count,
    COALESCE(SUM(prompt_tokens), 0) AS prompt_tokens,
    COALESCE(SUM(completion_tokens), 0) AS completion_tokens,
    COALESCE(SUM(total_tokens), 0) AS total_tokens,
    COALESCE(SUM(cost_usd), 0) AS cost_usd
  FROM requests
  WHERE created_at >= target_date::timestamptz
    AND created_at <  (target_date + INTERVAL '1 day')::timestamptz
    AND status_code < 400
    AND model IS NOT NULL
    AND model <> ''
  GROUP BY organization_id, project_id, provider, model
  ON CONFLICT (organization_id, project_id, date, provider, model)
  DO UPDATE SET
    request_count     = EXCLUDED.request_count,
    prompt_tokens     = EXCLUDED.prompt_tokens,
    completion_tokens = EXCLUDED.completion_tokens,
    total_tokens      = EXCLUDED.total_tokens,
    cost_usd          = EXCLUDED.cost_usd,
    updated_at        = now();

  GET DIAGNOSTICS row_count = ROW_COUNT;
  RETURN row_count;
END;
$$;

-- -----------------------------------------------------------------------------
-- Migration: 20260421020000_paddle_billing.sql
-- -----------------------------------------------------------------------------
-- Migration: paddle_billing
-- Links organizations ↔ Paddle customer / subscription. Writes flow through the
-- webhook handler (service_role); reads from the dashboard via RLS.

-- Nullable: free plan has no Paddle customer yet.
ALTER TABLE organizations
  ADD COLUMN paddle_customer_id TEXT;

CREATE INDEX organizations_paddle_customer_idx
  ON organizations (paddle_customer_id)
  WHERE paddle_customer_id IS NOT NULL;

-- Historical rows are kept on cancel for audit — current status tells us the state.
CREATE TABLE subscriptions (
  id                       UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  organization_id          UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,

  paddle_subscription_id   TEXT NOT NULL UNIQUE,
  paddle_customer_id       TEXT NOT NULL,
  paddle_price_id          TEXT NOT NULL,

  plan                     TEXT NOT NULL
                             CHECK (plan IN ('starter', 'team', 'enterprise')),
  status                   TEXT NOT NULL
                             CHECK (status IN ('active', 'trialing', 'past_due', 'paused', 'canceled')),

  current_period_start     TIMESTAMPTZ,
  current_period_end       TIMESTAMPTZ,
  cancel_at_period_end     BOOLEAN NOT NULL DEFAULT FALSE,

  metadata                 JSONB,

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

CREATE INDEX subscriptions_org_idx ON subscriptions (organization_id);
CREATE INDEX subscriptions_status_idx ON subscriptions (status) WHERE status IN ('active', 'trialing');

ALTER TABLE subscriptions ENABLE ROW LEVEL SECURITY;

CREATE POLICY "subscriptions_select" ON subscriptions
  FOR SELECT USING (is_org_member(organization_id));

CREATE TRIGGER subscriptions_updated_at
  BEFORE UPDATE ON subscriptions
  FOR EACH ROW EXECUTE FUNCTION update_updated_at();

-- -----------------------------------------------------------------------------
-- Migration: 20260421030000_alerts_and_webhooks.sql
-- -----------------------------------------------------------------------------
-- Migration: alerts_and_webhooks
--
-- 3 tables for the alerting pipeline:
--  • alerts                  — threshold configs (budget / error_rate / latency_p95)
--  • notification_channels   — delivery targets (email / slack / discord)
--  • alert_deliveries        — audit log of sends (for dedup + debugging)
--
-- Evaluator cron (GitHub Actions → /cron/evaluate-alerts) reads alerts,
-- queries requests/usage_daily to compute current metric, compares to
-- threshold, and POSTs to every active channel. cooldown_minutes prevents
-- spam; last_triggered_at is stamped on each fire.

CREATE TABLE alerts (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
  project_id      UUID REFERENCES projects(id) ON DELETE CASCADE,

  name            TEXT NOT NULL,
  type            TEXT NOT NULL CHECK (type IN ('budget', 'error_rate', 'latency_p95')),

  threshold       NUMERIC NOT NULL,
  window_minutes  INTEGER NOT NULL DEFAULT 60,

  is_active           BOOLEAN NOT NULL DEFAULT TRUE,
  last_triggered_at   TIMESTAMPTZ,
  cooldown_minutes    INTEGER NOT NULL DEFAULT 60,

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

CREATE INDEX alerts_org_idx ON alerts (organization_id) WHERE is_active = TRUE;
CREATE INDEX alerts_project_idx ON alerts (project_id) WHERE project_id IS NOT NULL;

ALTER TABLE alerts ENABLE ROW LEVEL SECURITY;

CREATE POLICY "alerts_select" ON alerts FOR SELECT USING (is_org_member(organization_id));
CREATE POLICY "alerts_insert" ON alerts FOR INSERT WITH CHECK (is_org_member(organization_id));
CREATE POLICY "alerts_update" ON alerts FOR UPDATE
  USING (is_org_member(organization_id)) WITH CHECK (is_org_member(organization_id));
CREATE POLICY "alerts_delete" ON alerts FOR DELETE USING (is_org_member(organization_id));

CREATE TRIGGER alerts_updated_at BEFORE UPDATE ON alerts
  FOR EACH ROW EXECUTE FUNCTION update_updated_at();


CREATE TABLE notification_channels (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,

  kind            TEXT NOT NULL CHECK (kind IN ('email', 'slack', 'discord')),
  target          TEXT NOT NULL,   -- email: address; slack/discord: webhook URL

  is_active       BOOLEAN NOT NULL DEFAULT TRUE,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX notification_channels_org_idx ON notification_channels (organization_id)
  WHERE is_active = TRUE;

ALTER TABLE notification_channels ENABLE ROW LEVEL SECURITY;

CREATE POLICY "channels_select" ON notification_channels
  FOR SELECT USING (is_org_member(organization_id));
CREATE POLICY "channels_insert" ON notification_channels
  FOR INSERT WITH CHECK (is_org_member(organization_id));
CREATE POLICY "channels_update" ON notification_channels
  FOR UPDATE USING (is_org_member(organization_id))
  WITH CHECK (is_org_member(organization_id));
CREATE POLICY "channels_delete" ON notification_channels
  FOR DELETE USING (is_org_member(organization_id));

CREATE TRIGGER channels_updated_at BEFORE UPDATE ON notification_channels
  FOR EACH ROW EXECUTE FUNCTION update_updated_at();


CREATE TABLE alert_deliveries (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
  alert_id        UUID NOT NULL REFERENCES alerts(id) ON DELETE CASCADE,
  channel_id      UUID NOT NULL REFERENCES notification_channels(id) ON DELETE CASCADE,

  status          TEXT NOT NULL CHECK (status IN ('sent', 'failed')),
  error_message   TEXT,
  payload         JSONB,

  created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX alert_deliveries_alert_idx ON alert_deliveries (alert_id, created_at DESC);

ALTER TABLE alert_deliveries ENABLE ROW LEVEL SECURITY;
CREATE POLICY "deliveries_select" ON alert_deliveries
  FOR SELECT USING (is_org_member(organization_id));

-- -----------------------------------------------------------------------------
-- Migration: 20260421040000_prune_logs_fn.sql
-- -----------------------------------------------------------------------------
-- Migration: prune_logs_fn
-- Called daily by /cron/prune-logs to enforce plan retention:
--   free=7d, starter=30d, team=90d, enterprise=365d

CREATE OR REPLACE FUNCTION prune_logs_by_retention()
RETURNS JSON
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
  deleted_requests INT := 0;
  deleted_spans    INT := 0;
  deleted_traces   INT := 0;
  deleted_deliveries INT := 0;
  r RECORD;
BEGIN
  FOR r IN SELECT id, plan FROM organizations LOOP
    DECLARE
      retention_days INT;
      cutoff TIMESTAMPTZ;
      row_count INT;
    BEGIN
      retention_days := CASE r.plan
        WHEN 'free' THEN 7
        WHEN 'starter' THEN 30
        WHEN 'team' THEN 90
        ELSE 365
      END;
      cutoff := now() - (retention_days || ' days')::interval;

      DELETE FROM requests WHERE organization_id = r.id AND created_at < cutoff;
      GET DIAGNOSTICS row_count = ROW_COUNT;
      deleted_requests := deleted_requests + row_count;

      DELETE FROM traces WHERE organization_id = r.id AND created_at < cutoff;
      GET DIAGNOSTICS row_count = ROW_COUNT;
      deleted_traces := deleted_traces + row_count;

      DELETE FROM alert_deliveries WHERE organization_id = r.id AND created_at < cutoff;
      GET DIAGNOSTICS row_count = ROW_COUNT;
      deleted_deliveries := deleted_deliveries + row_count;
    END;
  END LOOP;

  RETURN json_build_object(
    'deleted_requests', deleted_requests,
    'deleted_traces',   deleted_traces,
    'deleted_spans',    deleted_spans,
    'deleted_alert_deliveries', deleted_deliveries
  );
END;
$$;

-- -----------------------------------------------------------------------------
-- Migration: 20260421050000_request_flags.sql
-- -----------------------------------------------------------------------------
-- Phase 3A — security scan flags on requests
--
-- Flags are attached by lib/logger.ts at log time via lib/security-scan.ts.
-- Shape: jsonb array of { type: 'pii' | 'injection', pattern: string, sample: string }
-- Empty array when clean. We keep the column NOT NULL with default '[]'::jsonb
-- so query code can rely on array semantics without null checks.
ALTER TABLE public.requests
  ADD COLUMN IF NOT EXISTS flags jsonb NOT NULL DEFAULT '[]'::jsonb;

-- Partial index: only rows WITH flags. Empty-array rows stay out so the
-- index is small even when most traffic is clean.
CREATE INDEX IF NOT EXISTS idx_requests_flags_nonempty
  ON public.requests ((organization_id))
  WHERE jsonb_array_length(flags) > 0;

COMMENT ON COLUMN public.requests.flags IS
  'Security scan results: [{type, pattern, sample}]. Populated by lib/security-scan.ts. Empty when clean.';

-- -----------------------------------------------------------------------------
-- Migration: 20260421060000_prompt_versions.sql
-- -----------------------------------------------------------------------------
-- Phase 3A — prompt versioning (foundation for A/B comparison and model recommendation)
--
-- A "prompt" is identified by (organization_id, project_id, name). Each name has
-- many versions — each version is an immutable snapshot of `content` + `variables`.
-- Requests that use a prompt reference the specific version via requests.prompt_version_id.

CREATE TABLE IF NOT EXISTS public.prompt_versions (
  id            uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  organization_id uuid NOT NULL REFERENCES public.organizations(id) ON DELETE CASCADE,
  project_id    uuid REFERENCES public.projects(id) ON DELETE SET NULL,
  name          text NOT NULL,
  version       integer NOT NULL,
  content       text NOT NULL,
  variables     jsonb NOT NULL DEFAULT '[]'::jsonb,  -- [{ name, description, required }]
  metadata      jsonb NOT NULL DEFAULT '{}'::jsonb,
  created_by    uuid REFERENCES auth.users(id) ON DELETE SET NULL,
  created_at    timestamptz NOT NULL DEFAULT now(),
  UNIQUE (organization_id, name, version)
);

CREATE INDEX IF NOT EXISTS idx_prompt_versions_org_name
  ON public.prompt_versions (organization_id, name, version DESC);
CREATE INDEX IF NOT EXISTS idx_prompt_versions_project
  ON public.prompt_versions (project_id)
  WHERE project_id IS NOT NULL;

-- Link requests ↔ prompt_versions so A/B comparison can aggregate request metrics per version
ALTER TABLE public.requests
  ADD COLUMN IF NOT EXISTS prompt_version_id uuid REFERENCES public.prompt_versions(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_requests_prompt_version
  ON public.requests (prompt_version_id)
  WHERE prompt_version_id IS NOT NULL;

-- Row-level security: org members SELECT; INSERT via authenticated authJwt only (not anon)
ALTER TABLE public.prompt_versions ENABLE ROW LEVEL SECURITY;

CREATE POLICY "prompt_versions_select_member" ON public.prompt_versions
  FOR SELECT TO authenticated
  USING (public.is_org_member(organization_id));

CREATE POLICY "prompt_versions_insert_member" ON public.prompt_versions
  FOR INSERT TO authenticated
  WITH CHECK (public.is_org_member(organization_id));

CREATE POLICY "prompt_versions_delete_member" ON public.prompt_versions
  FOR DELETE TO authenticated
  USING (public.is_org_member(organization_id));

COMMENT ON TABLE public.prompt_versions IS
  'Immutable prompt snapshots. New version = new row. Requests may reference one version via requests.prompt_version_id.';

-- -----------------------------------------------------------------------------
-- Migration: 20260422120000_quota_warnings.sql
-- -----------------------------------------------------------------------------
-- Migration: quota_warnings
-- Track when each organization was last warned about quota usage crossing
-- 80% / 100% in the current calendar month. Used by the
-- cron-quota-warnings job to avoid duplicate emails.
--
-- Reset logic is implicit: the cron compares `*_sent_at` against the start
-- of the current UTC calendar month — stale timestamps (from a previous
-- month) are treated as "not yet sent this period" without needing an
-- explicit reset trigger.

ALTER TABLE organizations
  ADD COLUMN quota_warning_80_sent_at  TIMESTAMPTZ,
  ADD COLUMN quota_warning_100_sent_at TIMESTAMPTZ;

-- Index helps the cron job filter eligible orgs quickly when the table grows.
CREATE INDEX organizations_quota_warning_idx
  ON organizations (quota_warning_100_sent_at, quota_warning_80_sent_at);

-- -----------------------------------------------------------------------------
-- Migration: 20260422140000_subscription_overage_charges.sql
-- -----------------------------------------------------------------------------
-- Migration: subscription_overage_charges
-- Idempotency table for Paddle usage-based overage billing.
--
-- The daily cron-report-usage-overage job decides, at the end of each
-- billing period, to issue a one-time charge for the overage amount via
-- POST /subscriptions/{id}/charge. The UNIQUE (subscription_id, period_end)
-- constraint here is the core guard against double-charging.
--
-- Intended write pattern:
--   1. INSERT with status='pending' before calling Paddle
--   2. Call POST /subscriptions/{id}/charge
--   3. UPDATE with status='charged' + paddle_response on success,
--      or status='error' + error_message on failure
--
-- On cron re-run after a crash, the pending/charged/error row already
-- exists — SELECT returns it, the job skips it. Safer to under-bill
-- than to double-charge: an operator can flip a stuck `pending` or
-- `error` row to `retry` manually.

CREATE TABLE subscription_overage_charges (
  id                      UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  subscription_id         UUID NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE,
  period_start            TIMESTAMPTZ NOT NULL,
  period_end              TIMESTAMPTZ NOT NULL,
  overage_requests        INTEGER NOT NULL,
  overage_quantity        INTEGER NOT NULL, -- usually ceil(overage_requests / 1000)
  price_id                TEXT NOT NULL,
  status                  TEXT NOT NULL
                            DEFAULT 'pending'
                            CHECK (status IN ('pending', 'charged', 'error', 'retry')),
  paddle_response         JSONB,
  error_message           TEXT,
  charged_at              TIMESTAMPTZ NOT NULL DEFAULT now(),
  completed_at            TIMESTAMPTZ,
  UNIQUE (subscription_id, period_end)
);

CREATE INDEX subscription_overage_charges_status_idx
  ON subscription_overage_charges (status)
  WHERE status IN ('pending', 'error', 'retry');

ALTER TABLE subscription_overage_charges ENABLE ROW LEVEL SECURITY;

-- Dashboard read: org members can see their own overage history.
CREATE POLICY "overage_select" ON subscription_overage_charges
  FOR SELECT USING (
    EXISTS (
      SELECT 1 FROM subscriptions s
      WHERE s.id = subscription_overage_charges.subscription_id
        AND is_org_member(s.organization_id)
    )
  );

-- Writes go through service_role only — no INSERT/UPDATE/DELETE policies
-- means the anon/authenticated roles have no write access.

-- -----------------------------------------------------------------------------
-- Migration: 20260422150000_overage_policy.sql
-- -----------------------------------------------------------------------------
-- Migration: overage_policy
-- Per-organization controls for the Pattern C quota policy:
--
--   Free plan:                   always hard-blocked at limit (ignored: columns below)
--   Paid plan + allow_overage=true:
--     - usage < limit: pass
--     - usage in [limit, limit * overage_cap_multiplier): pass + accumulates overage
--     - usage >= limit * overage_cap_multiplier: hard-blocked (safety)
--   Paid plan + allow_overage=false:
--     - usage >= limit: hard-blocked (legacy Pattern A behavior)
--
-- Defaults: overage on, 5x hard cap. Starter (100K) gets 500K hard cap;
-- Team (500K) gets 2.5M hard cap. This bounds the worst-case runaway
-- monthly bill to a predictable multiple of the plan fee.

ALTER TABLE organizations
  ADD COLUMN allow_overage              BOOLEAN NOT NULL DEFAULT TRUE,
  ADD COLUMN overage_cap_multiplier     INTEGER NOT NULL DEFAULT 5
    CHECK (overage_cap_multiplier BETWEEN 1 AND 100);

-- -----------------------------------------------------------------------------
-- Migration: 20260422153000_stats_and_security_aggregation_fns.sql
-- -----------------------------------------------------------------------------
-- stats_overview: single-row aggregate for the dashboard overview cards.
-- Called by GET /api/v1/stats/overview.
CREATE OR REPLACE FUNCTION stats_overview(
  p_org_id    UUID,
  p_project_id UUID DEFAULT NULL,
  p_from      TIMESTAMPTZ DEFAULT NULL,
  p_to        TIMESTAMPTZ DEFAULT NULL
) RETURNS TABLE (
  total_requests     BIGINT,
  success_requests   BIGINT,
  error_requests     BIGINT,
  total_cost_usd     NUMERIC,
  total_tokens       BIGINT,
  prompt_tokens      BIGINT,
  completion_tokens  BIGINT,
  avg_latency_ms     NUMERIC
)
LANGUAGE sql STABLE AS $$
  SELECT
    COUNT(*)                                                      AS total_requests,
    COUNT(*) FILTER (WHERE status_code < 400)                     AS success_requests,
    COUNT(*) FILTER (WHERE status_code >= 400)                    AS error_requests,
    COALESCE(SUM(cost_usd), 0)                                    AS total_cost_usd,
    COALESCE(SUM(total_tokens), 0)                                AS total_tokens,
    COALESCE(SUM(prompt_tokens), 0)                               AS prompt_tokens,
    COALESCE(SUM(completion_tokens), 0)                           AS completion_tokens,
    COALESCE(AVG(latency_ms), 0)                                  AS avg_latency_ms
  FROM requests
  WHERE organization_id = p_org_id
    AND (p_project_id IS NULL OR project_id = p_project_id)
    AND created_at >= COALESCE(p_from, NOW() - INTERVAL '30 days')
    AND created_at <= COALESCE(p_to, NOW());
$$;

-- security_summary: counts flagged requests by flag type and pattern.
-- Called by GET /api/v1/security/summary.
-- flags column is JSONB array of objects: [{type, pattern, sample}, ...]
CREATE OR REPLACE FUNCTION security_summary(
  p_org_id UUID,
  p_hours  INT DEFAULT 24
) RETURNS TABLE (
  flag_type TEXT,
  pattern   TEXT,
  count     BIGINT
)
LANGUAGE sql STABLE AS $$
  SELECT
    (flag->>'type')::text    AS flag_type,
    (flag->>'pattern')::text AS pattern,
    COUNT(*)                 AS count
  FROM requests,
       LATERAL jsonb_array_elements(flags) AS flag
  WHERE organization_id = p_org_id
    AND jsonb_array_length(flags) > 0
    AND created_at >= NOW() - (p_hours || ' hours')::INTERVAL
  GROUP BY 1, 2
  ORDER BY count DESC;
$$;

-- -----------------------------------------------------------------------------
-- Migration: 20260423095500_fix_stats_timeseries_null_params.sql
-- -----------------------------------------------------------------------------
-- Fix: stats_timeseries returned empty when from/to were passed as NULL.
--
-- The original function used `created_at >= p_from` directly. When the
-- caller passes p_from = NULL, Postgres evaluates `created_at >= NULL` as
-- NULL (not TRUE), so the WHERE clause filters out every row → empty result.
--
-- The default values (`DEFAULT (NOW() - INTERVAL '30 days')`) only apply
-- when the parameter is OMITTED — passing explicit NULL bypasses them. The
-- server code does `p_from: from ?? null`, which always passes NULL when
-- the query string is absent, so the defaults never kicked in for the
-- common case (dashboard home with no filters).
--
-- Fix: COALESCE inside the function. Handles both omitted-params and
-- explicit-null-params, falling back to the same "last 30 days" range the
-- pre-RPC JS implementation used.
--
-- Verified post-deploy: stats_timeseries(<org_id>, NULL, NULL, NULL)
-- returns the expected daily aggregates again.

CREATE OR REPLACE FUNCTION stats_timeseries(
  p_org_id UUID,
  p_project_id UUID DEFAULT NULL,
  p_from TIMESTAMPTZ DEFAULT NULL,
  p_to   TIMESTAMPTZ DEFAULT NULL
) RETURNS TABLE (
  day       DATE,
  requests  BIGINT,
  cost      NUMERIC,
  tokens    BIGINT,
  errors    BIGINT
)
LANGUAGE sql STABLE AS $$
  SELECT
    date_trunc('day', created_at)::date                  AS day,
    COUNT(*)                                             AS requests,
    COALESCE(SUM(cost_usd), 0)                           AS cost,
    COALESCE(SUM(total_tokens), 0)                       AS tokens,
    COUNT(*) FILTER (WHERE status_code >= 400)           AS errors
  FROM requests
  WHERE organization_id = p_org_id
    AND (p_project_id IS NULL OR project_id = p_project_id)
    AND created_at >= COALESCE(p_from, NOW() - INTERVAL '30 days')
    AND created_at <= COALESCE(p_to, NOW())
  GROUP BY 1
  ORDER BY 1;
$$;

-- -----------------------------------------------------------------------------
-- Migration: 20260423110000_requests_provider_key_id.sql
-- -----------------------------------------------------------------------------
-- Track which provider_keys row authenticated each upstream call.
-- An org may have multiple keys per provider over time (rotation, A/B,
-- multi-account); this column lets the dashboard show
-- "openai (prod-key-2)" instead of just "openai" so the user knows which
-- credential was used.
--
-- Nullable: existing historical rows have no value, and proxy fallbacks
-- (e.g. self-host with environment-variable key, no provider_keys row) may
-- not have one. ON DELETE SET NULL preserves the request log when a key is
-- revoked.

ALTER TABLE requests
  ADD COLUMN IF NOT EXISTS provider_key_id UUID REFERENCES provider_keys(id) ON DELETE SET NULL;

CREATE INDEX IF NOT EXISTS requests_provider_key_idx
  ON requests (provider_key_id)
  WHERE provider_key_id IS NOT NULL;

-- -----------------------------------------------------------------------------
-- Migration: 20260423110100_saved_filters.sql
-- -----------------------------------------------------------------------------
-- Per-user named filter bookmarks for the /requests dashboard.
-- Lets users save "prod errors yesterday" type queries and re-apply with one
-- click. Scope is per-user (not org) so each team member has their own list.

CREATE TABLE saved_filters (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id         UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
  organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
  name            TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 80),
  /* JSONB: { provider?, model?, status?, projectId?, providerKeyId?, from?, to? } */
  filters         JSONB NOT NULL DEFAULT '{}'::jsonb,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (user_id, name)
);

CREATE INDEX saved_filters_user_idx ON saved_filters (user_id);

ALTER TABLE saved_filters ENABLE ROW LEVEL SECURITY;

CREATE POLICY "saved_filters_select" ON saved_filters
  FOR SELECT USING (user_id = auth.uid());
CREATE POLICY "saved_filters_insert" ON saved_filters
  FOR INSERT WITH CHECK (user_id = auth.uid());
CREATE POLICY "saved_filters_delete" ON saved_filters
  FOR DELETE USING (user_id = auth.uid());

-- -----------------------------------------------------------------------------
-- Migration: 20260423120000_anomaly_events.sql
-- -----------------------------------------------------------------------------
-- Persisted snapshot of anomalies detected by the daily cron. Lets the
-- dashboard show "anomaly history over the last N days" — patterns like
-- "every Tuesday at lunchtime gpt-4o latency spikes" become visible.
--
-- Idempotency: each (org, day, provider, model, kind) combo gets at most
-- ONE row per day. The cron's UPSERT relies on the unique constraint.

CREATE TABLE anomaly_events (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
  detected_on     DATE NOT NULL,
  provider        TEXT NOT NULL,
  model           TEXT NOT NULL,
  kind            TEXT NOT NULL CHECK (kind IN ('latency', 'cost', 'error_rate')),
  current_value   NUMERIC NOT NULL,
  baseline_mean   NUMERIC NOT NULL,
  baseline_stddev NUMERIC NOT NULL,
  deviations      NUMERIC NOT NULL,
  sample_count    INTEGER NOT NULL,
  reference_count INTEGER NOT NULL,
  detected_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (organization_id, detected_on, provider, model, kind)
);

CREATE INDEX anomaly_events_org_date_idx
  ON anomaly_events (organization_id, detected_on DESC);

ALTER TABLE anomaly_events ENABLE ROW LEVEL SECURITY;

CREATE POLICY "anomaly_events_select" ON anomaly_events
  FOR SELECT USING (is_org_member(organization_id));
-- writes: service_role only (no INSERT/UPDATE/DELETE policy)

-- -----------------------------------------------------------------------------
-- Migration: 20260423140000_provider_keys_project_scope.sql
-- -----------------------------------------------------------------------------
-- Migration: provider_keys_project_scope
-- Adds optional project_id to provider_keys so each project can have its own
-- OpenAI/Anthropic/Gemini key. When project_id IS NULL the row acts as the
-- org-level default (fallback when no project-specific key exists).

-- ────────────────────────────────────────────────────────────
-- 1. Add project_id column (NULL = org-level default)
-- ────────────────────────────────────────────────────────────
ALTER TABLE provider_keys
  ADD COLUMN project_id UUID REFERENCES projects(id) ON DELETE CASCADE;

-- ────────────────────────────────────────────────────────────
-- 2. Unique scope: one active key per (org, project_id, provider).
--    NULL project_id collapses to a sentinel UUID so Postgres treats all
--    org-defaults as a single slot per provider.
-- ────────────────────────────────────────────────────────────
CREATE UNIQUE INDEX provider_keys_scope_active_unique
  ON provider_keys (
    organization_id,
    COALESCE(project_id, '00000000-0000-0000-0000-000000000000'::uuid),
    provider
  )
  WHERE is_active = true;

-- ────────────────────────────────────────────────────────────
-- 3. Lookup index for the project-scoped proxy resolver
-- ────────────────────────────────────────────────────────────
CREATE INDEX provider_keys_project_lookup
  ON provider_keys (project_id, provider)
  WHERE is_active = true AND project_id IS NOT NULL;

-- -----------------------------------------------------------------------------
-- Migration: 20260424000000_anomaly_acks.sql
-- -----------------------------------------------------------------------------
-- Migration: anomaly_acks
-- Tracks which live anomalies the user has acknowledged so the UI can
-- suppress or de-emphasize them until they re-fire with new data.

CREATE TABLE anomaly_acks (
  organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
  provider        TEXT NOT NULL,
  model           TEXT NOT NULL,
  kind            TEXT NOT NULL CHECK (kind IN ('latency', 'cost', 'error_rate')),
  acknowledged_by UUID REFERENCES auth.users(id),
  acknowledged_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  PRIMARY KEY (organization_id, provider, model, kind)
);

ALTER TABLE anomaly_acks ENABLE ROW LEVEL SECURITY;

CREATE POLICY "anomaly_acks_select" ON anomaly_acks FOR SELECT
  USING (is_org_member(organization_id));
CREATE POLICY "anomaly_acks_insert" ON anomaly_acks FOR INSERT
  WITH CHECK (is_org_member(organization_id));
CREATE POLICY "anomaly_acks_update" ON anomaly_acks FOR UPDATE
  USING (is_org_member(organization_id));
CREATE POLICY "anomaly_acks_delete" ON anomaly_acks FOR DELETE
  USING (is_org_member(organization_id));

-- -----------------------------------------------------------------------------
-- Migration: 20260425000000_org_members.sql
-- -----------------------------------------------------------------------------
-- Multi-user organizations: org_members + invitations + per-user dismissals.
--
-- Before this migration, `organizations.owner_id` was the single user allowed
-- into an org. This migration introduces a proper membership table with roles
-- (admin/editor/viewer), and rewrites `is_org_member()` to check it.
--
-- Existing owners are backfilled as admins so nothing breaks for current users.
-- organizations.owner_id is kept for now — it still points at the org creator
-- and is used as an anchor for backfill + a fast "who created this" shortcut.
-- A future cleanup can drop it once all code paths have migrated.

-- ────────────────────────────────────────────────────────────
-- 1. org_role enum
-- ────────────────────────────────────────────────────────────
CREATE TYPE org_role AS ENUM ('admin', 'editor', 'viewer');

-- ────────────────────────────────────────────────────────────
-- 2. org_members (membership + role)
-- ────────────────────────────────────────────────────────────
CREATE TABLE org_members (
  organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
  user_id         UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
  role            org_role NOT NULL DEFAULT 'viewer',
  invited_by      UUID REFERENCES auth.users(id) ON DELETE SET NULL,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  PRIMARY KEY (organization_id, user_id)
);

CREATE INDEX idx_org_members_user ON org_members(user_id);
CREATE INDEX idx_org_members_org_role ON org_members(organization_id, role);

-- Backfill: every existing org owner becomes an admin in the new table.
INSERT INTO org_members (organization_id, user_id, role)
SELECT id, owner_id, 'admin'::org_role
FROM organizations
ON CONFLICT DO NOTHING;

ALTER TABLE org_members ENABLE ROW LEVEL SECURITY;

-- Read: anyone in the same org can see all members (for the team list).
CREATE POLICY "org_members_select" ON org_members
  FOR SELECT USING (
    organization_id IN (
      SELECT organization_id FROM org_members WHERE user_id = auth.uid()
    )
  );

-- Write: locked down in RLS — the server uses service_role for these ops
-- and enforces role checks (admin-only) + last-admin protection in app code.
-- We do NOT grant INSERT/UPDATE/DELETE to authenticated users here: going
-- through supabaseAdmin is the single code path, which keeps the logic
-- centralized and avoids RLS-bypass footguns.

-- ────────────────────────────────────────────────────────────
-- 3. Rewrite is_org_member() to consult org_members
--    (replaces the owner_id check in the initial schema)
-- ────────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION is_org_member(org_id UUID)
RETURNS BOOLEAN
LANGUAGE sql
SECURITY DEFINER
STABLE
AS $$
  SELECT EXISTS (
    SELECT 1 FROM org_members
    WHERE organization_id = org_id AND user_id = auth.uid()
  )
$$;

-- ────────────────────────────────────────────────────────────
-- 4. org_invitations (email-based, 7-day expiry)
--    token_hash is sha256(token). The raw token lives only in the
--    emailed URL — never in the DB. That way a DB leak can't be
--    turned into working invite links.
-- ────────────────────────────────────────────────────────────
CREATE TABLE org_invitations (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
  email           TEXT NOT NULL,
  role            org_role NOT NULL,
  token_hash      TEXT NOT NULL UNIQUE,
  invited_by      UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
  expires_at      TIMESTAMPTZ NOT NULL,
  accepted_at     TIMESTAMPTZ,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_invitations_org_pending
  ON org_invitations(organization_id)
  WHERE accepted_at IS NULL;

CREATE INDEX idx_invitations_email_pending
  ON org_invitations(lower(email))
  WHERE accepted_at IS NULL;

ALTER TABLE org_invitations ENABLE ROW LEVEL SECURITY;

-- Members of the org can see pending invitations for their org.
CREATE POLICY "invitations_select" ON org_invitations
  FOR SELECT USING (is_org_member(organization_id));

-- Writes go through supabaseAdmin + server-side role check (admin-only).

-- ────────────────────────────────────────────────────────────
-- 5. attn_dismissals — per-user dismiss state for dashboard
--    "Needs attention" cards. A dismissed card stays hidden for
--    THAT user only, in every browser, forever (until the card_key
--    changes, e.g. a new anomaly appears).
-- ────────────────────────────────────────────────────────────
CREATE TABLE attn_dismissals (
  organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
  user_id         UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
  card_key        TEXT NOT NULL,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  PRIMARY KEY (organization_id, user_id, card_key)
);

CREATE INDEX idx_attn_dismissals_user
  ON attn_dismissals(user_id, organization_id);

ALTER TABLE attn_dismissals ENABLE ROW LEVEL SECURITY;

-- Users can read/write their own dismissals only.
CREATE POLICY "attn_dismissals_select_own" ON attn_dismissals
  FOR SELECT USING (user_id = auth.uid());

CREATE POLICY "attn_dismissals_insert_own" ON attn_dismissals
  FOR INSERT WITH CHECK (
    user_id = auth.uid() AND is_org_member(organization_id)
  );

CREATE POLICY "attn_dismissals_delete_own" ON attn_dismissals
  FOR DELETE USING (user_id = auth.uid());

-- -----------------------------------------------------------------------------
-- Migration: 20260425120000_user_profiles.sql
-- -----------------------------------------------------------------------------
-- Onboarding profile data per user.
--
-- Captures the answers to the post-signup survey (use case + role) and
-- doubles as the "has the user finished onboarding?" flag via onboarded_at.
-- The dashboard layout uses this flag to decide whether to show the app or
-- redirect to /onboarding.
--
-- Designed as a separate table (not a column on auth.users) so we can:
--   • iterate on the survey schema without touching auth tables
--   • drop the table during a future product pivot without an auth migration
--   • have RLS policies attached to it independently of Supabase's managed
--     auth schema (which we cannot freely modify).

CREATE TABLE user_profiles (
  user_id        UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,

  -- "What are you building?" — chatbot / rag / agent / code_assistant /
  -- internal_tool / other. Free-text stored so we can add new options without
  -- a migration; the API layer validates against an allowlist.
  use_case       TEXT,

  -- "What's your role?" — engineer / product / founder / researcher / other.
  role           TEXT,

  -- Stamped when the user completes (or skips) the survey. Until set, the
  -- dashboard layout sends them to /onboarding. NULL means "still in
  -- onboarding" — a row may exist without onboarded_at if we ever pre-create
  -- profiles for invited users, but right now we only INSERT on completion.
  onboarded_at   TIMESTAMPTZ,

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

CREATE INDEX idx_user_profiles_onboarded ON user_profiles(onboarded_at)
  WHERE onboarded_at IS NULL;

-- updated_at trigger so any future PATCH to use_case / role bumps the column
-- without the API having to remember.
CREATE OR REPLACE FUNCTION set_user_profiles_updated_at()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
  NEW.updated_at := now();
  RETURN NEW;
END;
$$;

CREATE TRIGGER user_profiles_updated_at
  BEFORE UPDATE ON user_profiles
  FOR EACH ROW EXECUTE FUNCTION set_user_profiles_updated_at();

ALTER TABLE user_profiles ENABLE ROW LEVEL SECURITY;

-- Self-only access. Server writes go through supabaseAdmin.
CREATE POLICY "user_profiles_select_own" ON user_profiles
  FOR SELECT USING (user_id = auth.uid());

-- -----------------------------------------------------------------------------
-- Migration: 20260425130000_fix_org_members_rls_recursion.sql
-- -----------------------------------------------------------------------------
-- The org_members SELECT policy from 20260425000000_org_members.sql
-- self-referenced the same table:
--
--   USING ( organization_id IN
--           (SELECT organization_id FROM org_members WHERE user_id = auth.uid()) )
--
-- PostgreSQL detects this as infinite recursion and fails the query with
-- 42P17 ("infinite recursion detected in policy"). Server-side calls go
-- through supabaseAdmin (service_role, RLS bypass) so the bug never
-- surfaced for the dashboard UI; but any client-side `from('org_members')`
-- query — or even an incidental REST API hit — blows up.
--
-- Replace with a simple self-row policy: each authenticated user can read
-- ONLY their own org_members rows (used to check "what workspaces am I in?"
-- without leaking other members' membership). Listing teammates of an org
-- continues to go through the server's GET /api/v1/organizations/:id/members
-- endpoint, which uses service_role and enforces is_org_member() in app code.

DROP POLICY IF EXISTS "org_members_select" ON org_members;

CREATE POLICY "org_members_select_self" ON org_members
  FOR SELECT USING (user_id = auth.uid());

-- -----------------------------------------------------------------------------
-- Migration: 20260427000000_waitlist.sql
-- -----------------------------------------------------------------------------
-- Waitlist table for collecting alpha/early-access sign-ups
-- Status flow: pending → invited (admin sends invite) | rejected

CREATE TABLE IF NOT EXISTS waitlist (
  id         UUID        DEFAULT gen_random_uuid() PRIMARY KEY,
  email      TEXT        NOT NULL,
  name       TEXT,
  company    TEXT,
  use_case   TEXT,
  status     TEXT        NOT NULL DEFAULT 'pending'
               CHECK (status IN ('pending', 'invited', 'rejected')),
  created_at TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE(email)
);

ALTER TABLE waitlist ENABLE ROW LEVEL SECURITY;

-- Only service_role can read (admin dashboard via supabaseAdmin)
-- No anon SELECT or INSERT policies — inserts go through the server API

-- -----------------------------------------------------------------------------
-- Migration: 20260427010000_proxy_overhead_ms.sql
-- -----------------------------------------------------------------------------
-- Track proxy overhead separately from provider latency.
-- latency_ms (existing) = time for the upstream provider fetch.
-- proxy_overhead_ms (new) = our pre-fetch processing time
--   (auth + key decryption + body parsing) measured in the proxy handler.
-- Overhead target: p95 < 50ms.

ALTER TABLE requests
  ADD COLUMN IF NOT EXISTS proxy_overhead_ms INTEGER;

-- -----------------------------------------------------------------------------
-- Migration: 20260427150000_connect_webhooks.sql
-- -----------------------------------------------------------------------------
-- Migration: connect_webhooks
--
-- 2 tables for the Connect / Webhooks feature:
--  • webhooks           — endpoint configs per organization
--  • webhook_deliveries — delivery audit log (sent by service role)
--
-- RLS follows the is_org_member() SECURITY DEFINER pattern used throughout
-- the codebase (see alerts_and_webhooks migration).  We NEVER write a
-- sub-SELECT on the same table in a USING clause (gotcha #14).

CREATE TABLE webhooks (
  id              UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
  organization_id UUID        NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
  name            TEXT        NOT NULL,
  url             TEXT        NOT NULL,
  secret          TEXT        NOT NULL,
  events          TEXT[]      NOT NULL DEFAULT ARRAY['request.created'],
  is_active       BOOLEAN     NOT NULL DEFAULT TRUE,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX webhooks_org_idx ON webhooks (organization_id) WHERE is_active = TRUE;

ALTER TABLE webhooks ENABLE ROW LEVEL SECURITY;

CREATE POLICY "webhooks_select" ON webhooks
  FOR SELECT USING (is_org_member(organization_id));
CREATE POLICY "webhooks_insert" ON webhooks
  FOR INSERT WITH CHECK (is_org_member(organization_id));
CREATE POLICY "webhooks_update" ON webhooks
  FOR UPDATE USING (is_org_member(organization_id))
  WITH CHECK (is_org_member(organization_id));
CREATE POLICY "webhooks_delete" ON webhooks
  FOR DELETE USING (is_org_member(organization_id));


-- webhook_deliveries: written only by service role (supabaseAdmin), read by org members.
-- There is no direct FK to organizations — we traverse webhooks instead so
-- the SELECT policy can check org membership without self-referencing a table.
CREATE TABLE webhook_deliveries (
  id             UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
  webhook_id     UUID        NOT NULL REFERENCES webhooks(id) ON DELETE CASCADE,
  event_type     TEXT        NOT NULL,
  status         TEXT        NOT NULL CHECK (status IN ('success', 'failed')),
  http_status    INTEGER,
  error_message  TEXT,
  duration_ms    INTEGER,
  delivered_at   TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX webhook_deliveries_webhook_idx ON webhook_deliveries (webhook_id, delivered_at DESC);

ALTER TABLE webhook_deliveries ENABLE ROW LEVEL SECURITY;

-- Org members can read deliveries for webhooks in their organisation.
-- We join through webhooks and use is_org_member() to avoid recursion.
CREATE POLICY "webhook_deliveries_select" ON webhook_deliveries
  FOR SELECT USING (
    webhook_id IN (
      SELECT id FROM webhooks WHERE is_org_member(organization_id)
    )
  );

-- Service role inserts delivery records (RLS bypassed by supabaseAdmin).
-- Explicit policy so that non-service-role tokens cannot insert.
CREATE POLICY "webhook_deliveries_insert_service" ON webhook_deliveries
  FOR INSERT WITH CHECK (TRUE);

-- -----------------------------------------------------------------------------
-- Migration: 20260428023000_security_settings.sql
-- -----------------------------------------------------------------------------
-- Security/notification settings for stale-key reminders and leak detection.
--
-- Both features are notification-only — no auto-revoke. Stale-key reminders
-- run as a weekly digest; leak detection runs daily and emails immediately
-- on the first scan that returns "leaked" for a given key (dedup via the
-- new provider_key_leak_scans table).

ALTER TABLE organizations
  ADD COLUMN stale_key_alerts_enabled BOOLEAN NOT NULL DEFAULT true,
  ADD COLUMN stale_key_threshold_days INTEGER NOT NULL DEFAULT 90
    CHECK (stale_key_threshold_days BETWEEN 30 AND 365),
  ADD COLUMN leak_detection_enabled   BOOLEAN NOT NULL DEFAULT false;

-- One row per scan attempt. `result='leaked'` rows with non-null notified_at
-- mean we already emailed admins for this incident — subsequent scans of
-- the same still-leaked key won't re-spam.
CREATE TABLE provider_key_leak_scans (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  provider_key_id UUID NOT NULL REFERENCES provider_keys(id) ON DELETE CASCADE,
  organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
  scanned_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  result          TEXT NOT NULL CHECK (result IN ('clean', 'leaked', 'error')),
  notified_at     TIMESTAMPTZ,
  details         JSONB
);

CREATE INDEX idx_pkls_key_time ON provider_key_leak_scans(provider_key_id, scanned_at DESC);
CREATE INDEX idx_pkls_org_time ON provider_key_leak_scans(organization_id, scanned_at DESC);

ALTER TABLE provider_key_leak_scans ENABLE ROW LEVEL SECURITY;

-- Members can read their org's scan history. All writes go through the
-- service-role admin client in the cron handler — no INSERT/UPDATE/DELETE
-- policies needed (deny-by-default for non-admin roles).
CREATE POLICY "leak_scans_select" ON provider_key_leak_scans FOR SELECT
  USING (is_org_member(organization_id));

-- Index hint for the stale-key digest cron, which does
-- MAX(created_at) GROUP BY provider_key_id over `requests`. We already index
-- (organization_id, created_at), but provider_key_id alone helps when the
-- workspace has lots of requests across many keys.
CREATE INDEX IF NOT EXISTS idx_requests_provider_key_id_created_at
  ON requests(provider_key_id, created_at DESC)
  WHERE provider_key_id IS NOT NULL;

-- -----------------------------------------------------------------------------
-- Migration: 20260428120000_stats_models_fn.sql
-- -----------------------------------------------------------------------------
-- stats_models: per-model aggregation for the dashboard /models endpoint.
-- Replaces the previous in-memory JS aggregation in apps/server/src/api/stats.ts.
-- Composite index on (organization_id, created_at DESC) already exists from
-- migration 20260422153000_stats_and_security_aggregation_fns.sql.

CREATE OR REPLACE FUNCTION stats_models(
  p_org_id     UUID,
  p_project_id UUID        DEFAULT NULL,
  p_from       TIMESTAMPTZ DEFAULT NULL,
  p_to         TIMESTAMPTZ DEFAULT NULL
) RETURNS TABLE (
  provider       TEXT,
  model          TEXT,
  requests       BIGINT,
  total_cost_usd NUMERIC,
  avg_latency_ms NUMERIC,
  error_rate     NUMERIC
)
LANGUAGE sql STABLE AS $$
  SELECT
    provider,
    model,
    COUNT(*)                                                        AS requests,
    COALESCE(SUM(cost_usd), 0)                                      AS total_cost_usd,
    COALESCE(AVG(latency_ms), 0)                                    AS avg_latency_ms,
    COALESCE(
      AVG(CASE WHEN status_code >= 400 THEN 1.0 ELSE 0.0 END), 0
    )                                                               AS error_rate
  FROM requests
  WHERE organization_id = p_org_id
    AND (p_project_id IS NULL OR project_id = p_project_id)
    AND created_at >= COALESCE(p_from, NOW() - INTERVAL '30 days')
    AND created_at <= COALESCE(p_to, NOW())
  GROUP BY provider, model
  ORDER BY total_cost_usd DESC;
$$;

-- -----------------------------------------------------------------------------
-- Migration: 20260428180000_stats_timeseries_granularity.sql
-- -----------------------------------------------------------------------------
-- Fix: stats_timeseries always bucketed at day granularity regardless of the
-- selected time range. 1h / 24h views showed a single daily bucket instead of
-- per-hour data points, making the chart nearly useless for short ranges.
--
-- Changes:
--   • Add p_granularity TEXT DEFAULT 'day' parameter.
--     Server auto-selects 'hour' for ranges ≤ 48h, 'day' otherwise.
--   • Return type of `day` changed from DATE → TIMESTAMPTZ so that hourly
--     buckets carry time information (e.g. "2026-04-28T14:00:00+00:00").
--     Existing callers that do r.day.slice(0,10) continue to work.

CREATE OR REPLACE FUNCTION stats_timeseries(
  p_org_id     UUID,
  p_project_id UUID        DEFAULT NULL,
  p_from       TIMESTAMPTZ DEFAULT NULL,
  p_to         TIMESTAMPTZ DEFAULT NULL,
  p_granularity TEXT       DEFAULT 'day'
) RETURNS TABLE (
  day       TIMESTAMPTZ,
  requests  BIGINT,
  cost      NUMERIC,
  tokens    BIGINT,
  errors    BIGINT
)
LANGUAGE sql STABLE AS $$
  SELECT
    date_trunc(p_granularity, created_at)          AS day,
    COUNT(*)                                        AS requests,
    COALESCE(SUM(cost_usd), 0)                      AS cost,
    COALESCE(SUM(total_tokens), 0)                  AS tokens,
    COUNT(*) FILTER (WHERE status_code >= 400)      AS errors
  FROM requests
  WHERE organization_id = p_org_id
    AND (p_project_id IS NULL OR project_id = p_project_id)
    AND created_at >= COALESCE(p_from, NOW() - INTERVAL '30 days')
    AND created_at <= COALESCE(p_to, NOW())
  GROUP BY 1
  ORDER BY 1;
$$;

-- -----------------------------------------------------------------------------
-- Migration: 20260428181000_drop_old_stats_timeseries.sql
-- -----------------------------------------------------------------------------
-- The previous migration (20260428180000) used CREATE OR REPLACE with a new
-- 5th parameter (p_granularity TEXT DEFAULT 'day'). Because the parameter
-- count changed, PostgreSQL created a SECOND overloaded function instead of
-- replacing the original 4-parameter version. PostgREST sees two functions
-- with the same name → ambiguity → 500 on any call to that function.
--
-- Fix: drop the old 4-parameter signature. The new 5-parameter version
-- already has DEFAULT 'day', so all existing callers (spend-forecast, etc.)
-- continue to work without passing p_granularity.

DROP FUNCTION IF EXISTS stats_timeseries(UUID, UUID, TIMESTAMPTZ, TIMESTAMPTZ);

-- -----------------------------------------------------------------------------
-- Migration: 20260429120000_detect_anomaly_stats_fn.sql
-- -----------------------------------------------------------------------------
-- DB-side aggregation for anomaly detection.
--
-- Replaces the previous pattern of fetching all raw rows into Node.js memory
-- and computing mean/stddev in JavaScript. Instead, PostgreSQL computes the
-- aggregates in a single GROUP BY scan and returns one row per (provider, model).
--
-- Parameters:
--   p_org_id     — organization to scope the query
--   p_ref_start  — start of reference window (e.g. now - 7d)
--   p_obs_start  — start of observation window (e.g. now - 1h); rows before
--                  this timestamp are the reference set
--   p_project_id — optional project scope (NULL = all projects)
--
-- Latency + cost are aggregated over success-only rows (status_code < 400)
-- so that a 500-storm doesn't poison the latency/cost baseline.
-- Error rate is aggregated over all rows (Bernoulli proportion).

CREATE OR REPLACE FUNCTION detect_anomaly_stats(
  p_org_id      UUID,
  p_ref_start   TIMESTAMPTZ,
  p_obs_start   TIMESTAMPTZ,
  p_project_id  UUID DEFAULT NULL
)
RETURNS TABLE (
  provider            TEXT,
  model               TEXT,
  -- Latency (success-only)
  obs_latency_mean    DOUBLE PRECISION,
  obs_latency_count   BIGINT,
  ref_latency_mean    DOUBLE PRECISION,
  ref_latency_stddev  DOUBLE PRECISION,
  ref_latency_count   BIGINT,
  -- Cost (success-only)
  obs_cost_mean       DOUBLE PRECISION,
  obs_cost_count      BIGINT,
  ref_cost_mean       DOUBLE PRECISION,
  ref_cost_stddev     DOUBLE PRECISION,
  ref_cost_count      BIGINT,
  -- Error rate (all rows)
  obs_error_rate      DOUBLE PRECISION,
  obs_all_count       BIGINT,
  ref_error_rate      DOUBLE PRECISION,
  ref_error_stddev    DOUBLE PRECISION,
  ref_all_count       BIGINT
)
LANGUAGE sql
STABLE
AS $$
  SELECT
    r.provider,
    r.model,
    -- ── Latency ────────────────────────────────────────────────────────────
    AVG(r.latency_ms)         FILTER (WHERE r.created_at >= p_obs_start
                                        AND r.status_code < 400
                                        AND r.latency_ms  IS NOT NULL)::DOUBLE PRECISION,
    COUNT(r.latency_ms)       FILTER (WHERE r.created_at >= p_obs_start
                                        AND r.status_code < 400
                                        AND r.latency_ms  IS NOT NULL),
    AVG(r.latency_ms)         FILTER (WHERE r.created_at <  p_obs_start
                                        AND r.status_code < 400
                                        AND r.latency_ms  IS NOT NULL)::DOUBLE PRECISION,
    STDDEV_SAMP(r.latency_ms) FILTER (WHERE r.created_at <  p_obs_start
                                        AND r.status_code < 400
                                        AND r.latency_ms  IS NOT NULL)::DOUBLE PRECISION,
    COUNT(r.latency_ms)       FILTER (WHERE r.created_at <  p_obs_start
                                        AND r.status_code < 400
                                        AND r.latency_ms  IS NOT NULL),
    -- ── Cost ───────────────────────────────────────────────────────────────
    AVG(r.cost_usd)           FILTER (WHERE r.created_at >= p_obs_start
                                        AND r.status_code < 400
                                        AND r.cost_usd    IS NOT NULL)::DOUBLE PRECISION,
    COUNT(r.cost_usd)         FILTER (WHERE r.created_at >= p_obs_start
                                        AND r.status_code < 400
                                        AND r.cost_usd    IS NOT NULL),
    AVG(r.cost_usd)           FILTER (WHERE r.created_at <  p_obs_start
                                        AND r.status_code < 400
                                        AND r.cost_usd    IS NOT NULL)::DOUBLE PRECISION,
    STDDEV_SAMP(r.cost_usd)   FILTER (WHERE r.created_at <  p_obs_start
                                        AND r.status_code < 400
                                        AND r.cost_usd    IS NOT NULL)::DOUBLE PRECISION,
    COUNT(r.cost_usd)         FILTER (WHERE r.created_at <  p_obs_start
                                        AND r.status_code < 400
                                        AND r.cost_usd    IS NOT NULL),
    -- ── Error rate ─────────────────────────────────────────────────────────
    AVG(CASE WHEN r.status_code >= 400 THEN 1.0 ELSE 0.0 END)
                              FILTER (WHERE r.created_at >= p_obs_start)::DOUBLE PRECISION,
    COUNT(*)                  FILTER (WHERE r.created_at >= p_obs_start),
    AVG(CASE WHEN r.status_code >= 400 THEN 1.0 ELSE 0.0 END)
                              FILTER (WHERE r.created_at <  p_obs_start)::DOUBLE PRECISION,
    STDDEV_SAMP(CASE WHEN r.status_code >= 400 THEN 1.0 ELSE 0.0 END)
                              FILTER (WHERE r.created_at <  p_obs_start)::DOUBLE PRECISION,
    COUNT(*)                  FILTER (WHERE r.created_at <  p_obs_start)
  FROM requests r
  WHERE r.organization_id = p_org_id
    AND r.created_at       >= p_ref_start
    AND r.model             IS NOT NULL
    AND (p_project_id IS NULL OR r.project_id = p_project_id)
  GROUP BY r.provider, r.model
$$;

-- -----------------------------------------------------------------------------
-- Migration: 20260429190000_anomaly_acks_project_id.sql
-- -----------------------------------------------------------------------------
-- Migration: add project_id + surrogate PK to anomaly_acks
-- Enables per-project ack isolation: org-wide acks use project_id IS NULL.
-- NULLS NOT DISTINCT makes (org, NULL, provider, model, kind) unique.

ALTER TABLE anomaly_acks
  ADD COLUMN id UUID NOT NULL DEFAULT gen_random_uuid(),
  ADD COLUMN project_id UUID REFERENCES projects(id) ON DELETE CASCADE;

-- Existing rows retain project_id = NULL (treated as org-wide acks).

-- Replace composite natural PK with surrogate PK.
ALTER TABLE anomaly_acks DROP CONSTRAINT anomaly_acks_pkey;
ALTER TABLE anomaly_acks ADD PRIMARY KEY (id);

-- Unique constraint — NULLS NOT DISTINCT so two org-wide acks for the same
-- (provider, model, kind) still conflict even though project_id IS NULL.
CREATE UNIQUE INDEX anomaly_acks_unique_idx
  ON anomaly_acks (organization_id, project_id, provider, model, kind)
  NULLS NOT DISTINCT;

-- -----------------------------------------------------------------------------
-- Migration: 20260430120000_security_block_alert.sql
-- -----------------------------------------------------------------------------
-- Security blocking + alert settings.
--
-- Three new capabilities:
--   1. Per-project request blocking — proxy returns 422 when injection detected
--      and blocking is enabled for that project.
--   2. Response scanning — requests.response_flags stores flags found in the
--      LLM's reply (PII in output, etc.).
--   3. Security alert emails — when any flag is detected, email org admins
--      (rate-limited to 1 email per 5 minutes per org via last_security_alert_at).

-- ── projects: injection blocking toggle ───────────────────────────────────────
ALTER TABLE projects
  ADD COLUMN security_block_enabled BOOLEAN NOT NULL DEFAULT false;

-- ── requests: response-side security flags ────────────────────────────────────
ALTER TABLE requests
  ADD COLUMN response_flags JSONB NOT NULL DEFAULT '[]'::jsonb;

-- Generated column — true when either request OR response has flags.
-- Used as a fast, index-friendly filter for the flagged-requests list.
ALTER TABLE requests
  ADD COLUMN has_security_flags BOOLEAN GENERATED ALWAYS AS (
    (flags != '[]'::jsonb OR response_flags != '[]'::jsonb)
  ) STORED;

CREATE INDEX idx_requests_has_security_flags
  ON requests (organization_id, created_at DESC)
  WHERE has_security_flags = true;

-- ── organizations: alert settings ────────────────────────────────────────────
ALTER TABLE organizations
  ADD COLUMN security_alert_enabled   BOOLEAN   NOT NULL DEFAULT false,
  ADD COLUMN last_security_alert_at   TIMESTAMPTZ;

-- -----------------------------------------------------------------------------
-- Migration: 20260430140000_get_model_aggregates_fn.sql
-- -----------------------------------------------------------------------------
-- Aggregate model usage stats for the recommendation engine.
--
-- Why a function instead of fetching raw rows:
--   The Supabase JS client applies a 1000-row default limit on .select() calls.
--   For orgs with >1000 requests in the analysis window this silently truncates
--   data, producing wrong sampleCount values and potentially missed/wrong
--   recommendations. Doing GROUP BY in the DB eliminates the problem entirely
--   and is also much faster (no round-trip of raw rows into JS memory).

CREATE OR REPLACE FUNCTION get_model_aggregates(
  p_organization_id uuid,
  p_window_start     timestamptz,
  p_status_codes     int[]
)
RETURNS TABLE (
  provider               text,
  model                  text,
  sample_count           bigint,
  avg_prompt_tokens      double precision,
  avg_completion_tokens  double precision,
  total_cost_usd         double precision
)
LANGUAGE sql
SECURITY DEFINER
SET search_path = public
AS $$
  SELECT
    provider,
    model,
    COUNT(*)                          AS sample_count,
    AVG(prompt_tokens::float)         AS avg_prompt_tokens,
    AVG(completion_tokens::float)     AS avg_completion_tokens,
    COALESCE(SUM(cost_usd), 0)        AS total_cost_usd
  FROM requests
  WHERE
    organization_id = p_organization_id
    AND created_at  >= p_window_start
    AND status_code = ANY(p_status_codes)
    AND model       IS NOT NULL
    AND provider    IS NOT NULL
  GROUP BY provider, model
$$;

-- -----------------------------------------------------------------------------
-- Migration: 20260430150000_fix_model_aggregates_null_tokens.sql
-- -----------------------------------------------------------------------------
-- Fix: AVG() returns NULL when all rows have NULL tokens.
-- In that case the TypeScript envelope check (avg > max) evaluates to false
-- (null > number === false in JS) and bypasses the filter entirely — causing
-- recommendations to fire on models where we have no token-volume evidence.
--
-- Using COALESCE(AVG(...), 999999) maps "no token data" to an enormous value
-- that always fails the envelope check, so we conservatively skip the
-- recommendation rather than showing a potentially wrong one.

CREATE OR REPLACE FUNCTION get_model_aggregates(
  p_organization_id uuid,
  p_window_start     timestamptz,
  p_status_codes     int[]
)
RETURNS TABLE (
  provider               text,
  model                  text,
  sample_count           bigint,
  avg_prompt_tokens      double precision,
  avg_completion_tokens  double precision,
  total_cost_usd         double precision
)
LANGUAGE sql
SECURITY DEFINER
SET search_path = public
AS $$
  SELECT
    provider,
    model,
    COUNT(*)                                          AS sample_count,
    COALESCE(AVG(prompt_tokens::float),     999999)  AS avg_prompt_tokens,
    COALESCE(AVG(completion_tokens::float), 999999)  AS avg_completion_tokens,
    COALESCE(SUM(cost_usd), 0)                        AS total_cost_usd
  FROM requests
  WHERE
    organization_id = p_organization_id
    AND created_at  >= p_window_start
    AND status_code = ANY(p_status_codes)
    AND model       IS NOT NULL
    AND provider    IS NOT NULL
  GROUP BY provider, model
$$;

-- -----------------------------------------------------------------------------
-- Migration: 20260430161000_recommendation_applications.sql
-- -----------------------------------------------------------------------------
-- Track when organizations apply a cost-saving recommendation.
-- Shows "Applied N days ago" badges in the Savings dashboard.

CREATE TABLE recommendation_applications (
  id                 uuid        PRIMARY KEY DEFAULT gen_random_uuid(),
  organization_id    uuid        NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
  user_id            uuid        NOT NULL,
  provider           text        NOT NULL,
  model              text        NOT NULL,
  suggested_provider text        NOT NULL,
  suggested_model    text        NOT NULL,
  applied_at         timestamptz NOT NULL DEFAULT now(),
  note               text
);

ALTER TABLE recommendation_applications ENABLE ROW LEVEL SECURITY;

-- Service-role (supabaseAdmin) handles all writes via the server.
-- This policy allows org members to read their own application records
-- for direct Supabase client queries (currently unused, good hygiene).
CREATE POLICY "users can select their own applications"
  ON recommendation_applications
  FOR SELECT
  USING (user_id = auth.uid());

-- Fast lookups by org + model pair
CREATE INDEX idx_rec_apps_org_model
  ON recommendation_applications (organization_id, provider, model, suggested_provider, suggested_model);

-- Sorted list by recency for the dashboard
CREATE INDEX idx_rec_apps_org_applied
  ON recommendation_applications (organization_id, applied_at DESC);

-- -----------------------------------------------------------------------------
-- Migration: 20260430162000_recommendation_notifications.sql
-- -----------------------------------------------------------------------------
-- Tracks which high-confidence recommendations have had a notification sent.
-- The UNIQUE (organization_id, recommendation_key) ensures at most one
-- notification per recommendation per org (idempotent cron runs).

CREATE TABLE recommendation_notifications (
  id                  uuid          PRIMARY KEY DEFAULT gen_random_uuid(),
  organization_id     uuid          NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
  recommendation_key  text          NOT NULL,
  confidence_level    text          NOT NULL CHECK (confidence_level IN ('high', 'medium', 'low')),
  savings_usd         numeric(10,2) NOT NULL,
  sent_at             timestamptz   NOT NULL DEFAULT now(),
  UNIQUE (organization_id, recommendation_key)
);

ALTER TABLE recommendation_notifications ENABLE ROW LEVEL SECURITY;

CREATE INDEX idx_rec_notifs_org
  ON recommendation_notifications (organization_id, sent_at DESC);

-- -----------------------------------------------------------------------------
-- Migration: 20260430170000_prompt_ab_experiments.sql
-- -----------------------------------------------------------------------------
-- A/B experiment tracking for prompt versions.
--
-- An experiment compares two versions of the same prompt (version_a vs version_b)
-- by routing a fraction of @latest traffic to each. One org can have at most one
-- running experiment per prompt name at a time (enforced by partial unique index).
--
-- Lifecycle: running → concluded | stopped
--   concluded = experiment ran its course, winner decided
--   stopped   = manually ended before conclusion

CREATE TABLE IF NOT EXISTS public.prompt_ab_experiments (
  id               uuid        PRIMARY KEY DEFAULT gen_random_uuid(),
  organization_id  uuid        NOT NULL REFERENCES public.organizations(id) ON DELETE CASCADE,
  project_id       uuid        REFERENCES public.projects(id) ON DELETE SET NULL,
  prompt_name      text        NOT NULL,
  version_a_id     uuid        NOT NULL REFERENCES public.prompt_versions(id) ON DELETE RESTRICT,
  version_b_id     uuid        NOT NULL REFERENCES public.prompt_versions(id) ON DELETE RESTRICT,
  -- traffic_split = % of requests routed to version_a (0-100). Remaining goes to B.
  traffic_split    smallint    NOT NULL DEFAULT 50 CHECK (traffic_split BETWEEN 1 AND 99),
  status           text        NOT NULL DEFAULT 'running'
                               CHECK (status IN ('running', 'concluded', 'stopped')),
  started_at       timestamptz NOT NULL DEFAULT now(),
  ends_at          timestamptz,          -- optional planned end date
  concluded_at     timestamptz,
  winner_version_id uuid       REFERENCES public.prompt_versions(id) ON DELETE SET NULL,
  created_by       uuid        REFERENCES auth.users(id) ON DELETE SET NULL,
  CONSTRAINT version_a_ne_b CHECK (version_a_id <> version_b_id)
);

-- Only one running experiment per (org, prompt_name) at a time.
CREATE UNIQUE INDEX IF NOT EXISTS idx_prompt_ab_exp_unique_running
  ON public.prompt_ab_experiments (organization_id, prompt_name)
  WHERE status = 'running';

-- Lookup index for traffic routing (hot path in resolve-prompt-version).
CREATE INDEX IF NOT EXISTS idx_prompt_ab_exp_org_name_status
  ON public.prompt_ab_experiments (organization_id, prompt_name, status);

-- RLS
ALTER TABLE public.prompt_ab_experiments ENABLE ROW LEVEL SECURITY;

CREATE POLICY "prompt_ab_exp_select_member" ON public.prompt_ab_experiments
  FOR SELECT TO authenticated
  USING (public.is_org_member(organization_id));

CREATE POLICY "prompt_ab_exp_insert_member" ON public.prompt_ab_experiments
  FOR INSERT TO authenticated
  WITH CHECK (public.is_org_member(organization_id));

CREATE POLICY "prompt_ab_exp_update_member" ON public.prompt_ab_experiments
  FOR UPDATE TO authenticated
  USING (public.is_org_member(organization_id));

COMMENT ON TABLE public.prompt_ab_experiments IS
  'Tracks A/B experiments comparing two prompt versions. Traffic split routes @latest requests.';

-- -----------------------------------------------------------------------------
-- Migration: 20260430170100_prompt_version_archived.sql
-- -----------------------------------------------------------------------------
-- Add is_archived flag to prompt_versions.
-- Archived versions are hidden from the default list view but not deleted.

ALTER TABLE public.prompt_versions
  ADD COLUMN IF NOT EXISTS is_archived boolean NOT NULL DEFAULT false;

-- Index to efficiently query non-archived versions (the common case)
CREATE INDEX IF NOT EXISTS idx_prompt_versions_not_archived
  ON public.prompt_versions (organization_id, name)
  WHERE is_archived = false;

COMMENT ON COLUMN public.prompt_versions.is_archived IS
  'When true the version is hidden from default list views but not deleted. Reversible.';

-- -----------------------------------------------------------------------------
-- Migration: 20260430170200_prompt_quality_timeseries_fn.sql
-- -----------------------------------------------------------------------------
-- Batch sparkline RPC for prompt quality timeseries.
--
-- Returns bucketed quality scores (0-100) for N prompt names in a single
-- round-trip. Used by the prompts list page to render inline sparklines
-- without N+1 queries.
--
-- Quality score per bucket = 100 * (1 - error_rate)
-- where error_rate = requests with status_code >= 400 / total requests.
-- Buckets with no data return null so the sparkline can render gaps.

CREATE OR REPLACE FUNCTION public.get_prompts_quality_sparklines(
  p_org_id   uuid,
  p_names    text[],
  p_hours    int  DEFAULT 24,
  p_buckets  int  DEFAULT 20
)
RETURNS TABLE (
  prompt_name    text,
  bucket_index   int,
  bucket_start   timestamptz,
  quality_score  numeric
)
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
  WITH
  -- time bounds
  bounds AS (
    SELECT
      now() - (p_hours || ' hours')::interval AS since,
      now()                                    AS until
  ),
  -- all version ids for the requested prompt names, scoped to org
  version_ids AS (
    SELECT pv.id, pv.name
    FROM   prompt_versions pv
    WHERE  pv.organization_id = p_org_id
      AND  pv.name            = ANY(p_names)
  ),
  -- requests in window
  reqs AS (
    SELECT
      vi.name                AS prompt_name,
      r.created_at,
      r.status_code
    FROM   requests r
    JOIN   version_ids vi ON vi.id = r.prompt_version_id
    CROSS  JOIN bounds b
    WHERE  r.organization_id = p_org_id
      AND  r.created_at     >= b.since
      AND  r.created_at     <  b.until
  ),
  -- assign bucket index (0 = oldest, p_buckets-1 = newest)
  bucketed AS (
    SELECT
      prompt_name,
      floor(
        extract(epoch FROM (reqs.created_at - b.since)) /
        (extract(epoch FROM (b.until - b.since)) / p_buckets)
      )::int AS bidx,
      status_code
    FROM reqs
    CROSS JOIN bounds b
  ),
  -- aggregate per (name, bucket)
  agg AS (
    SELECT
      prompt_name,
      bidx,
      count(*)                                              AS total,
      count(*) FILTER (WHERE status_code >= 400)            AS errors
    FROM bucketed
    WHERE bidx BETWEEN 0 AND p_buckets - 1
    GROUP BY prompt_name, bidx
  )
  SELECT
    agg.prompt_name,
    agg.bidx                                    AS bucket_index,
    bounds.since + (
      agg.bidx::numeric / p_buckets *
      extract(epoch FROM (bounds.until - bounds.since)) * interval '1 second'
    )                                           AS bucket_start,
    round(
      100.0 * (1.0 - agg.errors::numeric / agg.total),
      1
    )                                           AS quality_score
  FROM agg
  CROSS JOIN bounds
  ORDER BY agg.prompt_name, agg.bidx;
$$;

COMMENT ON FUNCTION public.get_prompts_quality_sparklines IS
  'Batch sparkline data: bucketed quality scores (0-100) for multiple prompt names.';

-- -----------------------------------------------------------------------------
-- Migration: 20260430180000_api_keys_provider_key_link.sql
-- -----------------------------------------------------------------------------
-- Link api_keys to a specific provider_key row.
-- When set, the proxy bypasses org/project key search and uses this key directly.
-- Nullable for backward compatibility with existing keys.
ALTER TABLE api_keys
  ADD COLUMN provider_key_id uuid REFERENCES provider_keys(id) ON DELETE SET NULL;

-- Index for the FK (Postgres doesn't auto-create FK indexes)
CREATE INDEX idx_api_keys_provider_key_id ON api_keys(provider_key_id)
  WHERE provider_key_id IS NOT NULL;

-- -----------------------------------------------------------------------------
-- Migration: 20260501120000_requests_api_key_id_fk_set_null.sql
-- -----------------------------------------------------------------------------
-- Change requests.api_key_id FK from NO ACTION to SET NULL
-- so that deleting an api_key preserves request history (api_key_id becomes NULL)
ALTER TABLE requests
  DROP CONSTRAINT requests_api_key_id_fkey;

ALTER TABLE requests
  ADD CONSTRAINT requests_api_key_id_fkey
  FOREIGN KEY (api_key_id) REFERENCES api_keys(id) ON DELETE SET NULL;

-- -----------------------------------------------------------------------------
-- Migration: 20260501120100_requests_api_key_id_nullable.sql
-- -----------------------------------------------------------------------------
-- Allow api_key_id to be NULL so deleted keys don't block request history
ALTER TABLE requests ALTER COLUMN api_key_id DROP NOT NULL;

-- -----------------------------------------------------------------------------
-- Migration: 20260504120000_rate_limit_buckets.sql
-- -----------------------------------------------------------------------------
-- Rate-limit sliding-window buckets (per-minute granularity).
--
-- Each row tracks how many requests a given key has made in a
-- specific 1-minute window ("YYYY-MM-DDTHH:MM" UTC string).
--
-- Reads and writes are done via the check_rate_limit() RPC which
-- performs an atomic INSERT ... ON CONFLICT DO UPDATE so concurrent
-- requests never miss each other's counts.
--
-- Rows older than 10 minutes are cleaned up by the existing
-- prune-logs cron. The table never grows large because windows expire quickly.

CREATE TABLE IF NOT EXISTS rate_limit_buckets (
  key          TEXT        NOT NULL,
  window_key   TEXT        NOT NULL, -- "YYYY-MM-DDTHH:MM" UTC
  count        INTEGER     NOT NULL DEFAULT 0,
  created_at   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  PRIMARY KEY (key, window_key)
);

-- Index to speed up cleanup queries
CREATE INDEX IF NOT EXISTS rate_limit_buckets_created_at_idx
  ON rate_limit_buckets (created_at);

-- Service-role only — no public access needed
ALTER TABLE rate_limit_buckets ENABLE ROW LEVEL SECURITY;

-- ── RPC: atomic increment + limit check ──────────────────────────
-- Returns TRUE  → request is within the limit (allowed)
-- Returns FALSE → request exceeded the limit (block with 429)
CREATE OR REPLACE FUNCTION check_rate_limit(
  p_key        TEXT,
  p_window_key TEXT,
  p_limit      INTEGER
) RETURNS BOOLEAN
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
  v_count INTEGER;
BEGIN
  INSERT INTO rate_limit_buckets (key, window_key, count)
  VALUES (p_key, p_window_key, 1)
  ON CONFLICT (key, window_key)
  DO UPDATE SET count = rate_limit_buckets.count + 1
  RETURNING rate_limit_buckets.count INTO v_count;

  RETURN v_count <= p_limit;
END;
$$;

-- ── Cleanup helper (called by prune-logs cron) ───────────────────
-- Deletes buckets older than 10 minutes to keep the table tiny.
CREATE OR REPLACE FUNCTION prune_rate_limit_buckets()
RETURNS INTEGER
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
  deleted INTEGER;
BEGIN
  DELETE FROM rate_limit_buckets
  WHERE created_at < NOW() - INTERVAL '10 minutes';
  GET DIAGNOSTICS deleted = ROW_COUNT;
  RETURN deleted;
END;
$$;

-- -----------------------------------------------------------------------------
-- Migration: 20260505040000_unified_keys.sql
-- -----------------------------------------------------------------------------
-- Migration: unified_keys
--
-- Switch from per-provider Spanlens keys to a single project-scoped Spanlens
-- key that can call ANY provider registered on that project.
--
-- BEFORE
--   api_keys.provider_key_id → provider_keys.id  (1:1)
--   • Each sl_live_xxx mapped to exactly one provider AI key.
--   • Customers had to issue 3 sl_live keys to use OpenAI + Anthropic + Gemini.
--
-- AFTER
--   api_keys.project_id  → projects.id           (N:1, already existed)
--   provider_keys.project_id  → projects.id      (N:1, NOT NULL)
--   • One sl_live_xxx per project. Provider is inferred from the request URL
--     path (`/proxy/openai/...` vs `/proxy/anthropic/...`). The proxy looks
--     up the project's active provider_key for the requested provider.
--
-- Org-level (project_id IS NULL) provider keys are deprecated: every key now
-- belongs explicitly to a project. Existing NULL rows are backfilled to each
-- org's oldest project before the NOT NULL constraint is applied.

-- ────────────────────────────────────────────────────────────
-- 1. Drop api_keys.provider_key_id — superseded by path-based provider
--    inference in the authApiKey middleware.
-- ────────────────────────────────────────────────────────────
DROP INDEX IF EXISTS idx_api_keys_provider_key_id;
ALTER TABLE api_keys DROP COLUMN IF EXISTS provider_key_id;

-- ────────────────────────────────────────────────────────────
-- 2. Backfill org-level provider_keys to a project before locking
--    project_id NOT NULL. Pick each org's oldest project as the destination —
--    deterministic, and matches the implicit "default project" most users have.
-- ────────────────────────────────────────────────────────────
UPDATE provider_keys pk
SET project_id = (
  SELECT p.id
  FROM projects p
  WHERE p.organization_id = pk.organization_id
  ORDER BY p.created_at ASC
  LIMIT 1
)
WHERE pk.project_id IS NULL;

-- Any remaining NULL rows belong to orgs with zero projects — orphaned.
-- Safe to drop because no Spanlens key can resolve to them under the new
-- contract anyway (api_keys.project_id is NOT NULL).
DELETE FROM provider_keys WHERE project_id IS NULL;

-- ────────────────────────────────────────────────────────────
-- 3. Lock project_id NOT NULL — enforces "every provider key belongs
--    to a project" invariant the new auth flow depends on.
-- ────────────────────────────────────────────────────────────
ALTER TABLE provider_keys ALTER COLUMN project_id SET NOT NULL;

-- ────────────────────────────────────────────────────────────
-- 4. Replace the sentinel-COALESCE unique index with a clean one.
--    Since project_id is now NOT NULL we don't need the
--    `COALESCE(project_id, '0000…')` trick from migration 20260423140000.
-- ────────────────────────────────────────────────────────────
DROP INDEX IF EXISTS provider_keys_scope_active_unique;
CREATE UNIQUE INDEX provider_keys_project_provider_active_uniq
  ON provider_keys (project_id, provider)
  WHERE is_active = true;

-- The 20260423140000 lookup index `provider_keys_project_lookup` already
-- covers `(project_id, provider) WHERE is_active = true` for reads, so we
-- keep it as-is — it's still the right shape for the new resolver.

-- -----------------------------------------------------------------------------
-- Migration: 20260505080000_provider_keys_under_api_keys.sql
-- -----------------------------------------------------------------------------
-- Migration: provider_keys nested under api_keys
--
-- Move provider_keys ownership from projects → api_keys. Each Spanlens
-- (sl_live_*) key now owns its own set of provider AI keys, so two
-- Spanlens keys in the same project can carry different OpenAI / Anthropic
-- / Gemini credentials (e.g. dev vs prod, team A vs team B).
--
-- BEFORE
--   provider_keys.project_id (NOT NULL) → projects.id
--   Resolution: (project_id, provider) — every Spanlens key in the project
--   shared the same provider keys.
--
-- AFTER
--   provider_keys.api_key_id (NOT NULL) → api_keys.id ON DELETE CASCADE
--   Resolution: (api_key_id, provider) — each Spanlens key has its own pool.
--
-- Backfill strategy
--   For each existing provider_key row, attach it to the *oldest* api_key
--   in the same project. Other api_keys start empty — owners can re-add
--   provider keys to them in the dashboard.
--   Provider keys whose project has zero api_keys are dropped (no Spanlens
--   key exists to call them anyway).
--
-- This is the ALPHA contract — minimal data is at risk and the trade-off
-- (deterministic, simple) beats per-row complex backfill.

-- ────────────────────────────────────────────────────────────
-- 1. Add the new FK column nullable so backfill can run.
-- ────────────────────────────────────────────────────────────
ALTER TABLE provider_keys
  ADD COLUMN api_key_id UUID REFERENCES api_keys(id) ON DELETE CASCADE;

-- ────────────────────────────────────────────────────────────
-- 2. Backfill — point each provider_key at the oldest api_key in its project.
-- ────────────────────────────────────────────────────────────
UPDATE provider_keys pk
SET api_key_id = (
  SELECT ak.id
  FROM api_keys ak
  WHERE ak.project_id = pk.project_id
  ORDER BY ak.created_at ASC
  LIMIT 1
)
WHERE pk.api_key_id IS NULL;

-- Provider keys for projects with no api_keys can't be reached by any
-- Spanlens key under the new model — drop them.
DELETE FROM provider_keys WHERE api_key_id IS NULL;

-- ────────────────────────────────────────────────────────────
-- 3. Lock the new column NOT NULL, drop the old project_id, swap indexes.
-- ────────────────────────────────────────────────────────────
ALTER TABLE provider_keys ALTER COLUMN api_key_id SET NOT NULL;

-- The old (project_id, provider) UNIQUE WHERE active and lookup index
-- can't survive — they reference a column we're about to drop.
DROP INDEX IF EXISTS provider_keys_project_provider_active_uniq;
DROP INDEX IF EXISTS provider_keys_project_lookup;

ALTER TABLE provider_keys DROP COLUMN project_id;

-- New uniqueness: per-api_key, only one active provider_key per provider.
-- Same shape as before but scoped one level deeper.
CREATE UNIQUE INDEX provider_keys_api_key_provider_active_uniq
  ON provider_keys (api_key_id, provider)
  WHERE is_active = true;

-- Lookup index for the proxy resolver: (api_key_id, provider) WHERE active.
CREATE INDEX provider_keys_api_key_lookup
  ON provider_keys (api_key_id, provider)
  WHERE is_active = true;

-- -----------------------------------------------------------------------------
-- Migration: 20260507000000_otlp_external_ids.sql
-- -----------------------------------------------------------------------------
-- Migration: otlp_external_ids
-- Purpose: Add external_trace_id / external_span_id columns to support OTLP/HTTP ingestion.
--
-- OTel trace_id is a 32-char hex string (16 bytes), OTel span_id is 16-char hex (8 bytes).
-- We keep our own UUID primary keys and store OTel IDs as TEXT in separate columns.
-- This avoids a risky migration of existing PK columns and keeps all existing code working.
--
-- Parent-span linkage (external_parent_span_id → parent_span_id UUID) is resolved by
-- the link_otlp_span_parents() function, called after batch INSERT from the OTLP receiver.

-- ── traces ────────────────────────────────────────────────────────
ALTER TABLE traces ADD COLUMN IF NOT EXISTS external_trace_id TEXT;

-- One external trace ID per org (idempotent upsert support)
CREATE UNIQUE INDEX IF NOT EXISTS traces_external_id_org_idx
  ON traces (organization_id, external_trace_id)
  WHERE external_trace_id IS NOT NULL;

-- ── spans ─────────────────────────────────────────────────────────
ALTER TABLE spans ADD COLUMN IF NOT EXISTS external_span_id TEXT;
ALTER TABLE spans ADD COLUMN IF NOT EXISTS external_parent_span_id TEXT;

CREATE INDEX IF NOT EXISTS spans_external_span_id_idx
  ON spans (external_span_id)
  WHERE external_span_id IS NOT NULL;

-- ── link_otlp_span_parents() ──────────────────────────────────────
-- After inserting a batch of OTLP spans, call this RPC to resolve
-- external_parent_span_id → parent_span_id (UUID) for spans in a given trace.
-- Only updates spans where parent_span_id is still NULL (idempotent).
CREATE OR REPLACE FUNCTION link_otlp_span_parents(p_trace_id UUID)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
  UPDATE spans AS child
  SET parent_span_id = parent.id
  FROM spans AS parent
  WHERE child.trace_id  = p_trace_id
    AND parent.trace_id = p_trace_id
    AND child.external_parent_span_id IS NOT NULL
    AND child.external_parent_span_id = parent.external_span_id
    AND child.parent_span_id IS NULL;
END;
$$;

-- -----------------------------------------------------------------------------
-- Migration: 20260507010000_recommendations_prior_window.sql
-- -----------------------------------------------------------------------------
-- Returns the total cost_usd for a specific (provider, model) in a bounded time window.
--
-- Used by the recommendation engine to detect when a model swap has been adopted:
-- a ≥70% drop in spend vs the prior comparable window is treated as "achieved".
--
-- Model matching uses boundary-aware prefix so that dated variants (e.g.
-- gpt-4o-2024-08-06) are covered when the caller passes the canonical alias (gpt-4o).
-- In practice callers pass the exact model string returned by get_model_aggregates,
-- so the LIKE arm also catches any other dated variant of the same family.
CREATE OR REPLACE FUNCTION get_model_prior_window_cost(
  p_organization_id uuid,
  p_provider        text,
  p_model           text,
  p_window_start    timestamptz,
  p_window_end      timestamptz
)
RETURNS double precision
LANGUAGE sql
SECURITY DEFINER
SET search_path = public
AS $$
  SELECT COALESCE(SUM(cost_usd), 0)::double precision
  FROM requests
  WHERE organization_id = p_organization_id
    AND provider        = p_provider
    AND (model = p_model OR model LIKE (p_model || '-%'))
    AND created_at >= p_window_start
    AND created_at <  p_window_end
    AND status_code = ANY(ARRAY[200, 201, 202, 204])
$$;

-- -----------------------------------------------------------------------------
-- Migration: 20260507010100_get_model_percentiles_fn.sql
-- -----------------------------------------------------------------------------
-- Returns P50 / P95 / P99 token distribution for a specific (provider, model)
-- within the analysis window.
--
-- Used by GET /api/v1/recommendations/percentiles, lazy-fetched only when the
-- Savings "Simulate" dialog opens. Lets the UI show how the org's actual token
-- distribution compares to the substitute model's envelope, and warn when P95
-- exceeds the envelope (suggesting some requests may degrade in quality).
--
-- percentile_cont requires ordered-set aggregation in SQL — pulling raw rows
-- into JS would be impractical for high-traffic models (100k+ rows).
CREATE OR REPLACE FUNCTION get_model_percentiles(
  p_organization_id uuid,
  p_provider        text,
  p_model           text,
  p_window_start    timestamptz
)
RETURNS TABLE (
  p50_prompt     double precision,
  p95_prompt     double precision,
  p99_prompt     double precision,
  p50_completion double precision,
  p95_completion double precision,
  p99_completion double precision,
  sample_count   bigint
)
LANGUAGE sql
SECURITY DEFINER
SET search_path = public
AS $$
  SELECT
    percentile_cont(0.50) WITHIN GROUP (ORDER BY prompt_tokens::float)     AS p50_prompt,
    percentile_cont(0.95) WITHIN GROUP (ORDER BY prompt_tokens::float)     AS p95_prompt,
    percentile_cont(0.99) WITHIN GROUP (ORDER BY prompt_tokens::float)     AS p99_prompt,
    percentile_cont(0.50) WITHIN GROUP (ORDER BY completion_tokens::float) AS p50_completion,
    percentile_cont(0.95) WITHIN GROUP (ORDER BY completion_tokens::float) AS p95_completion,
    percentile_cont(0.99) WITHIN GROUP (ORDER BY completion_tokens::float) AS p99_completion,
    COUNT(*)::bigint                                                        AS sample_count
  FROM requests
  WHERE organization_id = p_organization_id
    AND provider        = p_provider
    AND (model = p_model OR model LIKE (p_model || '-%'))
    AND created_at >= p_window_start
    AND status_code = ANY(ARRAY[200, 201, 202, 204])
    AND prompt_tokens     IS NOT NULL
    AND completion_tokens IS NOT NULL
$$;

-- -----------------------------------------------------------------------------
-- Migration: 20260512120000_realtime_requests.sql
-- -----------------------------------------------------------------------------
-- Enable Supabase Realtime for the requests table.
-- REPLICA IDENTITY FULL is required so Realtime can evaluate RLS policies
-- on INSERT events (the new row's columns must be available for filtering).
ALTER TABLE requests REPLICA IDENTITY FULL;

ALTER PUBLICATION supabase_realtime ADD TABLE requests;

-- -----------------------------------------------------------------------------
-- Migration: 20260513000000_evals.sql
-- -----------------------------------------------------------------------------
-- Evals: LLM-as-judge evaluation infrastructure for prompt versions.
--
-- An evaluator defines "how to score" (criterion + judge model).
-- An eval_run is a single execution of that evaluator over N samples.
-- An eval_result is the score for one sample (one request or one dataset item).
--
-- MVP scope:
--   - Evaluator type: 'llm_judge' only (heuristic etc. in Phase 2)
--   - Source: 'production' only (dataset support comes with Datasets tab)

CREATE TABLE IF NOT EXISTS public.evaluators (
  id              uuid        PRIMARY KEY DEFAULT gen_random_uuid(),
  organization_id uuid        NOT NULL REFERENCES public.organizations(id) ON DELETE CASCADE,
  prompt_name     text        NOT NULL,
  name            text        NOT NULL,
  type            text        NOT NULL DEFAULT 'llm_judge'
                              CHECK (type IN ('llm_judge')),
  -- For llm_judge: { criterion, judge_provider, judge_model, scale_min, scale_max }
  config          jsonb       NOT NULL,
  created_by      uuid        REFERENCES auth.users(id) ON DELETE SET NULL,
  created_at      timestamptz NOT NULL DEFAULT now(),
  archived_at     timestamptz
);

CREATE INDEX IF NOT EXISTS idx_evaluators_org_prompt
  ON public.evaluators (organization_id, prompt_name)
  WHERE archived_at IS NULL;

ALTER TABLE public.evaluators ENABLE ROW LEVEL SECURITY;

CREATE POLICY "evaluators_select_member" ON public.evaluators
  FOR SELECT TO authenticated
  USING (public.is_org_member(organization_id));

CREATE POLICY "evaluators_insert_member" ON public.evaluators
  FOR INSERT TO authenticated
  WITH CHECK (public.is_org_member(organization_id));

CREATE POLICY "evaluators_update_member" ON public.evaluators
  FOR UPDATE TO authenticated
  USING (public.is_org_member(organization_id));

COMMENT ON TABLE public.evaluators IS
  'Defines how to score prompt outputs (criterion + judge model). One row per reusable evaluator.';

-- ── eval_runs ────────────────────────────────────────────────────────────────

CREATE TABLE IF NOT EXISTS public.eval_runs (
  id                 uuid        PRIMARY KEY DEFAULT gen_random_uuid(),
  organization_id    uuid        NOT NULL REFERENCES public.organizations(id) ON DELETE CASCADE,
  evaluator_id       uuid        NOT NULL REFERENCES public.evaluators(id) ON DELETE CASCADE,
  prompt_version_id  uuid        NOT NULL REFERENCES public.prompt_versions(id) ON DELETE CASCADE,
  source             text        NOT NULL DEFAULT 'production'
                                 CHECK (source IN ('production', 'dataset')),
  sample_size        int         NOT NULL CHECK (sample_size > 0 AND sample_size <= 1000),
  -- Time window for production sampling (NULL for dataset source).
  sample_from        timestamptz,
  sample_to          timestamptz,
  status             text        NOT NULL DEFAULT 'pending'
                                 CHECK (status IN ('pending', 'running', 'completed', 'failed')),
  -- Populated when status = 'completed'.
  scored_count       int         NOT NULL DEFAULT 0,
  avg_score          numeric,
  total_cost_usd     numeric     NOT NULL DEFAULT 0,
  error              text,
  created_by         uuid        REFERENCES auth.users(id) ON DELETE SET NULL,
  started_at         timestamptz NOT NULL DEFAULT now(),
  completed_at       timestamptz
);

CREATE INDEX IF NOT EXISTS idx_eval_runs_evaluator
  ON public.eval_runs (evaluator_id, started_at DESC);

CREATE INDEX IF NOT EXISTS idx_eval_runs_prompt_version
  ON public.eval_runs (prompt_version_id, status, started_at DESC);

ALTER TABLE public.eval_runs ENABLE ROW LEVEL SECURITY;

CREATE POLICY "eval_runs_select_member" ON public.eval_runs
  FOR SELECT TO authenticated
  USING (public.is_org_member(organization_id));

CREATE POLICY "eval_runs_insert_member" ON public.eval_runs
  FOR INSERT TO authenticated
  WITH CHECK (public.is_org_member(organization_id));

CREATE POLICY "eval_runs_update_member" ON public.eval_runs
  FOR UPDATE TO authenticated
  USING (public.is_org_member(organization_id));

COMMENT ON TABLE public.eval_runs IS
  'One execution of an evaluator over N samples. Holds aggregate score and run metadata.';

-- ── eval_results ─────────────────────────────────────────────────────────────

CREATE TABLE IF NOT EXISTS public.eval_results (
  id              uuid        PRIMARY KEY DEFAULT gen_random_uuid(),
  organization_id uuid        NOT NULL REFERENCES public.organizations(id) ON DELETE CASCADE,
  eval_run_id     uuid        NOT NULL REFERENCES public.eval_runs(id) ON DELETE CASCADE,
  -- Exactly one of request_id / dataset_item_id is set (dataset_items table
  -- comes in Phase 2; column is nullable now so the schema is forward-compatible).
  request_id      uuid        REFERENCES public.requests(id) ON DELETE SET NULL,
  dataset_item_id uuid,
  score           numeric     NOT NULL,
  reasoning       text,
  judge_cost_usd  numeric     NOT NULL DEFAULT 0,
  judge_tokens    int         NOT NULL DEFAULT 0,
  created_at      timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_eval_results_run
  ON public.eval_results (eval_run_id);

CREATE INDEX IF NOT EXISTS idx_eval_results_request
  ON public.eval_results (request_id)
  WHERE request_id IS NOT NULL;

ALTER TABLE public.eval_results ENABLE ROW LEVEL SECURITY;

CREATE POLICY "eval_results_select_member" ON public.eval_results
  FOR SELECT TO authenticated
  USING (public.is_org_member(organization_id));

CREATE POLICY "eval_results_insert_member" ON public.eval_results
  FOR INSERT TO authenticated
  WITH CHECK (public.is_org_member(organization_id));

COMMENT ON TABLE public.eval_results IS
  'One score per sample (request or dataset_item). Aggregated into eval_runs.avg_score.';

-- -----------------------------------------------------------------------------
-- Migration: 20260513010000_datasets.sql
-- -----------------------------------------------------------------------------
-- Datasets: reusable input sets for offline evaluation.
--
-- A dataset is a named collection of (input, expected_output?) pairs.
-- Used by Evals to run a prompt version against a fixed test set instead of
-- production traffic. Future: Experiments will compare versions on a dataset.
--
-- dataset_items.input is jsonb to allow both "variables only" and "messages"
-- shapes. expected_output is optional — only required for accuracy-style evals.

CREATE TABLE IF NOT EXISTS public.datasets (
  id              uuid        PRIMARY KEY DEFAULT gen_random_uuid(),
  organization_id uuid        NOT NULL REFERENCES public.organizations(id) ON DELETE CASCADE,
  name            text        NOT NULL,
  description     text,
  created_by      uuid        REFERENCES auth.users(id) ON DELETE SET NULL,
  created_at      timestamptz NOT NULL DEFAULT now(),
  archived_at     timestamptz,
  UNIQUE (organization_id, name)
);

CREATE INDEX IF NOT EXISTS idx_datasets_org
  ON public.datasets (organization_id)
  WHERE archived_at IS NULL;

ALTER TABLE public.datasets ENABLE ROW LEVEL SECURITY;

CREATE POLICY "datasets_select_member" ON public.datasets
  FOR SELECT TO authenticated
  USING (public.is_org_member(organization_id));

CREATE POLICY "datasets_insert_member" ON public.datasets
  FOR INSERT TO authenticated
  WITH CHECK (public.is_org_member(organization_id));

CREATE POLICY "datasets_update_member" ON public.datasets
  FOR UPDATE TO authenticated
  USING (public.is_org_member(organization_id));

CREATE POLICY "datasets_delete_member" ON public.datasets
  FOR DELETE TO authenticated
  USING (public.is_org_member(organization_id));

COMMENT ON TABLE public.datasets IS
  'Named collection of (input, expected_output?) test cases for offline evaluation.';

-- ── dataset_items ────────────────────────────────────────────────────────────

CREATE TABLE IF NOT EXISTS public.dataset_items (
  id                uuid        PRIMARY KEY DEFAULT gen_random_uuid(),
  organization_id   uuid        NOT NULL REFERENCES public.organizations(id) ON DELETE CASCADE,
  dataset_id        uuid        NOT NULL REFERENCES public.datasets(id) ON DELETE CASCADE,
  -- Two shapes accepted:
  --   { "variables": { "name": "Alice", ... } }     ← for variable-based prompts
  --   { "messages": [{role,content}, ...] }         ← for raw chat input
  input             jsonb       NOT NULL,
  -- Optional reference answer (for accuracy-style judging).
  expected_output   text,
  -- If this item was imported from production traffic.
  source_request_id uuid        REFERENCES public.requests(id) ON DELETE SET NULL,
  created_at        timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_dataset_items_dataset
  ON public.dataset_items (dataset_id, created_at DESC);

ALTER TABLE public.dataset_items ENABLE ROW LEVEL SECURITY;

CREATE POLICY "dataset_items_select_member" ON public.dataset_items
  FOR SELECT TO authenticated
  USING (public.is_org_member(organization_id));

CREATE POLICY "dataset_items_insert_member" ON public.dataset_items
  FOR INSERT TO authenticated
  WITH CHECK (public.is_org_member(organization_id));

CREATE POLICY "dataset_items_delete_member" ON public.dataset_items
  FOR DELETE TO authenticated
  USING (public.is_org_member(organization_id));

COMMENT ON TABLE public.dataset_items IS
  'Individual test case in a dataset. input is jsonb (variables or messages shape).';

-- Now wire eval_results.dataset_item_id (added forward-compatibly in 20260513000000_evals.sql).
-- The column already exists but lacked an FK. Add the FK now so the relationship is enforced.
DO $$
BEGIN
  IF NOT EXISTS (
    SELECT 1 FROM information_schema.table_constraints
    WHERE constraint_name = 'eval_results_dataset_item_id_fkey'
      AND table_name = 'eval_results'
  ) THEN
    ALTER TABLE public.eval_results
      ADD CONSTRAINT eval_results_dataset_item_id_fkey
      FOREIGN KEY (dataset_item_id)
      REFERENCES public.dataset_items(id)
      ON DELETE SET NULL;
  END IF;
END $$;

-- And give eval_runs.dataset_id a proper FK too.
DO $$
BEGIN
  IF NOT EXISTS (
    SELECT 1 FROM information_schema.columns
    WHERE table_name = 'eval_runs' AND column_name = 'dataset_id'
  ) THEN
    ALTER TABLE public.eval_runs
      ADD COLUMN dataset_id uuid REFERENCES public.datasets(id) ON DELETE SET NULL;
    CREATE INDEX IF NOT EXISTS idx_eval_runs_dataset
      ON public.eval_runs (dataset_id)
      WHERE dataset_id IS NOT NULL;
  END IF;
END $$;

-- -----------------------------------------------------------------------------
-- Migration: 20260513020000_experiments.sql
-- -----------------------------------------------------------------------------
-- Experiments: offline side-by-side comparison of two prompt versions on a dataset.
--
-- DIFFERS FROM Prompts A/B (prompt_ab_experiments):
--   - A/B routes production traffic, takes days, exposes real users
--   - Experiments runs offline on a fixed dataset, takes minutes, no user exposure
--
-- Workflow:
--   1. Pick version_a, version_b, dataset, optional evaluator
--   2. Runner re-runs each dataset item through BOTH prompt versions
--   3. Optionally judges each output with the evaluator
--   4. UI shows side-by-side output comparison + score deltas

CREATE TABLE IF NOT EXISTS public.experiments (
  id                uuid        PRIMARY KEY DEFAULT gen_random_uuid(),
  organization_id   uuid        NOT NULL REFERENCES public.organizations(id) ON DELETE CASCADE,
  name              text        NOT NULL,
  prompt_name       text        NOT NULL,
  version_a_id      uuid        NOT NULL REFERENCES public.prompt_versions(id) ON DELETE RESTRICT,
  version_b_id      uuid        NOT NULL REFERENCES public.prompt_versions(id) ON DELETE RESTRICT,
  dataset_id        uuid        NOT NULL REFERENCES public.datasets(id) ON DELETE RESTRICT,
  evaluator_id      uuid        REFERENCES public.evaluators(id) ON DELETE SET NULL,
  -- Model / provider used to run the prompts (both arms use same setup so the
  -- only variable is the prompt content).
  run_provider      text        NOT NULL CHECK (run_provider IN ('openai', 'anthropic')),
  run_model         text        NOT NULL,
  status            text        NOT NULL DEFAULT 'pending'
                                CHECK (status IN ('pending', 'running', 'completed', 'failed')),
  -- Aggregates populated when status = 'completed'
  total_items       int         NOT NULL DEFAULT 0,
  completed_items   int         NOT NULL DEFAULT 0,
  avg_score_a       numeric,
  avg_score_b       numeric,
  total_cost_usd    numeric     NOT NULL DEFAULT 0,
  error             text,
  created_by        uuid        REFERENCES auth.users(id) ON DELETE SET NULL,
  started_at        timestamptz NOT NULL DEFAULT now(),
  completed_at      timestamptz,
  CONSTRAINT exp_version_a_ne_b CHECK (version_a_id <> version_b_id)
);

CREATE INDEX IF NOT EXISTS idx_experiments_org_prompt
  ON public.experiments (organization_id, prompt_name, started_at DESC);

ALTER TABLE public.experiments ENABLE ROW LEVEL SECURITY;

CREATE POLICY "experiments_select_member" ON public.experiments
  FOR SELECT TO authenticated
  USING (public.is_org_member(organization_id));

CREATE POLICY "experiments_insert_member" ON public.experiments
  FOR INSERT TO authenticated
  WITH CHECK (public.is_org_member(organization_id));

CREATE POLICY "experiments_update_member" ON public.experiments
  FOR UPDATE TO authenticated
  USING (public.is_org_member(organization_id));

COMMENT ON TABLE public.experiments IS
  'Offline side-by-side comparison: runs dataset items through two prompt versions.';

-- ── experiment_results ──────────────────────────────────────────────────────

CREATE TABLE IF NOT EXISTS public.experiment_results (
  id               uuid        PRIMARY KEY DEFAULT gen_random_uuid(),
  organization_id  uuid        NOT NULL REFERENCES public.organizations(id) ON DELETE CASCADE,
  experiment_id    uuid        NOT NULL REFERENCES public.experiments(id) ON DELETE CASCADE,
  dataset_item_id  uuid        NOT NULL REFERENCES public.dataset_items(id) ON DELETE CASCADE,
  -- Per-arm outputs and metrics
  output_a         text,
  output_b         text,
  cost_a_usd       numeric     NOT NULL DEFAULT 0,
  cost_b_usd       numeric     NOT NULL DEFAULT 0,
  latency_a_ms     int,
  latency_b_ms     int,
  tokens_a         int         NOT NULL DEFAULT 0,
  tokens_b         int         NOT NULL DEFAULT 0,
  -- Optional judge scores (when experiment.evaluator_id is set)
  score_a          numeric,
  score_b          numeric,
  reasoning_a      text,
  reasoning_b      text,
  error_a          text,
  error_b          text,
  created_at       timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_experiment_results_exp
  ON public.experiment_results (experiment_id);

ALTER TABLE public.experiment_results ENABLE ROW LEVEL SECURITY;

CREATE POLICY "experiment_results_select_member" ON public.experiment_results
  FOR SELECT TO authenticated
  USING (public.is_org_member(organization_id));

CREATE POLICY "experiment_results_insert_member" ON public.experiment_results
  FOR INSERT TO authenticated
  WITH CHECK (public.is_org_member(organization_id));

COMMENT ON TABLE public.experiment_results IS
  'Per dataset-item result for an experiment: outputs from both arms + optional judge scores.';

-- -----------------------------------------------------------------------------
-- Migration: 20260513030000_human_evals.sql
-- -----------------------------------------------------------------------------
-- Human evals: manual scoring of individual requests by team members.
--
-- Complements LLM-as-judge (eval_results) by capturing human ground truth.
-- The aggregate over LLM vs human scores tells you whether your LLM judge
-- is actually trustworthy.
--
-- Score is stored normalized to 0..1 to match eval_results. raw_score holds
-- the UI value (e.g. 1–5 stars) for re-display.

CREATE TABLE IF NOT EXISTS public.human_evals (
  id                 uuid        PRIMARY KEY DEFAULT gen_random_uuid(),
  organization_id    uuid        NOT NULL REFERENCES public.organizations(id) ON DELETE CASCADE,
  request_id         uuid        NOT NULL REFERENCES public.requests(id) ON DELETE CASCADE,
  -- Denormalized for fast filtering / correlation queries by prompt_version.
  prompt_version_id  uuid        REFERENCES public.prompt_versions(id) ON DELETE SET NULL,
  reviewer_id        uuid        NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
  -- Normalized 0..1 — same scale as eval_results.score so correlation is direct.
  score              numeric     NOT NULL CHECK (score >= 0 AND score <= 1),
  -- Raw UI value (e.g. 1..5 stars) for re-rendering.
  raw_score          numeric,
  comment            text,
  created_at         timestamptz NOT NULL DEFAULT now(),
  updated_at         timestamptz NOT NULL DEFAULT now(),
  -- One reviewer scores each request at most once. Update overwrites prior.
  UNIQUE (request_id, reviewer_id)
);

CREATE INDEX IF NOT EXISTS idx_human_evals_org
  ON public.human_evals (organization_id, created_at DESC);

CREATE INDEX IF NOT EXISTS idx_human_evals_prompt_version
  ON public.human_evals (prompt_version_id)
  WHERE prompt_version_id IS NOT NULL;

CREATE INDEX IF NOT EXISTS idx_human_evals_request
  ON public.human_evals (request_id);

ALTER TABLE public.human_evals ENABLE ROW LEVEL SECURITY;

CREATE POLICY "human_evals_select_member" ON public.human_evals
  FOR SELECT TO authenticated
  USING (public.is_org_member(organization_id));

CREATE POLICY "human_evals_insert_member" ON public.human_evals
  FOR INSERT TO authenticated
  WITH CHECK (public.is_org_member(organization_id));

CREATE POLICY "human_evals_update_own" ON public.human_evals
  FOR UPDATE TO authenticated
  USING (reviewer_id = auth.uid() AND public.is_org_member(organization_id));

CREATE POLICY "human_evals_delete_own" ON public.human_evals
  FOR DELETE TO authenticated
  USING (reviewer_id = auth.uid() AND public.is_org_member(organization_id));

COMMENT ON TABLE public.human_evals IS
  'Per-request human scoring. Score normalized 0..1 to match eval_results for direct correlation.';

-- Auto-update updated_at on row changes.
CREATE OR REPLACE FUNCTION public.human_evals_set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
  NEW.updated_at = now();
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

DROP TRIGGER IF EXISTS trg_human_evals_updated_at ON public.human_evals;
CREATE TRIGGER trg_human_evals_updated_at
  BEFORE UPDATE ON public.human_evals
  FOR EACH ROW
  EXECUTE FUNCTION public.human_evals_set_updated_at();

-- -----------------------------------------------------------------------------
-- Migration: 20260513040000_requests_user_session.sql
-- -----------------------------------------------------------------------------
-- Add user_id / session_id to requests for end-user attribution.
--
-- Populated from the x-spanlens-user / x-spanlens-session headers at proxy time.
-- Both are text (not FK) — these are the CUSTOMER's user IDs, not ours.

ALTER TABLE public.requests ADD COLUMN IF NOT EXISTS user_id    text;
ALTER TABLE public.requests ADD COLUMN IF NOT EXISTS session_id text;

CREATE INDEX IF NOT EXISTS idx_requests_user_id
  ON public.requests (organization_id, user_id, created_at DESC)
  WHERE user_id IS NOT NULL;

CREATE INDEX IF NOT EXISTS idx_requests_session_id
  ON public.requests (organization_id, session_id, created_at DESC)
  WHERE session_id IS NOT NULL;

COMMENT ON COLUMN public.requests.user_id    IS 'Customer-supplied end-user ID via x-spanlens-user header.';
COMMENT ON COLUMN public.requests.session_id IS 'Customer-supplied session ID via x-spanlens-session header.';

-- -----------------------------------------------------------------------------
-- Migration: 20260514120000_cache_pricing.sql
-- -----------------------------------------------------------------------------
-- ─────────────────────────────────────────────────────────────────────────────
-- Cache token pricing (Anthropic prompt caching · OpenAI prompt caching)
--
-- WHY: Both Anthropic and OpenAI charge different prices for cached input tokens
-- vs. fresh input tokens. Until now Spanlens lumped everything into prompt_tokens
-- × prompt_price, which OVERCOUNTS cost by 2–10× for cache-heavy workloads.
--
-- SEMANTIC:
--   • `prompt_tokens`       = TOTAL input tokens (including any cached portion)
--                             — unchanged semantic, all existing aggregations
--                             keep working.
--   • `cache_read_tokens`   = subset of prompt_tokens that hit a cache
--                             (Anthropic: cache_read_input_tokens
--                              OpenAI:    prompt_tokens_details.cached_tokens)
--   • `cache_write_tokens`  = subset of prompt_tokens that CREATED a cache entry
--                             (Anthropic: cache_creation_input_tokens
--                              OpenAI:    no equivalent yet)
--
-- COST FORMULA (applied in lib/cost.ts):
--   non_cached      = prompt_tokens - cache_read_tokens - cache_write_tokens
--   total_cost_usd  = non_cached         × prompt_price
--                   + cache_read_tokens  × cache_read_price
--                   + cache_write_tokens × cache_write_price
--                   + completion_tokens  × completion_price
--
-- HISTORICAL DATA: untouched. Backfill not attempted because raw breakdown was
-- never recorded — request_body / response_body don't reliably contain
-- usage.cached_tokens / usage.cache_read_input_tokens fields for past rows
-- (especially streaming). Going forward, every new request stores the
-- breakdown.
-- ─────────────────────────────────────────────────────────────────────────────

ALTER TABLE model_prices
  ADD COLUMN IF NOT EXISTS cache_read_price_per_1m  NUMERIC(10, 6),
  ADD COLUMN IF NOT EXISTS cache_write_price_per_1m NUMERIC(10, 6);

COMMENT ON COLUMN model_prices.cache_read_price_per_1m  IS
  'USD per 1M cached input tokens (read). NULL = model does not support cache or pricing unknown.';
COMMENT ON COLUMN model_prices.cache_write_price_per_1m IS
  'USD per 1M cache-creation input tokens. NULL = model does not support cache writes or pricing unknown.';

ALTER TABLE requests
  ADD COLUMN IF NOT EXISTS cache_read_tokens  INTEGER NOT NULL DEFAULT 0,
  ADD COLUMN IF NOT EXISTS cache_write_tokens INTEGER NOT NULL DEFAULT 0;

COMMENT ON COLUMN requests.cache_read_tokens  IS
  'Number of input tokens that hit a prompt cache (subset of prompt_tokens). 0 if not applicable.';
COMMENT ON COLUMN requests.cache_write_tokens IS
  'Number of input tokens written to a prompt cache, charged at write price (subset of prompt_tokens). 0 if not applicable.';

-- -----------------------------------------------------------------------------
-- Migration: 20260514130000_user_analytics_fn.sql
-- -----------------------------------------------------------------------------
-- ─────────────────────────────────────────────────────────────────────────────
-- get_user_analytics — aggregate per-user usage for /api/v1/users
--
-- Returns one row per distinct user_id within an organization, with totals
-- (requests, tokens, cost), behavior (avg latency, error count, distinct
-- models), and lifetime markers (first_seen, last_seen).
--
-- The total_count column carries the COUNT(*) OVER () windowed total so the
-- list endpoint can paginate without a second roundtrip.
--
-- Indexes already cover the hot filter:
--   idx_requests_user_id ON (organization_id, user_id, created_at DESC)
--     WHERE user_id IS NOT NULL  -- added in 20260513040000_requests_user_session.sql
--
-- Sort whitelist (p_sort_by): 'cost' | 'requests' | 'tokens' | 'last_seen'.
-- Anything else falls back to 'cost'. Direction whitelist: 'asc' | 'desc'.
-- ─────────────────────────────────────────────────────────────────────────────

CREATE OR REPLACE FUNCTION get_user_analytics(
  p_org_id      uuid,
  p_project_id  uuid,
  p_search      text,
  p_from        timestamptz,
  p_to          timestamptz,
  p_sort_by     text,
  p_sort_dir    text,
  p_limit       int,
  p_offset      int
)
RETURNS TABLE (
  user_id          text,
  total_requests   bigint,
  total_tokens     bigint,
  total_cost_usd   numeric,
  avg_latency_ms   numeric,
  first_seen       timestamptz,
  last_seen        timestamptz,
  error_requests   bigint,
  distinct_models  bigint,
  total_count      bigint
)
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
DECLARE
  v_sort_col text;
  v_sort_dir text;
BEGIN
  -- Whitelist sort inputs to prevent SQL injection.
  v_sort_col := CASE p_sort_by
    WHEN 'requests'  THEN 'total_requests'
    WHEN 'tokens'    THEN 'total_tokens'
    WHEN 'last_seen' THEN 'last_seen'
    ELSE 'total_cost_usd'
  END;
  v_sort_dir := CASE WHEN lower(coalesce(p_sort_dir, 'desc')) = 'asc' THEN 'ASC' ELSE 'DESC' END;

  RETURN QUERY EXECUTE format($q$
    WITH grouped AS (
      SELECT
        r.user_id                                         AS user_id,
        COUNT(*)::bigint                                  AS total_requests,
        COALESCE(SUM(r.total_tokens), 0)::bigint          AS total_tokens,
        COALESCE(SUM(r.cost_usd), 0)::numeric             AS total_cost_usd,
        AVG(r.latency_ms)::numeric                        AS avg_latency_ms,
        MIN(r.created_at)                                 AS first_seen,
        MAX(r.created_at)                                 AS last_seen,
        COUNT(*) FILTER (WHERE r.status_code >= 400)::bigint AS error_requests,
        COUNT(DISTINCT r.model)::bigint                   AS distinct_models
      FROM requests r
      WHERE r.organization_id = $1
        AND r.user_id IS NOT NULL
        AND ($2::uuid IS NULL OR r.project_id = $2)
        AND ($3::text IS NULL OR r.user_id ILIKE '%%' || $3 || '%%')
        AND ($4::timestamptz IS NULL OR r.created_at >= $4)
        AND ($5::timestamptz IS NULL OR r.created_at <= $5)
      GROUP BY r.user_id
    )
    SELECT
      g.*,
      (COUNT(*) OVER ())::bigint AS total_count
    FROM grouped g
    ORDER BY %I %s NULLS LAST
    LIMIT $6 OFFSET $7
  $q$, v_sort_col, v_sort_dir)
  USING p_org_id, p_project_id, p_search, p_from, p_to, p_limit, p_offset;
END;
$$;

COMMENT ON FUNCTION get_user_analytics(uuid, uuid, text, timestamptz, timestamptz, text, text, int, int) IS
  'Aggregate per-user usage stats for an organization. Used by GET /api/v1/users.';

GRANT EXECUTE ON FUNCTION get_user_analytics(uuid, uuid, text, timestamptz, timestamptz, text, text, int, int)
  TO authenticated, service_role;

-- -----------------------------------------------------------------------------
-- Migration: 20260515000000_webhook_retry.sql
-- -----------------------------------------------------------------------------
-- Migration: webhook_retry
--
-- Adds columns needed to retry failed webhook deliveries with exponential
-- back-off:
--   payload        — stores the signed payload so the retry can re-send it
--   attempt_count  — how many times delivery has been attempted
--   next_retry_at  — when the next retry should run (NULL = done / succeeded)
--
-- The cron endpoint /cron/retry-webhooks queries on next_retry_at and
-- re-dispatches deliveries that are past-due and have attempt_count < 5.

ALTER TABLE webhook_deliveries
  ADD COLUMN IF NOT EXISTS payload        JSONB,
  ADD COLUMN IF NOT EXISTS attempt_count  INTEGER     NOT NULL DEFAULT 1,
  ADD COLUMN IF NOT EXISTS next_retry_at  TIMESTAMPTZ;

-- Sparse index: only rows that are pending retry (failed + has a retry_at).
CREATE INDEX IF NOT EXISTS webhook_deliveries_retry_idx
  ON webhook_deliveries (next_retry_at)
  WHERE next_retry_at IS NOT NULL AND status = 'failed';

-- -----------------------------------------------------------------------------
-- Migration: 20260515010000_cron_job_runs.sql
-- -----------------------------------------------------------------------------
-- Track cron job execution history for the Settings → System monitor.
-- No org scoping — system-level table, accessed only via service_role.

CREATE TABLE IF NOT EXISTS cron_job_runs (
  id           UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
  job_name     TEXT        NOT NULL,
  ran_at       TIMESTAMPTZ NOT NULL DEFAULT now(),
  status       TEXT        NOT NULL CHECK (status IN ('ok', 'error')),
  duration_ms  INTEGER,
  error_message TEXT
);

ALTER TABLE cron_job_runs ENABLE ROW LEVEL SECURITY;
-- Deny all direct client access; only supabaseAdmin (service_role) reads/writes.
CREATE POLICY "deny_all" ON cron_job_runs USING (false);

-- Index for the "latest run per job" query pattern
CREATE INDEX IF NOT EXISTS cron_job_runs_job_name_ran_at_idx
  ON cron_job_runs (job_name, ran_at DESC);

-- Auto-prune: keep only the last 90 days of run history
CREATE OR REPLACE FUNCTION prune_cron_job_runs() RETURNS void LANGUAGE sql AS $$
  DELETE FROM cron_job_runs WHERE ran_at < now() - INTERVAL '90 days';
$$;

-- -----------------------------------------------------------------------------
-- Migration: 20260515100000_anomaly_contributing_factors_fn.sql
-- -----------------------------------------------------------------------------
-- Computes contributing factor data for a specific (provider, model) anomaly.
-- Returns token averages for both the observation and reference windows,
-- plus a distribution of error status codes in the observation window.
-- Called once per detected anomaly to explain WHY the anomaly occurred.

CREATE OR REPLACE FUNCTION get_anomaly_factors(
  p_org_id     UUID,
  p_provider   TEXT,
  p_model      TEXT,
  p_obs_start  TIMESTAMPTZ,
  p_ref_start  TIMESTAMPTZ,
  p_project_id UUID DEFAULT NULL
)
RETURNS TABLE (
  obs_prompt_tokens_mean     DOUBLE PRECISION,
  ref_prompt_tokens_mean     DOUBLE PRECISION,
  obs_completion_tokens_mean DOUBLE PRECISION,
  ref_completion_tokens_mean DOUBLE PRECISION,
  obs_total_tokens_mean      DOUBLE PRECISION,
  ref_total_tokens_mean      DOUBLE PRECISION,
  obs_status_distribution    JSONB
)
LANGUAGE sql
STABLE
AS $$
  WITH token_stats AS (
    SELECT
      AVG(CASE WHEN created_at >= p_obs_start THEN prompt_tokens::float8     END) AS obs_pt,
      AVG(CASE WHEN created_at <  p_obs_start THEN prompt_tokens::float8     END) AS ref_pt,
      AVG(CASE WHEN created_at >= p_obs_start THEN completion_tokens::float8 END) AS obs_ct,
      AVG(CASE WHEN created_at <  p_obs_start THEN completion_tokens::float8 END) AS ref_ct,
      AVG(CASE WHEN created_at >= p_obs_start THEN total_tokens::float8      END) AS obs_tt,
      AVG(CASE WHEN created_at <  p_obs_start THEN total_tokens::float8      END) AS ref_tt
    FROM requests
    WHERE organization_id = p_org_id
      AND provider        = p_provider
      AND model           = p_model
      AND created_at     >= p_ref_start
      AND (p_project_id IS NULL OR project_id = p_project_id)
  ),
  error_dist AS (
    SELECT COALESCE(
      jsonb_agg(
        jsonb_build_object('code', status_code, 'count', cnt)
        ORDER BY cnt DESC
      ),
      '[]'::jsonb
    ) AS dist
    FROM (
      SELECT status_code, COUNT(*) AS cnt
      FROM requests
      WHERE organization_id = p_org_id
        AND provider        = p_provider
        AND model           = p_model
        AND created_at     >= p_obs_start
        AND status_code    >= 400
        AND (p_project_id IS NULL OR project_id = p_project_id)
      GROUP BY status_code
      ORDER BY cnt DESC
      LIMIT 5
    ) sc
  )
  SELECT
    ts.obs_pt, ts.ref_pt,
    ts.obs_ct, ts.ref_ct,
    ts.obs_tt, ts.ref_tt,
    ed.dist
  FROM token_stats ts, error_dist ed;
$$;
