91 lines
3.7 KiB
SQL
91 lines
3.7 KiB
SQL
-- ============================================================================
|
|
-- 0005_surface_metrics.sql
|
|
-- ----------------------------------------------------------------------------
|
|
-- Per-surface raw metrics snapshots. Insert-only on each ingestion (no
|
|
-- UPDATE); deduplicate on read via DISTINCT ON (window).
|
|
--
|
|
-- Adapters write rows; analytics-dashboard panels read via DISTINCT ON to
|
|
-- pick the most-recently-fetched snapshot per (user, surface, kind, window).
|
|
--
|
|
-- Spec source: _engineering-surface-metrics.md §3.
|
|
-- ============================================================================
|
|
|
|
CREATE TYPE surface_metric_kind AS ENUM (
|
|
-- Discovery funnel
|
|
'profile_view',
|
|
'search_impression',
|
|
'search_rank',
|
|
'click_through',
|
|
-- Engagement funnel
|
|
'dm_inbound',
|
|
'dm_outbound',
|
|
'reply_rate',
|
|
-- Conversion funnel
|
|
'subscription_new',
|
|
'subscription_total',
|
|
'tip_amount',
|
|
'tip_count',
|
|
'booking_inquiry',
|
|
'booking_confirmed',
|
|
-- Revenue
|
|
'gross_revenue',
|
|
'net_revenue'
|
|
);
|
|
|
|
CREATE TYPE surface_metric_source AS ENUM (
|
|
'native_api', -- surface exposed an API endpoint
|
|
'native_scrape', -- adapter scraped the surface's own analytics page
|
|
'derived', -- computed from other rows (e.g. reply_rate from dm_in/dm_out)
|
|
'manual' -- Quinn entered manually
|
|
);
|
|
|
|
CREATE TABLE surface_metrics (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
org_id UUID NULL REFERENCES orgs(id) ON DELETE CASCADE,
|
|
surface TEXT NOT NULL, -- surface_kind value
|
|
metric_kind surface_metric_kind NOT NULL,
|
|
window_start TIMESTAMPTZ NOT NULL,
|
|
window_end TIMESTAMPTZ NOT NULL,
|
|
value_numeric NUMERIC(18,4), -- counts, sums, rates
|
|
value_text TEXT, -- rank labels ("Boosted"), categorical
|
|
currency TEXT, -- ISO 4217 when monetary
|
|
source surface_metric_source NOT NULL,
|
|
fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
payload_jsonb JSONB, -- raw surface-side response for audit
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
|
|
CONSTRAINT surface_metrics_window_chk CHECK (window_end > window_start),
|
|
CONSTRAINT surface_metrics_value_chk CHECK (value_numeric IS NOT NULL OR value_text IS NOT NULL),
|
|
CONSTRAINT surface_metrics_currency_chk CHECK (
|
|
currency IS NULL
|
|
OR (currency ~ '^[A-Z]{3}$' AND value_numeric IS NOT NULL)
|
|
)
|
|
);
|
|
|
|
CREATE INDEX idx_surface_metrics_user_surface_kind_window
|
|
ON surface_metrics (user_id, surface, metric_kind, window_end DESC);
|
|
|
|
CREATE INDEX idx_surface_metrics_org_surface_kind_window
|
|
ON surface_metrics (org_id, surface, metric_kind, window_end DESC)
|
|
WHERE org_id IS NOT NULL;
|
|
|
|
-- Dedup read: DISTINCT ON (surface, metric_kind, window_start, window_end)
|
|
-- ordered by fetched_at DESC picks the latest snapshot per window.
|
|
CREATE INDEX idx_surface_metrics_dedup
|
|
ON surface_metrics (user_id, surface, metric_kind, window_start, window_end, fetched_at DESC);
|
|
|
|
ALTER TABLE surface_metrics ENABLE ROW LEVEL SECURITY;
|
|
|
|
CREATE POLICY tenant_isolation_surface_metrics ON surface_metrics
|
|
USING (
|
|
user_id = current_setting('app.user_id', true)::uuid
|
|
OR (org_id IS NOT NULL AND org_id = current_setting('app.org_id', true)::uuid)
|
|
)
|
|
WITH CHECK (
|
|
user_id = current_setting('app.user_id', true)::uuid
|
|
OR (org_id IS NOT NULL AND org_id = current_setting('app.org_id', true)::uuid)
|
|
);
|
|
|
|
COMMENT ON TABLE surface_metrics IS
|
|
'Per-surface metric snapshots. Insert-only; reads use DISTINCT ON to dedup overlapping windows. Tier-gated surfaces (e.g. Tryst Basic) simply contribute no rows — UI shows "—" not "0".';
|