diff --git a/backend/migrations/20260424_01_docx_version_display_name.sql b/backend/migrations/20260424_01_docx_version_display_name.sql index 322075e463..82077f2f82 100644 --- a/backend/migrations/20260424_01_docx_version_display_name.sql +++ b/backend/migrations/20260424_01_docx_version_display_name.sql @@ -28,8 +28,21 @@ ALTER TABLE public.document_versions -- 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; +DO $$ +BEGIN + -- Later migrations remove documents.filename. Keep this historical + -- backfill re-runnable against both the old and current schemas. + 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.display_name IS NULL + AND d.id = dv.document_id; + END IF; +END $$; diff --git a/backend/migrations/20260427_01_move_storage_to_versions.sql b/backend/migrations/20260427_01_move_storage_to_versions.sql index 5b66c4100f..774ae81634 100644 --- a/backend/migrations/20260427_01_move_storage_to_versions.sql +++ b/backend/migrations/20260427_01_move_storage_to_versions.sql @@ -19,38 +19,66 @@ alter table public.document_versions -- 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; +do $$ +begin + -- The source columns are removed at the end of this migration. On a + -- re-run against the current schema, there is nothing left to copy. + if exists ( + select 1 from information_schema.columns + where table_schema = 'public' and table_name = 'documents' + and column_name = 'storage_path' + ) and exists ( + select 1 from information_schema.columns + where table_schema = 'public' and table_name = 'documents' + and column_name = 'pdf_storage_path' + ) and exists ( + select 1 from information_schema.columns + where table_schema = 'public' and table_name = 'documents' + and column_name = 'filename' + ) then + 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; + end if; +end $$; -- 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; +do $$ +begin + if exists ( + select 1 from information_schema.columns + where table_schema = 'public' and table_name = 'documents' + and column_name = 'pdf_storage_path' + ) then + 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; + end if; +end $$; -- 4. Backfill current_version_id for any document missing one — point it -- at the most recent version (assistant_edit / user_upload preferred, diff --git a/backend/migrations/20260602_01_add_document_version_file_metadata.sql b/backend/migrations/20260602_01_add_document_version_file_metadata.sql index 34b9d47f42..af10788c03 100644 --- a/backend/migrations/20260602_01_add_document_version_file_metadata.sql +++ b/backend/migrations/20260602_01_add_document_version_file_metadata.sql @@ -13,16 +13,35 @@ ALTER TABLE public.document_versions 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 - ); +DO $$ +BEGIN + -- These source columns are removed by a later migration. On a re-run + -- against the current schema, the per-version values are already canonical. + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'documents' + AND column_name = 'file_type' + ) AND EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'documents' + AND column_name = 'size_bytes' + ) AND 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 + 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; +END $$; diff --git a/backend/migrations/20260602_02_add_document_version_filename_temp.sql b/backend/migrations/20260602_02_add_document_version_filename_temp.sql index 5eb259aaec..3572ae305f 100644 --- a/backend/migrations/20260602_02_add_document_version_filename_temp.sql +++ b/backend/migrations/20260602_02_add_document_version_filename_temp.sql @@ -6,8 +6,20 @@ 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) <> ''; +DO $$ +BEGIN + -- display_name is renamed/dropped by later migrations. Only backfill while + -- the transitional source column is still present. + 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 + SET filename = display_name + WHERE (filename IS NULL OR btrim(filename) = '') + AND display_name IS NOT NULL + AND btrim(display_name) <> ''; + END IF; +END $$; diff --git a/backend/migrations/20260613_02_projects_overview_rpc.sql b/backend/migrations/20260613_02_projects_overview_rpc.sql index ec6332c81b..7ff5dd3369 100644 --- a/backend/migrations/20260613_02_projects_overview_rpc.sql +++ b/backend/migrations/20260613_02_projects_overview_rpc.sql @@ -4,6 +4,11 @@ -- Returns the project list, owner display name, and per-project counts in one -- database call for the /projects table. +-- Later migrations extend this function's output columns. PostgreSQL cannot +-- replace a RETURNS TABLE row type in place, so remove that later shape before +-- replaying this historical definition. +DROP FUNCTION IF EXISTS public.get_projects_overview(text, text); + create or replace function public.get_projects_overview( p_user_id text, p_user_email text default null diff --git a/backend/migrations/20260613_05_workflows_overview_rpc.sql b/backend/migrations/20260613_05_workflows_overview_rpc.sql index f7b7c77011..4a319ae41b 100644 --- a/backend/migrations/20260613_05_workflows_overview_rpc.sql +++ b/backend/migrations/20260613_05_workflows_overview_rpc.sql @@ -3,6 +3,10 @@ -- Workflows overview read model. -- Returns owned and shared workflows in one database call. +-- Later migrations add output columns. DROP first because CREATE OR REPLACE +-- cannot alter a function's RETURNS TABLE row type. +DROP FUNCTION IF EXISTS public.get_workflows_overview(text, text, text); + create or replace function public.get_workflows_overview( p_user_id text, p_user_email text default null, diff --git a/backend/migrations/20260710_01_library_documents.sql b/backend/migrations/20260710_01_library_documents.sql index 9a23f9df5d..c08e55ab8b 100644 --- a/backend/migrations/20260710_01_library_documents.sql +++ b/backend/migrations/20260710_01_library_documents.sql @@ -1,5 +1,4 @@ -- Migration date: 2026-07-10 - alter table public.documents add column if not exists library_kind text default 'file'; diff --git a/backend/migrations/20260728_audit_events.sql b/backend/migrations/20260728_audit_events.sql deleted file mode 100644 index 778c3f9496..0000000000 --- a/backend/migrations/20260728_audit_events.sql +++ /dev/null @@ -1,33 +0,0 @@ --- Audit history of user actions, queried via the service-role backend only. --- Like every other backend-owned table (see 20260508_01), the browser --- anon/authenticated roles are revoked and RLS is enabled with no policies as --- defense in depth. Without this, a hosted Supabase project's default ACLs --- leave the whole table readable and writable with the public anon key — any --- visitor could dump every user's email, chat titles and prompt excerpts, or --- forge/delete audit rows. -create table if not exists public.audit_events ( - id uuid primary key default gen_random_uuid(), - created_at timestamptz not null default now(), - user_id uuid not null, - user_email text, - action text not null, -- chat.message | document.uploaded | document.generated | document.edited | workflow.applied | tabular.created | tabular.generated | export.chats | export.account | export.tabular - status text not null default 'completed', -- completed | cancelled | failed - title text, - surface text, -- assistant | project | tabular | workflows | account - project_id uuid, - chat_id uuid, - document_id uuid, - review_id uuid, - model text, - detail jsonb -); -create index if not exists audit_events_user_created on public.audit_events (user_id, created_at desc); -create index if not exists audit_events_project_created on public.audit_events (project_id, created_at desc); - --- Backend-only access: revoke the browser roles, enable RLS (no policies), and --- grant the service_role the privileges the backend needs. The explicit grant --- keeps a fresh plain-Postgres apply working even where service_role has no --- default ACL for new tables. -revoke all on public.audit_events from anon, authenticated; -alter table public.audit_events enable row level security; -grant select, insert, update, delete on public.audit_events to service_role; diff --git a/backend/migrations/20260805_01_narrow_service_role_grants.sql b/backend/migrations/20260805_01_narrow_service_role_grants.sql deleted file mode 100644 index ead53dd081..0000000000 --- a/backend/migrations/20260805_01_narrow_service_role_grants.sql +++ /dev/null @@ -1,43 +0,0 @@ --- Narrow service_role's table/sequence privileges to the data operations the --- backend actually performs. --- --- Fresh installs already get this: commit b2dbb39 ("narrow service role --- schema grants", 2026-07-23) changed backend/schema.sql from --- `grant all privileges` to `grant select, insert, update, delete` on tables --- and `grant usage, select` on sequences. But that commit shipped no --- migration, so every deployment created before it — which follows the --- documented upgrade path of applying only dated migrations — still carries --- the old GRANT ALL: TRUNCATE, REFERENCES, and TRIGGER on every table, and --- UPDATE (setval) on every sequence. This migration brings those --- deployments to the same grants a fresh install gets. --- --- Why it matters: service_role bypasses RLS by design, so its privileges --- are the blast radius of a leaked backend credential or an application --- bug. The backend only ever reads and writes rows; it never truncates --- tables, creates triggers, or resets sequences. Least privilege says the --- role should not be able to either. --- --- Safe to re-run: REVOKE of an absent privilege and GRANT of a present one --- are both no-ops, and on a database already at the fresh-install grants --- the net effect is nothing. --- --- Wrapped in an explicit transaction: migrations are applied with plain --- `psql --set ON_ERROR_STOP=1` (no -1), so without begin/commit each --- statement autocommits and the moment between "revoke all" and the --- re-grant would be a real zero-privilege window on a live deployment, --- during which every backend query against these tables fails. Inside one --- transaction the revoke+grant become visible to other sessions atomically --- at commit, so running traffic never observes the intermediate state. -begin; - -revoke all privileges on all tables in schema public from service_role; -grant select, insert, update, delete - on all tables in schema public - to service_role; - -revoke all privileges on all sequences in schema public from service_role; -grant usage, select - on all sequences in schema public - to service_role; - -commit; diff --git a/backend/migrations/20260809_01_word_addin_chats.sql b/backend/migrations/20260809_01_word_addin_chats.sql deleted file mode 100644 index d0283814d7..0000000000 --- a/backend/migrations/20260809_01_word_addin_chats.sql +++ /dev/null @@ -1,60 +0,0 @@ --- Word add-in conversations are intentionally separate from the web --- assistant's chats/chat_messages tables. A client_document_id is generated by --- the add-in and embedded in the .docx through Office document settings. - -create table if not exists public.word_documents ( - id uuid primary key default gen_random_uuid(), - user_id text not null, - client_document_id uuid not null, - created_at timestamptz not null default now(), - updated_at timestamptz not null default now(), - unique (user_id, client_document_id) -); - -create index if not exists idx_word_documents_user_updated - on public.word_documents(user_id, updated_at desc); - -create table if not exists public.word_chats ( - id uuid primary key default gen_random_uuid(), - word_document_id uuid not null - references public.word_documents(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 idx_word_chats_document_updated - on public.word_chats(word_document_id, updated_at desc); - -create index if not exists idx_word_chats_user - on public.word_chats(user_id); - -create table if not exists public.word_chat_messages ( - id uuid primary key default gen_random_uuid(), - chat_id uuid not null references public.word_chats(id) on delete cascade, - role text not null check (role in ('user', 'assistant')), - content jsonb, - files jsonb, - workflow jsonb, - citations jsonb, - created_at timestamptz not null default now() -); - -create index if not exists idx_word_chat_messages_chat_created - on public.word_chat_messages(chat_id, created_at); - --- Application data is reachable only through the authenticated backend. RLS --- and revoked browser grants provide defense in depth if a client is ever --- accidentally pointed directly at Supabase. -alter table public.word_documents enable row level security; -alter table public.word_chats enable row level security; -alter table public.word_chat_messages enable row level security; - -revoke all on public.word_documents from anon, authenticated; -revoke all on public.word_chats from anon, authenticated; -revoke all on public.word_chat_messages from anon, authenticated; - -grant select, insert, update, delete - on public.word_documents, public.word_chats, public.word_chat_messages - to service_role; diff --git a/backend/migrations/20260811_01_workflow_restructure.sql b/backend/migrations/20260811_01_workflow_restructure.sql deleted file mode 100644 index 2e4ea39ed5..0000000000 --- a/backend/migrations/20260811_01_workflow_restructure.sql +++ /dev/null @@ -1,210 +0,0 @@ --- Workflow ownership, defaults, quick actions, add-on catalog, and references. - -create table if not exists public.default_workflow_installations ( - id uuid primary key default gen_random_uuid(), - user_id text not null, - default_key text not null, - workflow_id uuid references public.workflows(id) on delete set null, - installed_at timestamptz not null default now(), - constraint default_workflow_installations_user_key_unique - unique(user_id, default_key), - constraint default_workflow_installations_workflow_unique - unique(workflow_id) -); - -create table if not exists public.quick_actions ( - id uuid primary key default gen_random_uuid(), - user_id text not null, - workflow_id uuid not null references public.workflows(id) on delete cascade, - prompt text not null default '', - document_upload boolean not null default false, - enabled boolean not null default true, - sort_order integer not null default 0, - created_at timestamptz not null default now(), - updated_at timestamptz not null default now() -); - -create index if not exists quick_actions_user_order_idx - on public.quick_actions(user_id, sort_order, created_at); - -create index if not exists quick_actions_workflow_idx - on public.quick_actions(workflow_id); - -create table if not exists public.workflow_addons ( - id uuid primary key default gen_random_uuid(), - addon_key text not null unique, - version text, - title text not null, - description text, - type text not null, - prompt_md text, - columns_config jsonb, - contributors jsonb, - language text, - practice text, - jurisdictions text[], - content_hash text not null, - active boolean not null default true, - created_at timestamptz not null default now(), - updated_at timestamptz not null default now(), - constraint workflow_addons_type_check - check(type in ('assistant', 'tabular')) -); - -create index if not exists workflow_addons_active_type_idx - on public.workflow_addons(active, type, title); - -create table if not exists public.workflow_reference_documents ( - id uuid primary key default gen_random_uuid(), - workflow_id uuid not null references public.workflows(id) on delete cascade, - user_id text not null, - filename text not null, - file_type text not null, - storage_path text not null, - size_bytes integer, - content_hash text not null, - created_at timestamptz not null default now(), - updated_at timestamptz not null default now() -); - -create index if not exists workflow_reference_documents_workflow_idx - on public.workflow_reference_documents(workflow_id, created_at); - -create index if not exists workflow_reference_documents_user_idx - on public.workflow_reference_documents(user_id); - -create table if not exists public.workflow_addon_reference_files ( - id uuid primary key default gen_random_uuid(), - addon_id uuid not null references public.workflow_addons(id) on delete cascade, - filename text not null, - file_type text not null, - storage_path text not null, - size_bytes integer, - content_hash text not null, - created_at timestamptz not null default now(), - constraint workflow_addon_reference_files_name_unique - unique(addon_id, filename) -); - --- The backend passes the current default definitions from the generated --- workflow catalog. One RPC keeps workflow + installation + quick action --- creation atomic and serializes concurrent first-load requests per user. -create or replace function public.install_missing_default_workflows( - p_user_id text, - p_defaults jsonb -) -returns integer -language plpgsql -security definer -set search_path = public -as $$ -declare - item jsonb; - workflow_uuid uuid; - installed_count integer := 0; - jurisdiction_values text[]; -begin - perform pg_advisory_xact_lock(hashtextextended(p_user_id, 0)); - - for item in select value from jsonb_array_elements(coalesce(p_defaults, '[]'::jsonb)) - loop - if nullif(trim(item->>'default_key'), '') is null then - continue; - end if; - - if exists ( - select 1 - from public.default_workflow_installations dwi - where dwi.user_id = p_user_id - and dwi.default_key = item->>'default_key' - ) then - continue; - end if; - - select coalesce(array_agg(value), array['General']::text[]) - into jurisdiction_values - from jsonb_array_elements_text( - case - when jsonb_typeof(item->'jurisdictions') = 'array' - then item->'jurisdictions' - else '["General"]'::jsonb - end - ); - - insert into public.workflows ( - user_id, - title, - type, - prompt_md, - columns_config, - language, - practice, - jurisdictions - ) values ( - p_user_id, - item->>'title', - item->>'type', - nullif(item->>'prompt_md', ''), - case - when jsonb_typeof(item->'columns_config') = 'array' - then item->'columns_config' - else null - end, - coalesce(nullif(item->>'language', ''), 'English'), - coalesce(nullif(item->>'practice', ''), 'General Transactions'), - jurisdiction_values - ) - returning id into workflow_uuid; - - insert into public.default_workflow_installations ( - user_id, - default_key, - workflow_id - ) values ( - p_user_id, - item->>'default_key', - workflow_uuid - ); - - insert into public.quick_actions ( - user_id, - workflow_id, - prompt, - document_upload, - enabled, - sort_order - ) values ( - p_user_id, - workflow_uuid, - coalesce(item->>'quick_action_prompt', ''), - coalesce((item->>'document_upload')::boolean, false), - true, - coalesce((item->>'sort_order')::integer, installed_count) - ); - - installed_count := installed_count + 1; - end loop; - - return installed_count; -end; -$$; - -revoke all on public.default_workflow_installations from anon, authenticated; -revoke all on public.quick_actions from anon, authenticated; -revoke all on public.workflow_addons from anon, authenticated; -revoke all on public.workflow_reference_documents from anon, authenticated; -revoke all on public.workflow_addon_reference_files from anon, authenticated; -revoke all on function public.install_missing_default_workflows(text, jsonb) - from public, anon, authenticated; - -grant select, insert, update, delete - on public.default_workflow_installations, - public.quick_actions, - public.workflow_addons, - public.workflow_reference_documents, - public.workflow_addon_reference_files - to service_role; - -grant execute - on function public.install_missing_default_workflows(text, jsonb) - to service_role; diff --git a/backend/migrations/20260811_02_workflow_addon_pack_folders.sql b/backend/migrations/20260811_02_workflow_addon_pack_folders.sql deleted file mode 100644 index 402dd96b78..0000000000 --- a/backend/migrations/20260811_02_workflow_addon_pack_folders.sql +++ /dev/null @@ -1,10 +0,0 @@ --- Preserve repository workflow packs as folders in the add-on catalog. - -alter table public.workflow_addons - add column if not exists pack_key text, - add column if not exists pack_title text, - add column if not exists pack_description text, - add column if not exists pack_version text; - -create index if not exists workflow_addons_active_pack_idx - on public.workflow_addons(active, pack_key, title); diff --git a/backend/migrations/20260813_01_user_id_foreign_keys.sql b/backend/migrations/20260813_01_user_id_foreign_keys.sql deleted file mode 100644 index 5d60eee43f..0000000000 --- a/backend/migrations/20260813_01_user_id_foreign_keys.sql +++ /dev/null @@ -1,1087 +0,0 @@ --- Migration date: 2026-08-13 --- --- Enforce issue #104's missing auth.users referential integrity across every --- application-owned user identifier. RPC parameters and return columns remain --- text for API compatibility; their SQL bodies cast at the database boundary. - -begin; - --- Refuse to reinterpret malformed identifiers. Orphaned, well-formed UUIDs are --- rejected below when the foreign keys are validated. -do $$ -declare - target record; - invalid_count bigint; -begin - for target in - select * - from (values - ('projects', 'user_id'), - ('project_subfolders', 'user_id'), - ('library_folders', 'user_id'), - ('documents', 'user_id'), - ('workflows', 'user_id'), - ('hidden_workflows', 'user_id'), - ('workflow_shares', 'shared_by_user_id'), - ('default_workflow_installations', 'user_id'), - ('quick_actions', 'user_id'), - ('workflow_reference_documents', 'user_id'), - ('workflow_open_source_submissions', 'submitted_by_user_id'), - ('chats', 'user_id'), - ('word_documents', 'user_id'), - ('word_chats', 'user_id'), - ('tabular_reviews', 'user_id'), - ('tabular_review_chats', 'user_id') - ) as columns_to_convert(table_name, column_name) - loop - execute format( - 'select count(*) from public.%I where %I is not null and %I !~* %L', - target.table_name, - target.column_name, - target.column_name, - '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' - ) into invalid_count; - - if invalid_count > 0 then - raise exception - 'Cannot convert %.% to uuid: % malformed value(s)', - target.table_name, - target.column_name, - invalid_count; - end if; - end loop; -end -$$; - -alter table public.projects - alter column user_id type uuid using user_id::uuid; -alter table public.project_subfolders - alter column user_id type uuid using user_id::uuid; -alter table public.library_folders - alter column user_id type uuid using user_id::uuid; -alter table public.documents - alter column user_id type uuid using user_id::uuid; -alter table public.workflows - alter column user_id type uuid using user_id::uuid; -alter table public.hidden_workflows - alter column user_id type uuid using user_id::uuid; -alter table public.workflow_shares - alter column shared_by_user_id type uuid using shared_by_user_id::uuid; -alter table public.default_workflow_installations - alter column user_id type uuid using user_id::uuid; -alter table public.quick_actions - alter column user_id type uuid using user_id::uuid; -alter table public.workflow_reference_documents - alter column user_id type uuid using user_id::uuid; -alter table public.workflow_open_source_submissions - alter column submitted_by_user_id type uuid using submitted_by_user_id::uuid; -alter table public.chats - alter column user_id type uuid using user_id::uuid; -alter table public.word_documents - alter column user_id type uuid using user_id::uuid; -alter table public.word_chats - alter column user_id type uuid using user_id::uuid; -alter table public.tabular_reviews - alter column user_id type uuid using user_id::uuid; -alter table public.tabular_review_chats - alter column user_id type uuid using user_id::uuid; - --- NOT VALID separates installation from verification, so an orphan identifies --- the exact relationship that must be repaired without partially committing. -alter table public.projects - add constraint projects_user_id_fkey - foreign key (user_id) references auth.users(id) on delete cascade not valid; -alter table public.project_subfolders - add constraint project_subfolders_user_id_fkey - foreign key (user_id) references auth.users(id) on delete cascade not valid; -alter table public.library_folders - add constraint library_folders_user_id_fkey - foreign key (user_id) references auth.users(id) on delete cascade not valid; -alter table public.documents - add constraint documents_user_id_fkey - foreign key (user_id) references auth.users(id) on delete cascade not valid; -alter table public.document_versions - add constraint document_versions_deleted_by_fkey - foreign key (deleted_by) references auth.users(id) on delete set null not valid; -alter table public.workflows - add constraint workflows_user_id_fkey - foreign key (user_id) references auth.users(id) on delete cascade not valid; -alter table public.hidden_workflows - add constraint hidden_workflows_user_id_fkey - foreign key (user_id) references auth.users(id) on delete cascade not valid; -alter table public.workflow_shares - add constraint workflow_shares_shared_by_user_id_fkey - foreign key (shared_by_user_id) references auth.users(id) on delete cascade not valid; -alter table public.default_workflow_installations - add constraint default_workflow_installations_user_id_fkey - foreign key (user_id) references auth.users(id) on delete cascade not valid; -alter table public.quick_actions - add constraint quick_actions_user_id_fkey - foreign key (user_id) references auth.users(id) on delete cascade not valid; -alter table public.workflow_reference_documents - add constraint workflow_reference_documents_user_id_fkey - foreign key (user_id) references auth.users(id) on delete cascade not valid; -alter table public.workflow_open_source_submissions - add constraint workflow_open_source_submissions_submitted_by_user_id_fkey - foreign key (submitted_by_user_id) references auth.users(id) on delete cascade not valid; -alter table public.chats - add constraint chats_user_id_fkey - foreign key (user_id) references auth.users(id) on delete cascade not valid; -alter table public.word_documents - add constraint word_documents_user_id_fkey - foreign key (user_id) references auth.users(id) on delete cascade not valid; -alter table public.word_chats - add constraint word_chats_user_id_fkey - foreign key (user_id) references auth.users(id) on delete cascade not valid; -alter table public.tabular_reviews - add constraint tabular_reviews_user_id_fkey - foreign key (user_id) references auth.users(id) on delete cascade not valid; -alter table public.tabular_review_chats - add constraint tabular_review_chats_user_id_fkey - foreign key (user_id) references auth.users(id) on delete cascade not valid; -alter table public.audit_events - add constraint audit_events_user_id_fkey - foreign key (user_id) references auth.users(id) on delete cascade not valid; - -alter table public.projects validate constraint projects_user_id_fkey; -alter table public.project_subfolders validate constraint project_subfolders_user_id_fkey; -alter table public.library_folders validate constraint library_folders_user_id_fkey; -alter table public.documents validate constraint documents_user_id_fkey; -alter table public.document_versions validate constraint document_versions_deleted_by_fkey; -alter table public.workflows validate constraint workflows_user_id_fkey; -alter table public.hidden_workflows validate constraint hidden_workflows_user_id_fkey; -alter table public.workflow_shares validate constraint workflow_shares_shared_by_user_id_fkey; -alter table public.default_workflow_installations validate constraint default_workflow_installations_user_id_fkey; -alter table public.quick_actions validate constraint quick_actions_user_id_fkey; -alter table public.workflow_reference_documents validate constraint workflow_reference_documents_user_id_fkey; -alter table public.workflow_open_source_submissions validate constraint workflow_open_source_submissions_submitted_by_user_id_fkey; -alter table public.chats validate constraint chats_user_id_fkey; -alter table public.word_documents validate constraint word_documents_user_id_fkey; -alter table public.word_chats validate constraint word_chats_user_id_fkey; -alter table public.tabular_reviews validate constraint tabular_reviews_user_id_fkey; -alter table public.tabular_review_chats validate constraint tabular_review_chats_user_id_fkey; -alter table public.audit_events validate constraint audit_events_user_id_fkey; - --- Recompile RPCs that expose text user IDs while the storage columns are UUID. -create or replace function public.install_missing_default_workflows( - p_user_id text, - p_defaults jsonb -) -returns integer -language plpgsql -security definer -set search_path = public -as $$ -declare - item jsonb; - workflow_uuid uuid; - installed_count integer := 0; - jurisdiction_values text[]; -begin - perform pg_advisory_xact_lock(hashtextextended(p_user_id, 0)); - - for item in select value from jsonb_array_elements(coalesce(p_defaults, '[]'::jsonb)) - loop - if nullif(trim(item->>'default_key'), '') is null then - continue; - end if; - - if exists ( - select 1 - from public.default_workflow_installations dwi - where dwi.user_id::text = p_user_id - and dwi.default_key = item->>'default_key' - ) then - continue; - end if; - - select coalesce(array_agg(value), array['General']::text[]) - into jurisdiction_values - from jsonb_array_elements_text( - case - when jsonb_typeof(item->'jurisdictions') = 'array' - then item->'jurisdictions' - else '["General"]'::jsonb - end - ); - - insert into public.workflows ( - user_id, - title, - type, - prompt_md, - columns_config, - language, - practice, - jurisdictions - ) values ( - p_user_id::uuid, - item->>'title', - item->>'type', - nullif(item->>'prompt_md', ''), - case - when jsonb_typeof(item->'columns_config') = 'array' - then item->'columns_config' - else null - end, - coalesce(nullif(item->>'language', ''), 'English'), - coalesce(nullif(item->>'practice', ''), 'General Transactions'), - jurisdiction_values - ) - returning id into workflow_uuid; - - insert into public.default_workflow_installations ( - user_id, - default_key, - workflow_id - ) values ( - p_user_id::uuid, - item->>'default_key', - workflow_uuid - ); - - insert into public.quick_actions ( - user_id, - workflow_id, - prompt, - document_upload, - enabled, - sort_order - ) values ( - p_user_id::uuid, - workflow_uuid, - coalesce(item->>'quick_action_prompt', ''), - coalesce((item->>'document_upload')::boolean, false), - true, - coalesce((item->>'sort_order')::integer, installed_count) - ); - - installed_count := installed_count + 1; - end loop; - - return installed_count; -end; -$$; - -create or replace function public.get_chats_overview( - p_user_id text, - p_limit integer default null, - p_offset integer default 0 -) -returns table ( - id uuid, - project_id uuid, - user_id text, - title text, - created_at timestamptz, - project_name text -) -language sql -stable -as $$ - select - c.id, - c.project_id, - c.user_id::text as user_id, - c.title, - c.created_at, - p.name as project_name - from public.chats c - left join public.projects p on p.id = c.project_id - where c.user_id::text = p_user_id - or ( - p.id is not null - and p.user_id::text = p_user_id - ) - order by c.created_at desc, c.id asc - limit case - when p_limit is null then null - else greatest(1, least(p_limit, 100)) - end - offset greatest(coalesce(p_offset, 0), 0); -$$; - -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, - practice 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::text = p_user_id - or ( - coalesce(p_user_email, '') <> '' - and p.user_id::text <> 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::text as user_id, - vp.name, - vp.cm_number, - vp.practice, - vp.shared_with, - vp.created_at, - vp.updated_at, - vp.user_id::text = 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::text - 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 or replace function public.get_tabular_reviews_overview( - p_user_id text, - p_user_email text, - p_project_id text, - p_scope text, - p_limit integer, - p_offset integer, - p_search_term text, - p_sort_key text, - p_sort_direction text -) -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::text = p_user_id - or ( - coalesce(p_user_email, '') <> '' - and p.user_id::text <> 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 ( - coalesce(p_scope, 'all') = 'all' - or (p_scope = 'in-project' and tr.project_id is not null) - or (p_scope = 'standalone' and tr.project_id is null) - ) - and ( - p_search_term is null - or p_search_term = '' - or lower(tr.title) like - '%' || - replace( - replace( - replace(lower(p_search_term), '\', '\\'), - '%', - '\%' - ), - '_', - '\_' - ) || - '%' - escape '\' - ) - 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::text = p_user_id - or ( - tr.project_id in (select ap.id from accessible_projects ap) - and tr.user_id::text <> p_user_id - ) - or ( - p_project_id is null - and coalesce(p_user_email, '') <> '' - and tr.user_id::text <> 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 - where jsonb_typeof(vr.document_ids) is distinct from 'array' - ) - group by tc.review_id - ), - review_document_counts as ( - select - vr.id, - 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 - ) - select - vr.id, - vr.project_id, - vr.user_id::text as 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::text = p_user_id as is_owner, - rdc.document_count - from visible_reviews vr - join review_document_counts rdc - on rdc.id = vr.id - order by - case - when p_sort_key = 'name' and p_sort_direction = 'asc' then lower(coalesce(vr.title, '')) - else null - end asc, - case - when p_sort_key = 'name' and p_sort_direction = 'desc' then lower(coalesce(vr.title, '')) - else null - end desc, - case - when p_sort_key = 'columns' and p_sort_direction = 'asc' then jsonb_array_length(coalesce(vr.columns_config, '[]'::jsonb)) - else null - end asc, - case - when p_sort_key = 'columns' and p_sort_direction = 'desc' then jsonb_array_length(coalesce(vr.columns_config, '[]'::jsonb)) - else null - end desc, - case - when p_sort_key = 'documents' and p_sort_direction = 'asc' then rdc.document_count - else null - end asc, - case - when p_sort_key = 'documents' and p_sort_direction = 'desc' then rdc.document_count - else null - end desc, - case - when p_sort_key = 'created' and p_sort_direction = 'asc' then vr.created_at - else null - end asc, - case - when p_sort_key = 'created' and p_sort_direction = 'desc' then vr.created_at - else null - end desc, - vr.created_at desc, - vr.id asc - limit greatest(coalesce(p_limit, 20), 1) - offset greatest(coalesce(p_offset, 0), 0); -$$; - -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 $$ - select * - from public.get_tabular_reviews_overview( - p_user_id, - p_user_email, - p_project_id, - 'all', - 2147483647, - 0, - null, - 'created', - 'desc' - ); -$$; - -create or replace function public.get_tabular_review_ids_overview( - p_user_id text, - p_user_email text, - p_project_id text, - p_scope text, - p_search_term text, - p_limit integer, - p_offset integer -) -returns table ( - id uuid, - user_id text -) -language sql -stable -as $$ - with accessible_projects as ( - select p.id - from public.projects p - where p.user_id::text = p_user_id - or ( - coalesce(p_user_email, '') <> '' - and p.user_id::text <> p_user_id - and p.shared_with @> jsonb_build_array(p_user_email) - ) - ) - select tr.id, tr.user_id::text as user_id - from public.tabular_reviews tr - where (p_project_id is null or tr.project_id::text = p_project_id) - and ( - coalesce(p_scope, 'all') = 'all' - or (p_scope = 'in-project' and tr.project_id is not null) - or (p_scope = 'standalone' and tr.project_id is null) - ) - and ( - p_search_term is null - or p_search_term = '' - or lower(tr.title) like - '%' || - replace( - replace( - replace(lower(p_search_term), '\', '\\'), - '%', - '\%' - ), - '_', - '\_' - ) || - '%' - escape '\' - ) - 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::text = p_user_id - or ( - tr.project_id in (select ap.id from accessible_projects ap) - and tr.user_id::text <> p_user_id - ) - or ( - p_project_id is null - and coalesce(p_user_email, '') <> '' - and tr.user_id::text <> p_user_id - and tr.shared_with @> jsonb_build_array(p_user_email) - ) - ) - order by tr.created_at desc, tr.id asc - limit greatest(coalesce(p_limit, 1000), 1) - offset greatest(coalesce(p_offset, 0), 0); -$$; - -create or replace function public.search_library_documents( - p_user_id text, - p_library_kind text, - p_limit integer, - p_offset integer, - p_search_term text default null, - p_file_type text default null, - p_sort_key text default 'updated', - p_sort_direction text default 'desc' -) -returns table ( - id uuid, - project_id uuid, - user_id text, - status text, - folder_id uuid, - library_kind text, - library_folder_id uuid, - current_version_id uuid, - created_at timestamptz, - updated_at timestamptz, - filename text, - file_type text, - storage_path text, - pdf_storage_path text, - size_bytes integer, - page_count integer, - active_version_number integer -) -language sql -stable -as $$ - select - d.id, - d.project_id, - d.user_id::text as user_id, - d.status, - d.folder_id, - d.library_kind, - d.library_folder_id, - d.current_version_id, - d.created_at, - d.updated_at, - coalesce(nullif(trim(v.filename), ''), 'Untitled document') as filename, - v.file_type, - v.storage_path, - v.pdf_storage_path, - v.size_bytes, - v.page_count, - v.version_number as active_version_number - from public.documents d - left join public.document_versions v - on v.id = d.current_version_id - and v.deleted_at is null - where d.user_id::text = p_user_id - and d.project_id is null - and ( - (p_library_kind = 'file' and coalesce(d.library_kind, 'file') = 'file') - or d.library_kind = p_library_kind - ) - and ( - p_search_term is null - or p_search_term = '' - or lower(coalesce(v.filename, '')) like - '%' || replace(replace(replace(lower(p_search_term), '\', '\\'), '%', '\%'), '_', '\_') || '%' - escape '\' - ) - and ( - p_file_type is null - or lower(coalesce(v.file_type, '')) = lower(p_file_type) - ) - order by - case when p_sort_key = 'name' and p_sort_direction = 'asc' then lower(coalesce(v.filename, '')) else null end asc, - case when p_sort_key = 'name' and p_sort_direction = 'desc' then lower(coalesce(v.filename, '')) else null end desc, - case when p_sort_key = 'type' and p_sort_direction = 'asc' then lower(coalesce(v.file_type, '')) else null end asc, - case when p_sort_key = 'type' and p_sort_direction = 'desc' then lower(coalesce(v.file_type, '')) else null end desc, - case when p_sort_key = 'size' and p_sort_direction = 'asc' then coalesce(v.size_bytes, 0) else null end asc, - case when p_sort_key = 'size' and p_sort_direction = 'desc' then coalesce(v.size_bytes, 0) else null end desc, - case when p_sort_key = 'version' and p_sort_direction = 'asc' then coalesce(v.version_number, 0) else null end asc, - case when p_sort_key = 'version' and p_sort_direction = 'desc' then coalesce(v.version_number, 0) else null end desc, - case when p_sort_key = 'created' and p_sort_direction = 'asc' then d.created_at else null end asc, - case when p_sort_key = 'created' and p_sort_direction = 'desc' then d.created_at else null end desc, - case when p_sort_key = 'updated' and p_sort_direction = 'asc' then d.updated_at else null end asc, - case when p_sort_key = 'updated' and p_sort_direction = 'desc' then d.updated_at else null end desc, - d.updated_at desc, - d.id asc - limit greatest(coalesce(p_limit, 50), 1) - offset greatest(coalesce(p_offset, 0), 0); -$$; - -create or replace function public.get_library_filter_options( - p_user_id text, - p_library_kind text -) -returns table (file_types text[]) -language sql -stable -as $$ - select coalesce( - array_agg(distinct lower(v.file_type) order by lower(v.file_type)) - filter (where nullif(trim(v.file_type), '') is not null), - array[]::text[] - ) as file_types - from public.documents d - left join public.document_versions v - on v.id = d.current_version_id - and v.deleted_at is null - where d.user_id::text = p_user_id - and d.project_id is null - and ( - (p_library_kind = 'file' and coalesce(d.library_kind, 'file') = 'file') - or d.library_kind = p_library_kind - ); -$$; - -create or replace function public.get_project_filter_options( - p_user_id text, - p_user_email text default null -) -returns table (practices text[], owners jsonb) -language sql -stable -as $$ - with visible_projects as ( - select p.user_id, nullif(trim(p.practice), '') as practice - from public.projects p - where p.user_id::text = p_user_id - or ( - coalesce(p_user_email, '') <> '' - and p.user_id::text <> p_user_id - and p.shared_with @> jsonb_build_array(p_user_email) - ) - ), - distinct_owners as ( - select distinct vp.user_id - from visible_projects vp - ), - owner_options as ( - select - o.user_id, - case - when o.user_id::text = p_user_id then 'Me' - else coalesce( - nullif(trim(up.display_name), ''), - nullif(trim(up.email), ''), - 'Shared' - ) - end as label - from distinct_owners o - left join public.user_profiles up - on up.user_id::text = o.user_id::text - ) - select - coalesce( - (select array_agg(distinct practice order by practice) - from visible_projects - where practice is not null), - array[]::text[] - ) as practices, - coalesce( - (select jsonb_agg( - jsonb_build_object('value', user_id, 'label', label) - order by label, user_id - ) from owner_options), - '[]'::jsonb - ) as owners; -$$; - -create or replace function public.get_projects_overview( - p_user_id text, - p_user_email text, - p_scope text, - p_limit integer, - p_offset integer, - p_search_term text, - p_sort_key text, - p_sort_direction text, - p_practice text, - p_owner_user_id text -) -returns table ( - id uuid, - user_id text, - name text, - cm_number text, - practice 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::text = p_user_id - or ( - coalesce(p_user_email, '') <> '' - and p.user_id::text <> p_user_id - and p.shared_with @> jsonb_build_array(p_user_email) - ) - ) - and ( - coalesce(p_scope, 'all') = 'all' - or (p_scope = 'mine' and p.user_id::text = p_user_id) - or (p_scope = 'shared' and p.user_id::text <> p_user_id) - ) - and ( - p_search_term is null - or p_search_term = '' - or lower(coalesce(p.name, '')) like - '%' || replace(replace(replace(lower(p_search_term), '\', '\\'), '%', '\%'), '_', '\_') || '%' - escape '\' - or lower(coalesce(p.cm_number, '')) like - '%' || replace(replace(replace(lower(p_search_term), '\', '\\'), '%', '\%'), '_', '\_') || '%' - escape '\' - or lower(coalesce(p.practice, '')) like - '%' || replace(replace(replace(lower(p_search_term), '\', '\\'), '%', '\%'), '_', '\_') || '%' - escape '\' - ) - and (p_practice is null or p.practice = p_practice) - and (p_owner_user_id is null or p.user_id::text = p_owner_user_id) - ), - 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::text as user_id, - vp.name, - vp.cm_number, - vp.practice, - vp.shared_with, - vp.created_at, - vp.updated_at, - vp.user_id::text = 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::text - 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 - case when p_sort_key = 'name' and p_sort_direction = 'asc' then lower(coalesce(vp.name, '')) else null end asc, - case when p_sort_key = 'name' and p_sort_direction = 'desc' then lower(coalesce(vp.name, '')) else null end desc, - case when p_sort_key = 'cm' and p_sort_direction = 'asc' then lower(coalesce(vp.cm_number, '')) else null end asc, - case when p_sort_key = 'cm' and p_sort_direction = 'desc' then lower(coalesce(vp.cm_number, '')) else null end desc, - case when p_sort_key = 'files' and p_sort_direction = 'asc' then coalesce(dc.document_count, 0) else null end asc, - case when p_sort_key = 'files' and p_sort_direction = 'desc' then coalesce(dc.document_count, 0) else null end desc, - case when p_sort_key = 'chats' and p_sort_direction = 'asc' then coalesce(cc.chat_count, 0) else null end asc, - case when p_sort_key = 'chats' and p_sort_direction = 'desc' then coalesce(cc.chat_count, 0) else null end desc, - case when p_sort_key = 'reviews' and p_sort_direction = 'asc' then coalesce(rc.review_count, 0) else null end asc, - case when p_sort_key = 'reviews' and p_sort_direction = 'desc' then coalesce(rc.review_count, 0) else null end desc, - case when p_sort_key = 'created' and p_sort_direction = 'asc' then vp.created_at else null end asc, - case when p_sort_key = 'created' and p_sort_direction = 'desc' then vp.created_at else null end desc, - case when p_sort_key = 'updated' and p_sort_direction = 'asc' then vp.updated_at else null end asc, - case when p_sort_key = 'updated' and p_sort_direction = 'desc' then vp.updated_at else null end desc, - vp.created_at desc, - vp.id asc - limit greatest(coalesce(p_limit, 20), 1) - offset greatest(coalesce(p_offset, 0), 0); -$$; - -create or replace function public.get_project_ids_overview( - p_user_id text, - p_user_email text, - p_scope text, - p_search_term text, - p_practice text, - p_owner_user_id text, - p_limit integer, - p_offset integer -) -returns table ( - id uuid, - user_id text -) -language sql -stable -as $$ - select p.id, p.user_id::text as user_id - from public.projects p - where ( - p.user_id::text = p_user_id - or ( - coalesce(p_user_email, '') <> '' - and p.user_id::text <> p_user_id - and p.shared_with @> jsonb_build_array(p_user_email) - ) - ) - and ( - coalesce(p_scope, 'all') = 'all' - or (p_scope = 'mine' and p.user_id::text = p_user_id) - or (p_scope = 'shared' and p.user_id::text <> p_user_id) - ) - and ( - p_search_term is null - or p_search_term = '' - or lower(coalesce(p.name, '')) like - '%' || replace(replace(replace(lower(p_search_term), '\', '\\'), '%', '\%'), '_', '\_') || '%' - escape '\' - or lower(coalesce(p.cm_number, '')) like - '%' || replace(replace(replace(lower(p_search_term), '\', '\\'), '%', '\%'), '_', '\_') || '%' - escape '\' - or lower(coalesce(p.practice, '')) like - '%' || replace(replace(replace(lower(p_search_term), '\', '\\'), '%', '\%'), '_', '\_') || '%' - escape '\' - ) - and (p_practice is null or p.practice = p_practice) - and (p_owner_user_id is null or p.user_id::text = p_owner_user_id) - order by p.created_at desc, p.id asc - limit greatest(coalesce(p_limit, 1000), 1) - offset greatest(coalesce(p_offset, 0), 0); -$$; - -create or replace function public.get_project_summaries( - p_user_id text, - p_user_email text, - p_limit integer, - p_offset integer -) -returns table ( - id uuid, - user_id text, - name text, - created_at timestamptz, - updated_at timestamptz, - is_owner boolean -) -language sql -stable -as $$ - select - p.id, - p.user_id::text as user_id, - p.name, - p.created_at, - p.updated_at, - p.user_id::text = p_user_id as is_owner - from public.projects p - where p.user_id::text = p_user_id - or ( - coalesce(p_user_email, '') <> '' - and p.user_id::text <> p_user_id - and p.shared_with @> jsonb_build_array(p_user_email) - ) - order by p.updated_at desc, p.created_at desc, p.id asc - limit greatest(coalesce(p_limit, 11), 1) - offset greatest(coalesce(p_offset, 0), 0); -$$; - -create or replace function public.get_library_document_ids( - p_user_id text, - p_library_kind text, - p_search_term text, - p_file_type text, - p_limit integer, - p_offset integer -) -returns table ( - id uuid, - user_id text -) -language sql -stable -as $$ - select d.id, d.user_id::text as user_id - from public.documents d - left join public.document_versions v - on v.id = d.current_version_id - and v.deleted_at is null - where d.user_id::text = p_user_id - and d.project_id is null - and ( - (p_library_kind = 'file' and coalesce(d.library_kind, 'file') = 'file') - or d.library_kind = p_library_kind - ) - and ( - p_search_term is null - or p_search_term = '' - or lower(coalesce(v.filename, '')) like - '%' || replace(replace(replace(lower(p_search_term), '\', '\\'), '%', '\%'), '_', '\_') || '%' - escape '\' - ) - and ( - p_file_type is null - or lower(coalesce(v.file_type, '')) = lower(p_file_type) - ) - order by d.updated_at desc, d.id asc - limit greatest(coalesce(p_limit, 1000), 1) - offset greatest(coalesce(p_offset, 0), 0); -$$; - -commit; diff --git a/backend/migrations/20260814_01_normalize_project_sharing_emails.sql b/backend/migrations/20260814_01_normalize_project_sharing_emails.sql deleted file mode 100644 index da122e6921..0000000000 --- a/backend/migrations/20260814_01_normalize_project_sharing_emails.sql +++ /dev/null @@ -1,35 +0,0 @@ --- Migration date: 2026-08-14 --- --- Normalize legacy project-sharing emails so indexed JSONB containment remains --- reliable regardless of how an invitation was capitalized when it was stored. --- Current POST/PATCH routes already enforce this representation for new writes. - -with normalized_projects as ( - select - p.id, - coalesce( - jsonb_agg(entries.email order by entries.first_position) - filter (where entries.email is not null), - '[]'::jsonb - ) as shared_with - from public.projects p - left join lateral ( - select - lower(trim(raw.value)) as email, - min(raw.position) as first_position - from jsonb_array_elements_text( - case - when jsonb_typeof(p.shared_with) = 'array' then p.shared_with - else '[]'::jsonb - end - ) with ordinality as raw(value, position) - where trim(raw.value) <> '' - group by lower(trim(raw.value)) - ) entries on true - group by p.id -) -update public.projects p -set shared_with = normalized_projects.shared_with -from normalized_projects -where p.id = normalized_projects.id - and p.shared_with is distinct from normalized_projects.shared_with;