refactor: add table primitive and migrations by date; feat: add mcp connectors

This commit is contained in:
willchen96 2026-06-15 17:34:58 +08:00
parent 01dfcfe0d4
commit 9a1277ba99
99 changed files with 9344 additions and 2320 deletions

View file

@ -0,0 +1,32 @@
-- Migration date: 2026-04-19
-- Migration: Convert tabular_review_chat_messages.content from TEXT to JSONB
-- and add annotations JSONB column.
--
-- User messages: content TEXT → JSON string (e.g. "hello" → '"hello"')
-- Assistant messages: content TEXT → events array (e.g. "answer" → '[{"type":"content","text":"answer"}]')
--
-- Only convert while content is still TEXT. Re-running over jsonb content would
-- double-wrap assistant events, so the type check makes this safe to re-run.
DO $$
BEGIN
IF (
SELECT data_type
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'tabular_review_chat_messages'
AND column_name = 'content'
) = 'text' THEN
ALTER TABLE tabular_review_chat_messages
ALTER COLUMN content TYPE jsonb
USING CASE
WHEN role = 'user'
THEN to_jsonb(content)
ELSE
jsonb_build_array(jsonb_build_object('type', 'content', 'text', content))
END;
END IF;
END $$;
ALTER TABLE tabular_review_chat_messages
ADD COLUMN IF NOT EXISTS annotations jsonb;

View file

@ -0,0 +1,52 @@
-- Migration date: 2026-04-21
-- Migration: DOCX editing with tracked changes.
-- Adds per-edit Accept/Reject state and a pointer to the document's current version.
-- Assumes document_versions table already exists (see separate migration).
-- 1. Broaden document_versions.source to include 'user_reject'.
ALTER TABLE public.document_versions
DROP CONSTRAINT IF EXISTS document_versions_source_check;
ALTER TABLE public.document_versions
ADD CONSTRAINT document_versions_source_check
CHECK (source = ANY (ARRAY[
'upload'::text,
'assistant_edit'::text,
'user_accept'::text,
'user_reject'::text,
'generated'::text
]));
-- 2. Point each document at its currently active version (null = original upload).
ALTER TABLE public.documents
ADD COLUMN IF NOT EXISTS current_version_id uuid
REFERENCES public.document_versions(id) ON DELETE SET NULL;
-- 3. Per-edit registry. One row per tracked change proposed by the assistant.
-- change_id is the w:id written into document.xml so Accept/Reject can
-- locate the specific w:ins/w:del pair on the latest version.
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 REFERENCES public.chat_messages(id) ON DELETE SET NULL,
version_id uuid NOT NULL REFERENCES public.document_versions(id) ON DELETE CASCADE,
change_id text NOT NULL,
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);

View file

@ -0,0 +1,9 @@
-- Migration date: 2026-04-21
-- Migration: add optional per-user API keys for direct provider access.
-- When set, these keys override the server-wide env keys for that user's
-- requests; callers must fall back to env when null.
ALTER TABLE user_profiles
ADD COLUMN IF NOT EXISTS claude_api_key TEXT,
ADD COLUMN IF NOT EXISTS gemini_api_key TEXT;

View file

@ -0,0 +1,10 @@
-- Migration date: 2026-04-23
-- Migration: persist the actual w:ins / w:del numeric ids alongside the
-- logical change_id. Accept/Reject needs these to locate the wrapper
-- elements inside document.xml; change_id is our own opaque label and
-- never lands in the file.
ALTER TABLE public.document_edits
ADD COLUMN IF NOT EXISTS del_w_id text,
ADD COLUMN IF NOT EXISTS ins_w_id text;

View file

@ -0,0 +1,32 @@
-- Migration date: 2026-04-23
-- Migration: give each assistant-produced version of a document a
-- monotonic per-document version number (V1, V2, …). Only
-- `source = 'assistant_edit'` rows carry a number; the original upload
-- and the ephemeral user_accept/user_reject rows stay NULL. Numbers are
-- stable once written — accept/reject now overwrite bytes in place
-- rather than insert new rows, so the sequence never has gaps.
ALTER TABLE public.document_versions
ADD COLUMN IF NOT EXISTS version_number integer;
-- Backfill: assign 1..N to the existing assistant_edit rows per doc,
-- ordered by created_at ascending. Safe to re-run (only writes NULLs).
WITH numbered AS (
SELECT
id,
ROW_NUMBER() OVER (
PARTITION BY document_id
ORDER BY created_at ASC
) AS rn
FROM public.document_versions
WHERE source = 'assistant_edit'
)
UPDATE public.document_versions dv
SET version_number = n.rn
FROM numbered n
WHERE dv.id = n.id
AND dv.version_number IS NULL;
CREATE INDEX IF NOT EXISTS document_versions_doc_vnum_idx
ON public.document_versions (document_id, version_number);

View file

@ -0,0 +1,35 @@
-- Migration date: 2026-04-24
-- Migration: per-version user-editable display name + user_upload source.
-- Lets users rename individual versions (the assistant-edit default is
-- "[Edited V{n}]") and differentiate manually-uploaded new versions from
-- the original upload.
ALTER TABLE public.document_versions
ADD COLUMN IF NOT EXISTS display_name text;
-- Broaden source to include 'user_upload' for versions the user uploads
-- after the original document creation.
ALTER TABLE public.document_versions
DROP CONSTRAINT IF EXISTS document_versions_source_check;
ALTER TABLE public.document_versions
ADD 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
]));
-- Backfill: default display_name to the parent document's filename. New
-- assistant edits inherit the prior version's display_name (see
-- runEditDocument), so the version number is no longer baked into the
-- default label — it's surfaced as a separate tag in the UI.
UPDATE public.document_versions dv
SET display_name = d.filename
FROM public.documents d
WHERE dv.display_name IS NULL
AND d.id = dv.document_id;

View file

@ -0,0 +1,31 @@
-- Migration date: 2026-04-24
-- Migration: number the original upload as V1 so assistant edits start at V2.
-- Before: upload rows had version_number NULL, assistant_edit rows started at 1.
-- After: every row in document_versions has a monotonic per-document V# with
-- the upload as V1.
-- Guard: this shift is not naturally idempotent (re-running would bump the
-- numbers again). An unnumbered upload row is the signal that the migration
-- has not run yet; once every upload row is numbered there is nothing to do.
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM public.document_versions
WHERE source = 'upload' AND version_number IS NULL
) THEN
-- 1. Shift existing assistant_edit + user_upload numbers up by 1 so they no
-- longer collide with the upload's new V1. Done first so we don't violate
-- any uniqueness constraint while the upload row still lacks a number.
UPDATE public.document_versions
SET version_number = version_number + 1
WHERE source IN ('assistant_edit', 'user_upload')
AND version_number IS NOT NULL;
-- 2. Backfill every upload row's version_number to 1.
UPDATE public.document_versions
SET version_number = 1
WHERE source = 'upload'
AND version_number IS NULL;
END IF;
END $$;

View file

@ -0,0 +1,81 @@
-- Migration date: 2026-04-27
-- Move storage_path and pdf_storage_path from documents to document_versions.
--
-- Rationale: there were two sources of truth for "where the bytes live"
-- - documents.{storage_path, pdf_storage_path} (set on initial upload)
-- - document_versions.storage_path (set on each new version)
-- New-version uploads only updated the latter, so /display, downloads,
-- and assistant context all drifted to the original upload's bytes.
--
-- After this migration:
-- - document_versions owns storage_path and pdf_storage_path.
-- - documents.current_version_id is the only "which version is live" pointer.
-- - documents.{storage_path, pdf_storage_path} are dropped.
-- 1. Add pdf_storage_path to document_versions.
alter table public.document_versions
add column if not exists pdf_storage_path text;
-- 2. Backfill: ensure every document has at least one document_versions row
-- (the original upload). Older docs may predate document_versions entirely.
insert into public.document_versions (
document_id,
storage_path,
pdf_storage_path,
source,
version_number,
display_name,
created_at
)
select
d.id,
d.storage_path,
d.pdf_storage_path,
'upload',
1,
d.filename,
d.created_at
from public.documents d
left join public.document_versions dv
on dv.document_id = d.id and dv.source = 'upload'
where dv.id is null
and d.storage_path is not null;
-- 3. Backfill pdf_storage_path onto the existing 'upload' rows for docs
-- that already had one but predate document_versions.pdf_storage_path.
update public.document_versions dv
set pdf_storage_path = d.pdf_storage_path
from public.documents d
where dv.document_id = d.id
and dv.source = 'upload'
and dv.pdf_storage_path is null
and d.pdf_storage_path is not null;
-- 4. Backfill current_version_id for any document missing one — point it
-- at the most recent version (assistant_edit / user_upload preferred,
-- else the upload row).
update public.documents d
set current_version_id = sub.id
from (
select distinct on (document_id) id, document_id
from public.document_versions
order by document_id,
case source
when 'assistant_edit' then 1
when 'user_upload' then 2
when 'user_accept' then 3
when 'user_reject' then 4
when 'generated' then 5
when 'upload' then 6
else 7
end,
version_number desc nulls last,
created_at desc
) sub
where d.id = sub.document_id
and d.current_version_id is null;
-- 5. Drop the columns from documents.
alter table public.documents drop column if exists storage_path;
alter table public.documents drop column if exists pdf_storage_path;

View file

@ -0,0 +1,13 @@
-- Migration date: 2026-04-27
-- Migration: add shared_with to tabular_reviews so standalone reviews
-- (project_id IS NULL) can be shared by email, mirroring projects.shared_with.
-- Project-scoped reviews continue to inherit access from their parent project.
alter table public.tabular_reviews
add column if not exists shared_with jsonb not null default '[]';
-- Optional but worth it: a generic GIN index speeds up the contains-query
-- the backend uses to fan out shared-review listings.
create index if not exists tabular_reviews_shared_with_idx
on public.tabular_reviews using gin (shared_with);

View file

@ -0,0 +1,8 @@
-- Migration date: 2026-04-27
-- Migration: capture the user's organisation alongside display_name.
-- Collected on the signup form (optional) and editable from the account
-- page. Used for display only; no business logic depends on it yet.
ALTER TABLE user_profiles
ADD COLUMN IF NOT EXISTS organisation TEXT;

View file

@ -0,0 +1,28 @@
-- Migration date: 2026-04-28
-- Migration: enforce one share row per (workflow, recipient email) so
-- re-sharing to the same person updates the existing row instead of
-- creating duplicates. Without this, DELETE only removes one of N copies
-- and the recipient retains access after the owner thinks they revoked.
-- Collapse any existing duplicates first, keeping the most recent row.
delete from public.workflow_shares a
using public.workflow_shares b
where a.workflow_id = b.workflow_id
and a.shared_with_email = b.shared_with_email
and a.created_at < b.created_at;
-- Add the unique constraint only if it is not already present (ADD CONSTRAINT
-- has no IF NOT EXISTS form, so re-running the bare statement would error).
do $$
begin
if not exists (
select 1 from pg_constraint
where conname = 'workflow_shares_workflow_email_unique'
and conrelid = 'public.workflow_shares'::regclass
) then
alter table public.workflow_shares
add constraint workflow_shares_workflow_email_unique
unique (workflow_id, shared_with_email);
end if;
end $$;

View file

@ -0,0 +1,25 @@
-- Migration date: 2026-05-02
-- Migration: move BYO provider API keys into encrypted, server-only storage.
-- The backend encrypts values before writing them. RLS is enabled with no
-- client policies so browser Supabase clients cannot read key material.
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;
-- Legacy plaintext columns remain temporarily so the backend can migrate
-- existing users on first use, then clear each migrated value.

View file

@ -0,0 +1,36 @@
-- Migration date: 2026-05-08
-- Migration: make application data tables backend-only.
-- RLS remains enabled as defense in depth, but direct browser Supabase clients
-- should not be able to query or mutate these tables with anon/authenticated.
DO $$
DECLARE
table_name text;
backend_only_tables text[] := ARRAY[
'projects',
'project_subfolders',
'documents',
'document_versions',
'document_edits',
'workflows',
'hidden_workflows',
'workflow_shares',
'chats',
'chat_messages',
'tabular_reviews',
'tabular_cells',
'tabular_review_chats',
'tabular_review_chat_messages',
'user_api_keys'
];
BEGIN
FOREACH table_name IN ARRAY backend_only_tables LOOP
IF to_regclass(format('public.%I', table_name)) IS NOT NULL THEN
EXECUTE format(
'REVOKE ALL PRIVILEGES ON TABLE public.%I FROM anon, authenticated',
table_name
);
END IF;
END LOOP;
END $$;

View file

@ -0,0 +1,16 @@
-- Migration date: 2026-05-08
-- Migration: move user_profiles behind the backend API.
-- The frontend should use Supabase only for auth; profile reads and writes go
-- through /user/profile so internal fields cannot be mutated from the browser.
ALTER TABLE public.user_profiles ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "Users can view their own profile"
ON public.user_profiles;
DROP POLICY IF EXISTS "Users can update their own profile"
ON public.user_profiles;
REVOKE ALL PRIVILEGES ON TABLE public.user_profiles
FROM anon, authenticated;

View file

@ -0,0 +1,12 @@
-- Migration date: 2026-05-09
-- Allow users to store an OpenAI API key alongside Claude and Gemini keys.
do $$
begin
alter table public.user_api_keys
drop constraint if exists user_api_keys_provider_check;
alter table public.user_api_keys
add constraint user_api_keys_provider_check
check (provider in ('claude', 'gemini', 'openai'));
end $$;

View file

@ -0,0 +1,26 @@
-- Migration date: 2026-05-11
-- Store landing-page contact form submissions.
-- The landing server route writes with the Supabase service role; browser
-- anon/authenticated roles should not have direct table access.
create table if not exists public.contact_messages (
id uuid primary key default gen_random_uuid(),
name text,
email text not null,
subject text,
message text not null,
source text not null default 'landing',
user_agent text,
ip_hash text,
created_at timestamptz not null default now(),
responded_at timestamptz
);
create index if not exists idx_contact_messages_created_at
on public.contact_messages(created_at desc);
alter table public.contact_messages enable row level security;
revoke all privileges on table public.contact_messages
from anon, authenticated;

View file

@ -0,0 +1,36 @@
-- Migration date: 2026-05-13
-- Migration: convert projects.shared_with from text[] to jsonb.
-- tabular_reviews.shared_with is already jsonb and is intentionally untouched.
-- Only convert while shared_with is still text[]. Re-running the type change
-- over an already-jsonb column is unnecessary and the guard keeps it a no-op.
do $$
begin
if (
select data_type
from information_schema.columns
where table_schema = 'public'
and table_name = 'projects'
and column_name = 'shared_with'
) = 'ARRAY' then
alter table public.projects
alter column shared_with drop default;
alter table public.projects
alter column shared_with type jsonb
using case
when shared_with is null then '[]'::jsonb
else to_jsonb(shared_with)
end;
end if;
end $$;
alter table public.projects
alter column shared_with set default '[]'::jsonb;
alter table public.projects
alter column shared_with set not null;
create index if not exists projects_shared_with_idx
on public.projects using gin (shared_with);

View file

@ -0,0 +1,12 @@
-- Migration date: 2026-05-17
-- Persist selected document rows independently from generated cells.
-- This lets project-based tabular reviews keep an explicit document list even
-- when the review has no columns/cells or all rows have been removed.
alter table public.tabular_reviews
add column if not exists document_ids jsonb;
alter table public.tabular_reviews
alter column document_ids drop not null,
alter column document_ids drop default;

View file

@ -0,0 +1,41 @@
-- Migration date: 2026-05-23
-- CourtListener bulk-data indexes.
--
-- These tables hold lightweight lookup metadata imported from CourtListener
-- CSV exports. Full opinion bodies are stored in R2 at:
-- courtlistener/opinions/by-cluster/{cluster_id}/{opinion_id}.json
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);
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
);
revoke all on public.courtlistener_citation_index from anon, authenticated;
revoke all on public.courtlistener_opinion_cluster_index from anon, authenticated;

View file

@ -0,0 +1,8 @@
-- Migration date: 2026-05-28
ALTER TABLE public.user_api_keys
DROP CONSTRAINT IF EXISTS user_api_keys_provider_check;
ALTER TABLE public.user_api_keys
ADD CONSTRAINT user_api_keys_provider_check
CHECK (provider IN ('claude', 'gemini', 'openai', 'openrouter', 'courtlistener'));

View file

@ -0,0 +1,8 @@
-- Migration date: 2026-05-28
ALTER TABLE public.user_api_keys
DROP CONSTRAINT IF EXISTS user_api_keys_provider_check;
ALTER TABLE public.user_api_keys
ADD CONSTRAINT user_api_keys_provider_check
CHECK (provider IN ('claude', 'gemini', 'openai', 'openrouter', 'courtlistener'));

View file

@ -0,0 +1,5 @@
-- Migration date: 2026-05-28
ALTER TABLE public.user_profiles
ADD COLUMN IF NOT EXISTS title_model text,
ADD COLUMN IF NOT EXISTS quote_model text;

View file

@ -0,0 +1,28 @@
-- Migration date: 2026-06-02
-- Add per-version file metadata.
--
-- documents is the stable container. document_versions owns the bytes for each
-- version, so file metadata that describes those bytes belongs here too.
--
-- Safe to run before application code changes: this only adds nullable columns
-- and backfills them from the parent document.
ALTER TABLE public.document_versions
ADD COLUMN IF NOT EXISTS file_type text,
ADD COLUMN IF NOT EXISTS size_bytes integer,
ADD COLUMN IF NOT EXISTS page_count integer;
UPDATE public.document_versions dv
SET
file_type = COALESCE(NULLIF(btrim(dv.file_type), ''), d.file_type),
size_bytes = COALESCE(dv.size_bytes, d.size_bytes),
page_count = COALESCE(dv.page_count, d.page_count)
FROM public.documents d
WHERE dv.document_id = d.id
AND (
dv.file_type IS NULL
OR btrim(dv.file_type) = ''
OR dv.size_bytes IS NULL
OR dv.page_count IS NULL
);

View file

@ -0,0 +1,13 @@
-- Migration date: 2026-06-02
-- Temporary live-Supabase migration: add document_versions.filename without
-- renaming or dropping document_versions.display_name yet.
ALTER TABLE public.document_versions
ADD COLUMN IF NOT EXISTS filename text;
UPDATE public.document_versions
SET filename = display_name
WHERE (filename IS NULL OR btrim(filename) = '')
AND display_name IS NOT NULL
AND btrim(display_name) <> '';

View file

@ -0,0 +1,51 @@
-- Migration date: 2026-06-02
-- Destructive follow-up migration: remove legacy document-level file metadata.
--
-- Run this only after application code writes file_type, size_bytes,
-- and page_count to document_versions and reads those values
-- from the active version.
DO $$
DECLARE
documents_file_metadata_count integer;
BEGIN
SELECT count(*)
INTO documents_file_metadata_count
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'documents'
AND column_name IN (
'file_type',
'size_bytes',
'page_count'
);
IF documents_file_metadata_count = 3 THEN
ALTER TABLE public.document_versions
ADD COLUMN IF NOT EXISTS file_type text,
ADD COLUMN IF NOT EXISTS size_bytes integer,
ADD COLUMN IF NOT EXISTS page_count integer;
UPDATE public.document_versions dv
SET
file_type = COALESCE(NULLIF(btrim(dv.file_type), ''), d.file_type),
size_bytes = COALESCE(dv.size_bytes, d.size_bytes),
page_count = COALESCE(dv.page_count, d.page_count)
FROM public.documents d
WHERE dv.document_id = d.id
AND (
dv.file_type IS NULL
OR btrim(dv.file_type) = ''
OR dv.size_bytes IS NULL
OR dv.page_count IS NULL
);
END IF;
IF documents_file_metadata_count > 0 THEN
ALTER TABLE public.documents
DROP COLUMN IF EXISTS file_type,
DROP COLUMN IF EXISTS size_bytes,
DROP COLUMN IF EXISTS page_count;
END IF;
END $$;

View file

@ -0,0 +1,27 @@
-- Migration date: 2026-06-02
-- Migration: remove legacy document-level filename.
--
-- Before dropping the old column, copy any remaining legacy names onto
-- version rows that do not yet have their own filename/display value.
-- A later migration renames document_versions.display_name to filename.
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'documents'
AND column_name = 'filename'
) THEN
UPDATE public.document_versions dv
SET display_name = d.filename
FROM public.documents d
WHERE dv.document_id = d.id
AND (dv.display_name IS NULL OR btrim(dv.display_name) = '');
ALTER TABLE public.documents
DROP COLUMN filename;
END IF;
END $$;

View file

@ -0,0 +1,12 @@
-- Migration date: 2026-06-03
-- Remove unused document structure trees.
--
-- Safe to run before or after the document metadata migration because both
-- columns are optional and dropped conditionally.
ALTER TABLE public.document_versions
DROP COLUMN IF EXISTS structure_tree;
ALTER TABLE public.documents
DROP COLUMN IF EXISTS structure_tree;

View file

@ -0,0 +1,192 @@
-- Migration date: 2026-06-06
-- OSS migration for the current backend/schema.sql diff.
--
-- This brings existing OSS Supabase databases in line with the updated fresh
-- schema: model preference columns, BYO provider expansion, per-version
-- document metadata, and CourtListener bulk lookup tables.
-- ---------------------------------------------------------------------------
-- User profiles
-- ---------------------------------------------------------------------------
alter table public.user_profiles
add column if not exists title_model text,
add column if not exists mfa_on_login boolean not null default false,
add column if not exists quote_model text;
-- ---------------------------------------------------------------------------
-- User API keys
-- ---------------------------------------------------------------------------
alter table public.user_api_keys
drop constraint if exists user_api_keys_provider_check;
alter table public.user_api_keys
add constraint user_api_keys_provider_check
check (provider in ('claude', 'gemini', 'openai', 'openrouter', 'courtlistener'));
alter table public.user_api_keys enable row level security;
drop policy if exists user_api_keys_own on public.user_api_keys;
-- ---------------------------------------------------------------------------
-- Document metadata now lives on document_versions
-- ---------------------------------------------------------------------------
alter table public.document_versions
add column if not exists filename text,
add column if not exists file_type text,
add column if not exists size_bytes integer,
add column if not exists page_count integer;
do $$
begin
if exists (
select 1
from information_schema.columns
where table_schema = 'public'
and table_name = 'document_versions'
and column_name = 'display_name'
) then
update public.document_versions dv
set filename = dv.display_name
where (dv.filename is null or btrim(dv.filename) = '')
and dv.display_name is not null
and btrim(dv.display_name) <> '';
end if;
if exists (
select 1
from information_schema.columns
where table_schema = 'public'
and table_name = 'documents'
and column_name = 'filename'
) then
update public.document_versions dv
set filename = d.filename
from public.documents d
where dv.document_id = d.id
and (dv.filename is null or btrim(dv.filename) = '')
and d.filename is not null
and btrim(d.filename) <> '';
end if;
end $$;
do $$
begin
if exists (
select 1
from information_schema.columns
where table_schema = 'public'
and table_name = 'documents'
and column_name = 'file_type'
) then
update public.document_versions dv
set file_type = coalesce(nullif(btrim(dv.file_type), ''), d.file_type)
from public.documents d
where dv.document_id = d.id
and (dv.file_type is null or btrim(dv.file_type) = '');
end if;
if exists (
select 1
from information_schema.columns
where table_schema = 'public'
and table_name = 'documents'
and column_name = 'size_bytes'
) then
update public.document_versions dv
set size_bytes = d.size_bytes
from public.documents d
where dv.document_id = d.id
and dv.size_bytes is null
and d.size_bytes is not null;
end if;
if exists (
select 1
from information_schema.columns
where table_schema = 'public'
and table_name = 'documents'
and column_name = 'page_count'
) then
update public.document_versions dv
set page_count = d.page_count
from public.documents d
where dv.document_id = d.id
and dv.page_count is null
and d.page_count is not null;
end if;
end $$;
alter table public.document_versions
drop column if exists display_name;
alter table public.documents
drop column if exists filename,
drop column if exists file_type,
drop column if exists size_bytes,
drop column if exists page_count,
drop column if exists structure_tree;
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;
$$;
-- ---------------------------------------------------------------------------
-- 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;
drop policy if exists cl_citation_read on public.courtlistener_citation_index;
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;
drop policy if exists cl_cluster_read on public.courtlistener_opinion_cluster_index;
revoke all on public.courtlistener_citation_index from anon, authenticated;
revoke all on public.courtlistener_opinion_cluster_index from anon, authenticated;

View file

@ -0,0 +1,16 @@
-- Migration date: 2026-06-10
-- Keep document version tombstones after deleting version file bytes.
-- Deleted versions remain visible in history but are ignored by active-file
-- lookups and cannot be opened/downloaded/replaced.
alter table public.document_versions
alter column storage_path drop not null;
alter table public.document_versions
add column if not exists deleted_at timestamptz,
add column if not exists deleted_by uuid;
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;

View file

@ -0,0 +1,4 @@
-- Migration date: 2026-06-10
ALTER TABLE public.user_profiles
ADD COLUMN IF NOT EXISTS mfa_on_login boolean NOT NULL DEFAULT false;

View file

@ -0,0 +1,14 @@
-- Migration date: 2026-06-11
-- Per-user toggle for US legal research (CourtListener) tools in chat.
--
-- When true (the default), the CourtListener case-law tools and their system
-- prompt are exposed to the chat assistant. When false, both the tools and the
-- prompt are excluded from the chat. Surfaced in account settings under
-- Features > Legal Research > Jurisdiction > US.
--
-- Safe to run before application code changes: this only adds a column with a
-- default that preserves the existing (enabled) behaviour for all rows.
ALTER TABLE public.user_profiles
ADD COLUMN IF NOT EXISTS legal_research_us boolean NOT NULL DEFAULT true;

View file

@ -0,0 +1,39 @@
-- Migration date: 2026-06-13
-- Global assistant chats overview read model.
-- Returns the user's own chats plus chats under projects they own.
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;
$$;

View file

@ -0,0 +1,81 @@
-- Migration date: 2026-06-13
-- Projects overview read model.
-- Returns the project list, owner display name, and per-project counts in one
-- database call for the /projects table.
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;
$$;

View file

@ -0,0 +1,96 @@
-- Migration date: 2026-06-13
-- Tabular reviews overview read model.
-- Returns visible reviews plus document_count in one database call.
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;
$$;

View file

@ -0,0 +1,92 @@
-- Server-side MCP client connector storage.
-- Auth material is encrypted by the backend before insert. RLS is enabled with
-- no browser policies so only the service-role backend can read connector
-- URLs, encrypted auth config, token material, tool cache, and audit logs.
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,
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,
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_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;
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_connector_tools FROM anon, authenticated;
REVOKE ALL ON public.user_mcp_tool_audit_logs FROM anon, authenticated;

View file

@ -0,0 +1,75 @@
-- Migration date: 2026-06-13
-- Workflows overview read model.
-- Returns owned and shared workflows in one database call.
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;
$$;

View file

@ -0,0 +1,42 @@
-- Migration date: 2026-06-15
-- Adds OAuth metadata/state needed for HTTP MCP connectors that authorize via
-- OAuth 2.x instead of pasted bearer tokens.
ALTER TABLE public.user_mcp_connectors
ADD COLUMN IF NOT EXISTS auth_type text NOT NULL DEFAULT 'none'
CHECK (auth_type IN ('none', 'bearer', 'oauth'));
UPDATE public.user_mcp_connectors
SET auth_type = CASE
WHEN encrypted_auth_config IS NOT NULL THEN 'bearer'
ELSE 'none'
END
WHERE auth_type IS NULL OR auth_type = 'none';
ALTER TABLE public.user_mcp_oauth_tokens
ADD COLUMN IF NOT EXISTS authorization_server text,
ADD COLUMN IF NOT EXISTS token_endpoint text,
ADD COLUMN IF NOT EXISTS client_id text,
ADD COLUMN IF NOT EXISTS encrypted_client_secret text,
ADD COLUMN IF NOT EXISTS client_secret_iv text,
ADD COLUMN IF NOT EXISTS client_secret_tag text,
ADD COLUMN IF NOT EXISTS resource text;
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;
REVOKE ALL ON public.user_mcp_oauth_states FROM anon, authenticated;