-- The Disclosure Bureau — chat schema -- Apply via Supabase Studio SQL editor OR psql on the production DB. -- Safe to re-run (uses IF NOT EXISTS guards). -- 1. profiles — 1:1 with auth.users, holds budget + role CREATE TABLE IF NOT EXISTS public.profiles ( id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, display_name TEXT, avatar_url TEXT, role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('user','admin','suspended')), budget_cap_usd NUMERIC(10,4) NOT NULL DEFAULT 5.0, total_cost_usd NUMERIC(10,4) NOT NULL DEFAULT 0, daily_quota INT NOT NULL DEFAULT 100, daily_used INT NOT NULL DEFAULT 0, quota_reset_at TIMESTAMPTZ NOT NULL DEFAULT (DATE_TRUNC('day', NOW()) + INTERVAL '1 day'), preferred_locale TEXT DEFAULT 'pt-BR', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- Auto-create profile on user signup CREATE OR REPLACE FUNCTION public.handle_new_user() RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ BEGIN INSERT INTO public.profiles (id, display_name) VALUES (NEW.id, COALESCE(NEW.raw_user_meta_data->>'full_name', NEW.email)); RETURN NEW; END; $$; DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users; CREATE TRIGGER on_auth_user_created AFTER INSERT ON auth.users FOR EACH ROW EXECUTE FUNCTION public.handle_new_user(); -- 2. chat_sessions — one conversation per row CREATE TABLE IF NOT EXISTS public.chat_sessions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, title TEXT, summary TEXT, context_doc_id TEXT, context_page_id TEXT, is_public BOOLEAN NOT NULL DEFAULT FALSE, share_token TEXT UNIQUE, archived BOOLEAN NOT NULL DEFAULT FALSE, message_count INT NOT NULL DEFAULT 0, total_tokens INT NOT NULL DEFAULT 0, total_cost_usd NUMERIC(10,4) NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_sessions_user_updated ON public.chat_sessions(user_id, updated_at DESC) WHERE NOT archived; CREATE INDEX IF NOT EXISTS idx_sessions_share ON public.chat_sessions(share_token) WHERE share_token IS NOT NULL; -- 3. messages — one row per turn CREATE TABLE IF NOT EXISTS public.messages ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), session_id UUID NOT NULL REFERENCES public.chat_sessions(id) ON DELETE CASCADE, role TEXT NOT NULL CHECK (role IN ('user','assistant','tool','system')), content TEXT NOT NULL, tool_calls JSONB, tool_results JSONB, citations JSONB, model TEXT, tokens_in INT, tokens_out INT, cost_usd NUMERIC(10,6), duration_ms INT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_messages_session ON public.messages(session_id, created_at); -- 4. usage_events — audit log of every billable action (optional but cheap) CREATE TABLE IF NOT EXISTS public.usage_events ( id BIGSERIAL PRIMARY KEY, user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, session_id UUID REFERENCES public.chat_sessions(id) ON DELETE SET NULL, event_type TEXT NOT NULL, -- 'message','tool_call','enrichment',... cost_usd NUMERIC(10,6) NOT NULL DEFAULT 0, metadata JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_usage_user_created ON public.usage_events(user_id, created_at DESC); -- 5. updated_at trigger CREATE OR REPLACE FUNCTION public.touch_updated_at() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$; DROP TRIGGER IF EXISTS profiles_touch ON public.profiles; CREATE TRIGGER profiles_touch BEFORE UPDATE ON public.profiles FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at(); DROP TRIGGER IF EXISTS sessions_touch ON public.chat_sessions; CREATE TRIGGER sessions_touch BEFORE UPDATE ON public.chat_sessions FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at(); -- 6. message-count + cost rollup trigger CREATE OR REPLACE FUNCTION public.rollup_session_stats() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN UPDATE public.chat_sessions SET message_count = message_count + 1, total_tokens = total_tokens + COALESCE(NEW.tokens_in, 0) + COALESCE(NEW.tokens_out, 0), total_cost_usd = total_cost_usd + COALESCE(NEW.cost_usd, 0), updated_at = NOW() WHERE id = NEW.session_id; -- Also bump the user's total UPDATE public.profiles p SET total_cost_usd = total_cost_usd + COALESCE(NEW.cost_usd, 0), daily_used = daily_used + 1 FROM public.chat_sessions s WHERE s.id = NEW.session_id AND p.id = s.user_id; RETURN NEW; END; $$; DROP TRIGGER IF EXISTS messages_rollup ON public.messages; CREATE TRIGGER messages_rollup AFTER INSERT ON public.messages FOR EACH ROW EXECUTE FUNCTION public.rollup_session_stats(); -- 7. ROW LEVEL SECURITY — defense in depth ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY; ALTER TABLE public.chat_sessions ENABLE ROW LEVEL SECURITY; ALTER TABLE public.messages ENABLE ROW LEVEL SECURITY; ALTER TABLE public.usage_events ENABLE ROW LEVEL SECURITY; -- profiles: user sees/updates only their own DROP POLICY IF EXISTS "own_profile_select" ON public.profiles; CREATE POLICY "own_profile_select" ON public.profiles FOR SELECT USING (auth.uid() = id); DROP POLICY IF EXISTS "own_profile_update" ON public.profiles; CREATE POLICY "own_profile_update" ON public.profiles FOR UPDATE USING (auth.uid() = id); -- chat_sessions: user's own + anyone can read public-shared ones DROP POLICY IF EXISTS "own_sessions_select" ON public.chat_sessions; CREATE POLICY "own_sessions_select" ON public.chat_sessions FOR SELECT USING (auth.uid() = user_id OR is_public = TRUE); DROP POLICY IF EXISTS "own_sessions_modify" ON public.chat_sessions; CREATE POLICY "own_sessions_modify" ON public.chat_sessions FOR ALL USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id); -- messages: only owners of the parent session (or public if session is_public) DROP POLICY IF EXISTS "session_messages_select" ON public.messages; CREATE POLICY "session_messages_select" ON public.messages FOR SELECT USING ( EXISTS (SELECT 1 FROM public.chat_sessions s WHERE s.id = messages.session_id AND (s.user_id = auth.uid() OR s.is_public = TRUE)) ); DROP POLICY IF EXISTS "session_messages_insert" ON public.messages; CREATE POLICY "session_messages_insert" ON public.messages FOR INSERT WITH CHECK ( EXISTS (SELECT 1 FROM public.chat_sessions s WHERE s.id = messages.session_id AND s.user_id = auth.uid()) ); DROP POLICY IF EXISTS "session_messages_delete" ON public.messages; CREATE POLICY "session_messages_delete" ON public.messages FOR DELETE USING ( EXISTS (SELECT 1 FROM public.chat_sessions s WHERE s.id = messages.session_id AND s.user_id = auth.uid()) ); -- usage_events: insert by service role only; user can read their own DROP POLICY IF EXISTS "own_usage_select" ON public.usage_events; CREATE POLICY "own_usage_select" ON public.usage_events FOR SELECT USING (auth.uid() = user_id); -- 8. Helper RPC: get_or_create_session CREATE OR REPLACE FUNCTION public.get_or_create_session( p_context_doc_id TEXT DEFAULT NULL, p_context_page_id TEXT DEFAULT NULL, p_title TEXT DEFAULT NULL ) RETURNS UUID LANGUAGE plpgsql SECURITY INVOKER SET search_path = public AS $$ DECLARE new_id UUID; BEGIN IF auth.uid() IS NULL THEN RAISE EXCEPTION 'unauthenticated'; END IF; new_id := gen_random_uuid(); INSERT INTO chat_sessions (id, user_id, title, context_doc_id, context_page_id) VALUES (new_id, auth.uid(), p_title, p_context_doc_id, p_context_page_id); RETURN new_id; END; $$; GRANT EXECUTE ON FUNCTION public.get_or_create_session TO authenticated; -- 9. Helper RPC: enforce budget cap CREATE OR REPLACE FUNCTION public.check_budget(p_user_id UUID) RETURNS BOOLEAN LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ DECLARE prof RECORD; BEGIN SELECT * INTO prof FROM profiles WHERE id = p_user_id; IF prof.role = 'suspended' THEN RETURN FALSE; END IF; IF prof.total_cost_usd >= prof.budget_cap_usd THEN RETURN FALSE; END IF; -- Reset daily counter if past midnight IF prof.quota_reset_at <= NOW() THEN UPDATE profiles SET daily_used = 0, quota_reset_at = DATE_TRUNC('day', NOW()) + INTERVAL '1 day' WHERE id = p_user_id; RETURN TRUE; END IF; IF prof.daily_used >= prof.daily_quota THEN RETURN FALSE; END IF; RETURN TRUE; END; $$; GRANT EXECUTE ON FUNCTION public.check_budget TO authenticated;