-- Mike Supabase schema -- Use this for a fresh Supabase database. Existing deployments should instead -- apply the dated incremental migration files in backend/migrations that are -- newer than the version of Mike they currently have deployed. create extension if not exists "pgcrypto"; -- --------------------------------------------------------------------------- -- User profiles -- --------------------------------------------------------------------------- create table if not exists public.user_profiles ( id uuid primary key default gen_random_uuid(), user_id uuid not null unique references auth.users(id) on delete cascade, display_name text, organisation text, tier text not null default 'Free', message_credits_used integer not null default 0, credits_reset_date timestamptz not null default (now() + interval '30 days'), title_model text, tabular_model text not null default 'gemini-3-flash-preview', quote_model text, mfa_on_login boolean not null default false, legal_research_us boolean not null default true, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); create index if not exists idx_user_profiles_user on public.user_profiles(user_id); create or replace function public.handle_new_user() returns trigger language plpgsql security definer set search_path = public as $$ begin insert into public.user_profiles (user_id) values (new.id) on conflict (user_id) do nothing; return new; exception when others then -- Never block signup if the profile insert fails. 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 procedure public.handle_new_user(); create table if not exists public.user_api_keys ( id uuid primary key default gen_random_uuid(), user_id uuid not null references auth.users(id) on delete cascade, provider text not null check (provider in ('claude', 'gemini', 'openai', 'openrouter', 'courtlistener')), encrypted_key text not null, iv text not null, auth_tag text not null, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), unique(user_id, provider) ); create index if not exists idx_user_api_keys_user on public.user_api_keys(user_id); alter table public.user_api_keys enable row level security; create table if not exists public.user_mcp_connectors ( id uuid primary key default gen_random_uuid(), user_id uuid not null references auth.users(id) on delete cascade, name text not null, transport text not null default 'streamable_http' check (transport in ('streamable_http')), server_url text not null, auth_type text not null default 'none' check (auth_type in ('none', 'bearer', 'oauth')), enabled boolean not null default true, tool_policy jsonb not null default '{}'::jsonb, encrypted_auth_config text, auth_config_iv text, auth_config_tag text, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); create index if not exists idx_user_mcp_connectors_user on public.user_mcp_connectors(user_id); alter table public.user_mcp_connectors enable row level security; create table if not exists public.user_mcp_oauth_tokens ( id uuid primary key default gen_random_uuid(), connector_id uuid not null references public.user_mcp_connectors(id) on delete cascade, encrypted_access_token text, access_token_iv text, access_token_tag text, encrypted_refresh_token text, refresh_token_iv text, refresh_token_tag text, token_type text, scope text, expires_at timestamptz, authorization_server text, token_endpoint text, client_id text, encrypted_client_secret text, client_secret_iv text, client_secret_tag text, resource text, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), unique(connector_id) ); alter table public.user_mcp_oauth_tokens enable row level security; create table if not exists public.user_mcp_oauth_states ( id uuid primary key default gen_random_uuid(), user_id uuid not null references auth.users(id) on delete cascade, connector_id uuid not null references public.user_mcp_connectors(id) on delete cascade, state_hash text not null unique, encrypted_state_config text not null, state_config_iv text not null, state_config_tag text not null, expires_at timestamptz not null, created_at timestamptz not null default now() ); create index if not exists idx_user_mcp_oauth_states_expires on public.user_mcp_oauth_states(expires_at); alter table public.user_mcp_oauth_states enable row level security; create table if not exists public.user_mcp_connector_tools ( id uuid primary key default gen_random_uuid(), connector_id uuid not null references public.user_mcp_connectors(id) on delete cascade, tool_name text not null, openai_tool_name text not null, title text, description text, input_schema jsonb not null default '{"type":"object","properties":{}}'::jsonb, output_schema jsonb, annotations jsonb not null default '{}'::jsonb, enabled boolean not null default true, requires_confirmation boolean not null default false, last_seen_at timestamptz not null default now(), created_at timestamptz not null default now(), updated_at timestamptz not null default now(), unique(connector_id, tool_name), unique(openai_tool_name) ); create index if not exists idx_user_mcp_connector_tools_connector on public.user_mcp_connector_tools(connector_id); alter table public.user_mcp_connector_tools enable row level security; create table if not exists public.user_mcp_tool_audit_logs ( id uuid primary key default gen_random_uuid(), user_id uuid not null references auth.users(id) on delete cascade, connector_id uuid not null references public.user_mcp_connectors(id) on delete cascade, tool_id uuid references public.user_mcp_connector_tools(id) on delete set null, tool_name text not null, openai_tool_name text not null, status text not null check (status in ('ok', 'error')), error_message text, duration_ms integer not null default 0, result_size_chars integer not null default 0, created_at timestamptz not null default now() ); create index if not exists idx_user_mcp_tool_audit_logs_user_created on public.user_mcp_tool_audit_logs(user_id, created_at desc); alter table public.user_mcp_tool_audit_logs enable row level security; -- --------------------------------------------------------------------------- -- Projects and documents -- --------------------------------------------------------------------------- create table if not exists public.projects ( id uuid primary key default gen_random_uuid(), user_id text not null, name text not null, cm_number text, visibility text not null default 'private', shared_with jsonb not null default '[]'::jsonb, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); create index if not exists idx_projects_user on public.projects(user_id); create index if not exists projects_shared_with_idx on public.projects using gin (shared_with); create table if not exists public.project_subfolders ( id uuid primary key default gen_random_uuid(), project_id uuid not null references public.projects(id) on delete cascade, user_id text not null, name text not null, parent_folder_id uuid references public.project_subfolders(id) on delete cascade, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); create index if not exists idx_project_subfolders_project on public.project_subfolders(project_id); create table if not exists public.documents ( id uuid primary key default gen_random_uuid(), project_id uuid references public.projects(id) on delete cascade, user_id text not null, status text not null default 'pending', folder_id uuid references public.project_subfolders(id) on delete set null, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); create index if not exists idx_documents_user_project on public.documents(user_id, project_id); create index if not exists idx_documents_project_folder on public.documents(project_id, folder_id); create table if not exists public.document_versions ( id uuid primary key default gen_random_uuid(), document_id uuid not null references public.documents(id) on delete cascade, storage_path text, pdf_storage_path text, source text not null default 'upload', version_number integer, filename text, file_type text, size_bytes integer, page_count integer, deleted_at timestamptz, deleted_by uuid, created_at timestamptz not null default now(), constraint document_versions_source_check check (source = any (array[ 'upload'::text, 'user_upload'::text, 'assistant_edit'::text, 'user_accept'::text, 'user_reject'::text, 'generated'::text ])) ); create index if not exists document_versions_document_id_idx on public.document_versions(document_id, created_at desc); create index if not exists document_versions_active_document_id_idx on public.document_versions(document_id, created_at desc) where deleted_at is null; create index if not exists document_versions_doc_vnum_idx on public.document_versions(document_id, version_number); do $$ begin if not exists ( select 1 from pg_constraint where conname = 'document_versions_doc_version_unique' and conrelid = 'public.document_versions'::regclass ) then alter table public.document_versions add constraint document_versions_doc_version_unique unique (document_id, version_number); end if; end; $$; alter table public.documents add column if not exists current_version_id uuid references public.document_versions(id) on delete set null; create table if not exists public.document_edits ( id uuid primary key default gen_random_uuid(), document_id uuid not null references public.documents(id) on delete cascade, chat_message_id uuid, version_id uuid not null references public.document_versions(id) on delete cascade, change_id text not null, del_w_id text, ins_w_id text, deleted_text text not null default '', inserted_text text not null default '', context_before text, context_after text, status text not null default 'pending' check (status = any (array[ 'pending'::text, 'accepted'::text, 'rejected'::text ])), created_at timestamptz not null default now(), resolved_at timestamptz ); create index if not exists document_edits_document_id_idx on public.document_edits(document_id, created_at desc); create index if not exists document_edits_message_id_idx on public.document_edits(chat_message_id); create index if not exists document_edits_version_id_idx on public.document_edits(version_id); -- --------------------------------------------------------------------------- -- Workflows -- --------------------------------------------------------------------------- create table if not exists public.workflows ( id uuid primary key default gen_random_uuid(), user_id text, title text not null, type text not null, prompt_md text, columns_config jsonb, practice text, is_system boolean not null default false, created_at timestamptz not null default now() ); create index if not exists idx_workflows_user on public.workflows(user_id); create table if not exists public.hidden_workflows ( id uuid primary key default gen_random_uuid(), user_id text not null, workflow_id text not null, created_at timestamptz not null default now(), unique(user_id, workflow_id) ); create index if not exists idx_hidden_workflows_user on public.hidden_workflows(user_id); create table if not exists public.workflow_shares ( id uuid primary key default gen_random_uuid(), workflow_id uuid not null references public.workflows(id) on delete cascade, shared_by_user_id text not null, shared_with_email text not null, allow_edit boolean not null default false, created_at timestamptz not null default now(), constraint workflow_shares_workflow_email_unique unique(workflow_id, shared_with_email) ); create index if not exists workflow_shares_workflow_id_idx on public.workflow_shares(workflow_id); create index if not exists workflow_shares_email_idx on public.workflow_shares(shared_with_email); create or replace function public.get_workflows_overview( p_user_id text, p_user_email text default null, p_type text default null ) returns table ( id uuid, user_id text, title text, type text, prompt_md text, columns_config jsonb, practice text, is_system boolean, created_at timestamptz, allow_edit boolean, is_owner boolean, shared_by_name text ) language sql stable as $$ with owned as ( select w.*, true as allow_edit, true as is_owner, null::text as shared_by_name, 0 as sort_bucket from public.workflows w where w.user_id = p_user_id and w.is_system = false and (p_type is null or w.type = p_type) ), shared as ( select w.*, ws.allow_edit, false as is_owner, nullif(trim(up.display_name), '') as shared_by_name, 1 as sort_bucket from public.workflow_shares ws join public.workflows w on w.id = ws.workflow_id left join public.user_profiles up on up.user_id::text = ws.shared_by_user_id where lower(ws.shared_with_email) = lower(coalesce(p_user_email, '')) and (p_type is null or w.type = p_type) ), visible_workflows as ( select * from owned union all select * from shared ) select vw.id, vw.user_id, vw.title, vw.type, vw.prompt_md, vw.columns_config, vw.practice, vw.is_system, vw.created_at, vw.allow_edit, vw.is_owner, vw.shared_by_name from visible_workflows vw order by vw.sort_bucket asc, vw.created_at desc; $$; -- --------------------------------------------------------------------------- -- Assistant chats -- --------------------------------------------------------------------------- create table if not exists public.chats ( id uuid primary key default gen_random_uuid(), project_id uuid references public.projects(id) on delete cascade, user_id text not null, title text, created_at timestamptz not null default now() ); create index if not exists idx_chats_user on public.chats(user_id); create index if not exists idx_chats_project on public.chats(project_id); create or replace function public.get_chats_overview( p_user_id text, p_limit integer default null ) returns table ( id uuid, project_id uuid, user_id text, title text, created_at timestamptz ) language sql stable as $$ select c.id, c.project_id, c.user_id, c.title, c.created_at from public.chats c where c.user_id = p_user_id or exists ( select 1 from public.projects p where p.id = c.project_id and p.user_id = p_user_id ) order by c.created_at desc limit case when p_limit is null then null else greatest(1, least(p_limit, 100)) end; $$; create table if not exists public.chat_messages ( id uuid primary key default gen_random_uuid(), chat_id uuid not null references public.chats(id) on delete cascade, role text not null, content jsonb, files jsonb, annotations jsonb, created_at timestamptz not null default now() ); create index if not exists idx_chat_messages_chat on public.chat_messages(chat_id); do $$ begin if not exists ( select 1 from pg_constraint where conname = 'document_edits_chat_message_id_fkey' and conrelid = 'public.document_edits'::regclass ) then alter table public.document_edits add constraint document_edits_chat_message_id_fkey foreign key (chat_message_id) references public.chat_messages(id) on delete set null; end if; end; $$; -- --------------------------------------------------------------------------- -- Tabular reviews -- --------------------------------------------------------------------------- create table if not exists public.tabular_reviews ( id uuid primary key default gen_random_uuid(), project_id uuid references public.projects(id) on delete cascade, user_id text not null, title text, columns_config jsonb, document_ids jsonb, workflow_id uuid references public.workflows(id) on delete set null, practice text, shared_with jsonb not null default '[]'::jsonb, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); create index if not exists idx_tabular_reviews_user on public.tabular_reviews(user_id); create index if not exists idx_tabular_reviews_project on public.tabular_reviews(project_id); create index if not exists tabular_reviews_shared_with_idx on public.tabular_reviews using gin (shared_with); create or replace function public.get_projects_overview( p_user_id text, p_user_email text default null ) returns table ( id uuid, user_id text, name text, cm_number text, shared_with jsonb, created_at timestamptz, updated_at timestamptz, is_owner boolean, owner_display_name text, owner_email text, document_count integer, chat_count integer, review_count integer ) language sql stable as $$ with visible_projects as ( select p.* from public.projects p where p.user_id = p_user_id or ( coalesce(p_user_email, '') <> '' and p.user_id <> p_user_id and p.shared_with @> jsonb_build_array(p_user_email) ) ), document_counts as ( select d.project_id, count(*)::integer as document_count from public.documents d where d.project_id in (select vp.id from visible_projects vp) group by d.project_id ), chat_counts as ( select c.project_id, count(*)::integer as chat_count from public.chats c where c.project_id in (select vp.id from visible_projects vp) group by c.project_id ), review_counts as ( select tr.project_id, count(*)::integer as review_count from public.tabular_reviews tr where tr.project_id in (select vp.id from visible_projects vp) group by tr.project_id ) select vp.id, vp.user_id, vp.name, vp.cm_number, vp.shared_with, vp.created_at, vp.updated_at, vp.user_id = p_user_id as is_owner, nullif(trim(up.display_name), '') as owner_display_name, null::text as owner_email, coalesce(dc.document_count, 0) as document_count, coalesce(cc.chat_count, 0) as chat_count, coalesce(rc.review_count, 0) as review_count from visible_projects vp left join public.user_profiles up on up.user_id::text = vp.user_id left join document_counts dc on dc.project_id = vp.id left join chat_counts cc on cc.project_id = vp.id left join review_counts rc on rc.project_id = vp.id order by vp.created_at desc; $$; create table if not exists public.tabular_cells ( id uuid primary key default gen_random_uuid(), review_id uuid not null references public.tabular_reviews(id) on delete cascade, document_id uuid not null references public.documents(id) on delete cascade, column_index integer not null, content text, citations jsonb, status text not null default 'pending', created_at timestamptz not null default now() ); create index if not exists idx_tabular_cells_review on public.tabular_cells(review_id, document_id, column_index); create or replace function public.get_tabular_reviews_overview( p_user_id text, p_user_email text default null, p_project_id text default null ) returns table ( id uuid, project_id uuid, user_id text, title text, columns_config jsonb, document_ids jsonb, workflow_id uuid, shared_with jsonb, created_at timestamptz, updated_at timestamptz, is_owner boolean, document_count integer ) language sql stable as $$ with accessible_projects as ( select p.id from public.projects p where p.user_id = p_user_id or ( coalesce(p_user_email, '') <> '' and p.user_id <> p_user_id and p.shared_with @> jsonb_build_array(p_user_email) ) ), visible_reviews as ( select tr.* from public.tabular_reviews tr where (p_project_id is null or tr.project_id::text = p_project_id) and ( p_project_id is null or exists ( select 1 from accessible_projects ap where ap.id::text = p_project_id ) ) and ( tr.user_id = p_user_id or ( tr.project_id in (select ap.id from accessible_projects ap) and tr.user_id <> p_user_id ) or ( p_project_id is null and coalesce(p_user_email, '') <> '' and tr.user_id <> p_user_id and tr.shared_with @> jsonb_build_array(p_user_email) ) ) ), cell_document_counts as ( select tc.review_id, count(distinct tc.document_id)::integer as document_count from public.tabular_cells tc where tc.review_id in (select vr.id from visible_reviews vr) group by tc.review_id ) select vr.id, vr.project_id, vr.user_id, vr.title, vr.columns_config, vr.document_ids, vr.workflow_id, vr.shared_with, vr.created_at, vr.updated_at, vr.user_id = p_user_id as is_owner, case when jsonb_typeof(vr.document_ids) = 'array' then ( select count(distinct doc_id.value)::integer from jsonb_array_elements_text(vr.document_ids) as doc_id(value) ) else coalesce(cdc.document_count, 0) end as document_count from visible_reviews vr left join cell_document_counts cdc on cdc.review_id = vr.id order by vr.created_at desc; $$; create table if not exists public.tabular_review_chats ( id uuid primary key default gen_random_uuid(), review_id uuid not null references public.tabular_reviews(id) on delete cascade, user_id text not null, title text, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); create index if not exists tabular_review_chats_review_idx on public.tabular_review_chats(review_id, updated_at desc); create index if not exists tabular_review_chats_user_idx on public.tabular_review_chats(user_id); create table if not exists public.tabular_review_chat_messages ( id uuid primary key default gen_random_uuid(), chat_id uuid not null references public.tabular_review_chats(id) on delete cascade, role text not null, content jsonb, annotations jsonb, created_at timestamptz not null default now() ); create index if not exists tabular_review_chat_messages_chat_idx on public.tabular_review_chat_messages(chat_id, created_at); -- --------------------------------------------------------------------------- -- CourtListener bulk-data indexes -- --------------------------------------------------------------------------- create table if not exists public.courtlistener_citation_index ( id bigint primary key, volume text not null, reporter text not null, page text not null, type integer, cluster_id bigint not null, date_created timestamptz, date_modified timestamptz ); create index if not exists courtlistener_citation_lookup_idx on public.courtlistener_citation_index(volume, reporter, page); create index if not exists courtlistener_citation_cluster_idx on public.courtlistener_citation_index(cluster_id); alter table public.courtlistener_citation_index enable row level security; create table if not exists public.courtlistener_opinion_cluster_index ( id bigint primary key, case_name text, case_name_short text, case_name_full text, slug text, date_filed date, citation_count integer, precedential_status text, filepath_pdf_harvard text, filepath_json_harvard text, docket_id bigint ); alter table public.courtlistener_opinion_cluster_index enable row level security; -- --------------------------------------------------------------------------- -- Direct client grant hardening -- --------------------------------------------------------------------------- -- -- The frontend uses Supabase directly only for authentication. Application -- data access goes through the backend API with the service role after the -- backend verifies the user's JWT. Do not grant the browser anon/authenticated -- roles direct table privileges for backend-owned data. revoke all on public.user_profiles from anon, authenticated; revoke all on public.projects from anon, authenticated; revoke all on public.project_subfolders from anon, authenticated; revoke all on public.documents from anon, authenticated; revoke all on public.document_versions from anon, authenticated; revoke all on public.document_edits from anon, authenticated; revoke all on public.workflows from anon, authenticated; revoke all on public.hidden_workflows from anon, authenticated; revoke all on public.workflow_shares from anon, authenticated; revoke all on public.chats from anon, authenticated; revoke all on public.chat_messages from anon, authenticated; revoke all on public.tabular_reviews from anon, authenticated; revoke all on public.tabular_cells from anon, authenticated; revoke all on public.tabular_review_chats from anon, authenticated; revoke all on public.tabular_review_chat_messages from anon, authenticated; revoke all on public.user_api_keys from anon, authenticated; revoke all on public.user_mcp_connectors from anon, authenticated; revoke all on public.user_mcp_oauth_tokens from anon, authenticated; revoke all on public.user_mcp_oauth_states from anon, authenticated; revoke all on public.user_mcp_connector_tools from anon, authenticated; revoke all on public.user_mcp_tool_audit_logs from anon, authenticated; revoke all on public.courtlistener_citation_index from anon, authenticated; revoke all on public.courtlistener_opinion_cluster_index from anon, authenticated;