From f31e2267dcf2e0302ea4b48ab8ee3b42e19e50eb Mon Sep 17 00:00:00 2001 From: Anthony May Date: Fri, 7 Aug 2026 19:29:14 +1000 Subject: [PATCH 1/6] Feat: database pagination for projects view --- ...260807_01_projects_overview_pagination.sql | 207 ++++++++++++ .../integration/projects.routes.test.ts | 94 ++++++ .../projectsPagination.supabase.test.ts | 284 ++++++++++++++++ .../lib/__tests__/projectsOverview.test.ts | 110 ++++++ backend/src/lib/__tests__/sort.test.ts | 24 +- backend/src/lib/projectsOverview.ts | 80 +++++ backend/src/lib/sort.ts | 24 ++ backend/src/routes/projects.ts | 100 +++++- .../src/app/(pages)/tabular-reviews/page.tsx | 27 +- .../projects/ProjectReviewsTable.tsx | 27 +- .../components/projects/ProjectsOverview.tsx | 249 ++++++-------- .../components/shared/TableLoadMoreRow.tsx | 42 +++ .../src/app/hooks/usePaginatedProjects.ts | 312 ++++++++++++++++++ frontend/src/app/lib/mikeApi.test.ts | 106 ++++++ frontend/src/app/lib/mikeApi.ts | 54 +++ package.json | 5 +- 16 files changed, 1555 insertions(+), 190 deletions(-) create mode 100644 backend/migrations/20260807_01_projects_overview_pagination.sql create mode 100644 backend/src/__tests__/integration/projectsPagination.supabase.test.ts create mode 100644 backend/src/lib/__tests__/projectsOverview.test.ts create mode 100644 backend/src/lib/projectsOverview.ts create mode 100644 frontend/src/app/components/shared/TableLoadMoreRow.tsx create mode 100644 frontend/src/app/hooks/usePaginatedProjects.ts diff --git a/backend/migrations/20260807_01_projects_overview_pagination.sql b/backend/migrations/20260807_01_projects_overview_pagination.sql new file mode 100644 index 000000000..69f929678 --- /dev/null +++ b/backend/migrations/20260807_01_projects_overview_pagination.sql @@ -0,0 +1,207 @@ +-- Migration date: 2026-08-07 + +-- Server-side pagination for the Projects overview page (/projects), mirroring +-- the pattern already built for Tabular Reviews in +-- 20260726_01_tabular_reviews_pagination.sql / +-- 20260727_01_tabular_review_ids_overview.sql: +-- * a trigram index so leading-wildcard search can use an index scan +-- * a new, higher-arity overload of get_projects_overview that adds +-- scope/search/practice/owner filters, server-side sort, and limit/offset +-- * the existing 2-arg get_projects_overview (from 20260703_02_project_practice.sql) +-- is left completely untouched as the back-compat path for every caller +-- that doesn't ask for pagination (sidebar nav, document-picker directory +-- view, tabular-review project pickers) — see backend/src/routes/projects.ts +-- for the routing logic that decides which overload to call. +-- * a lightweight get_project_ids_overview companion for "select all +-- matching" bulk actions. + +create extension if not exists pg_trgm; + +create index if not exists projects_name_trgm_idx + on public.projects using gin (lower(name) gin_trgm_ops); + +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 = 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) + ) + ) + and ( + coalesce(p_scope, 'all') = 'all' + or (p_scope = 'mine' and p.user_id = p_user_id) + or (p_scope = 'shared' and p.user_id <> 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 = 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, + vp.name, + vp.cm_number, + vp.practice, + 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 + 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, + vp.created_at desc, + vp.id asc + limit greatest(coalesce(p_limit, 20), 1) + offset greatest(coalesce(p_offset, 0), 0); +$$; + +-- Lightweight companion for bulk "select all matching" actions — id + owning +-- user only, no count joins. Duplicates visible_projects' predicate rather +-- than delegating to get_projects_overview (same rationale as +-- get_tabular_review_ids_overview: the count CTEs there would be pure waste +-- for a caller that only wants ids). Keep this predicate in sync by hand if +-- visible_projects above ever changes. +-- +-- Paginated (not "return everything") because PostgREST enforces its own +-- row cap on every RPC response and truncates silently rather than erroring; +-- backend/src/routes/projects.ts pages through this on the caller's behalf. +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 + 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) + ) + ) + and ( + coalesce(p_scope, 'all') = 'all' + or (p_scope = 'mine' and p.user_id = p_user_id) + or (p_scope = 'shared' and p.user_id <> 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 = 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); +$$; diff --git a/backend/src/__tests__/integration/projects.routes.test.ts b/backend/src/__tests__/integration/projects.routes.test.ts index 9955c98a3..166a19b60 100644 --- a/backend/src/__tests__/integration/projects.routes.test.ts +++ b/backend/src/__tests__/integration/projects.routes.test.ts @@ -114,11 +114,29 @@ vi.mock("../../lib/documentVersions", () => ({ import { app } from "../../app"; import crypto from "crypto"; import { manifestPublicKey } from "../../lib/manifestSigning"; +import { createServerSupabase } from "../../lib/supabase"; const SIGNING_KEY = "3b".repeat(32); const AUTH = ["Authorization", "Bearer test"] as const; +// Wraps mockSupabase()'s rpc so the next request's exact RPC call args can be +// asserted on — the shared mock otherwise only lets tests control the +// *response*, not inspect what was sent. +function captureRpcArgs(): { args: unknown } { + const captured: { args: unknown } = { args: undefined }; + vi.mocked(createServerSupabase).mockImplementationOnce(() => { + const db = mockSupabase(); + const originalRpc = db.rpc; + db.rpc = vi.fn((name: string, args: unknown) => { + captured.args = args; + return originalRpc(name, args as never); + }); + return db as unknown as ReturnType; + }); + return captured; +} + describe("projects.routes", () => { beforeEach(() => { vi.clearAllMocks(); @@ -193,6 +211,82 @@ describe("projects.routes", () => { expect(res.status).toBe(500); expect(res.body.detail).toBe("boom"); }); + + // Regression guard: the sidebar nav, the document-picker directory + // view, and the tabular-review project pickers all call GET /projects + // with no query params and need the full, unpaginated list back. If + // this ever silently switched to the paginated RPC shape by default, + // those callers would start seeing a truncated list with no error. + it("calls the legacy 2-arg RPC shape when no pagination params are present", async () => { + const captured = captureRpcArgs(); + supabaseState.rpc = { data: [], error: null }; + + await request(app).get("/projects").set(...AUTH); + + expect(captured.args).toEqual({ + p_user_id: "u1", + p_user_email: "u1@test.local", + }); + }); + + it("calls the paginated RPC shape with every filter parsed once any pagination param is present", async () => { + const captured = captureRpcArgs(); + supabaseState.rpc = { data: [], error: null }; + + await request(app) + .get( + "/projects?limit=10&scope=mine&sort_key=name&sort_direction=asc" + + "&search=acme&practice=Litigation&owner_user_id=u2", + ) + .set(...AUTH); + + expect(captured.args).toEqual({ + p_user_id: "u1", + p_user_email: "u1@test.local", + p_scope: "mine", + p_limit: 10, + p_offset: 0, + p_search_term: "acme", + p_sort_key: "name", + p_sort_direction: "asc", + p_practice: "Litigation", + p_owner_user_id: "u2", + }); + }); + }); + + // ── GET /projects/ids (select-all-matching support) ────────────────── + describe("GET /projects/ids", () => { + it("pages through the RPC until an empty page is returned", async () => { + const rpcMock = vi + .fn() + .mockResolvedValueOnce({ + data: [{ id: "p1", user_id: "u1" }], + error: null, + }) + .mockResolvedValueOnce({ data: [], error: null }); + vi.mocked(createServerSupabase).mockImplementationOnce(() => { + const db = mockSupabase(); + db.rpc = rpcMock; + return db as unknown as ReturnType; + }); + + const res = await request(app).get("/projects/ids").set(...AUTH); + + expect(res.status).toBe(200); + expect(res.body).toEqual([{ id: "p1", user_id: "u1" }]); + expect(rpcMock).toHaveBeenCalledTimes(2); + expect(rpcMock.mock.calls[0][0]).toBe("get_project_ids_overview"); + }); + + it("returns 500 with detail when the RPC errors", async () => { + supabaseState.rpc = { data: null, error: { message: "boom" } }; + + const res = await request(app).get("/projects/ids").set(...AUTH); + + expect(res.status).toBe(500); + expect(res.body.detail).toBe("boom"); + }); }); // ── POST /projects (create) ─────────────────────────────────────────── diff --git a/backend/src/__tests__/integration/projectsPagination.supabase.test.ts b/backend/src/__tests__/integration/projectsPagination.supabase.test.ts new file mode 100644 index 000000000..4918c8552 --- /dev/null +++ b/backend/src/__tests__/integration/projectsPagination.supabase.test.ts @@ -0,0 +1,284 @@ +import { createClient, type SupabaseClient } from "@supabase/supabase-js"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +const url = process.env.SUPABASE_TEST_URL; +const serviceKey = process.env.SUPABASE_TEST_SERVICE_ROLE_KEY; +const maybeDescribe = url && serviceKey ? describe : describe.skip; + +maybeDescribe("Supabase projects-overview pagination", () => { + const ownerId = crypto.randomUUID(); + const ownerEmail = `pagination-${ownerId}@test.local`; + const otherUserId = crypto.randomUUID(); + const myProjectIds = Array.from({ length: 25 }, () => crypto.randomUUID()); + const sharedProjectIds = Array.from({ length: 5 }, () => crypto.randomUUID()); + const tiedCreatedAt = "2026-07-27T00:00:00.000Z"; + let admin: SupabaseClient; + + beforeAll(async () => { + admin = createClient(url!, serviceKey!, { + auth: { persistSession: false, autoRefreshToken: false }, + }); + + const myProjects = await admin.from("projects").insert( + myProjectIds.map((id) => ({ + id, + user_id: ownerId, + name: "Needle Project", + practice: "Litigation", + created_at: tiedCreatedAt, + updated_at: tiedCreatedAt, + })), + ); + if (myProjects.error) throw myProjects.error; + + const sharedProjects = await admin.from("projects").insert( + sharedProjectIds.map((id) => ({ + id, + user_id: otherUserId, + name: "Shared Needle", + practice: "Corporate", + shared_with: [ownerEmail], + created_at: tiedCreatedAt, + updated_at: tiedCreatedAt, + })), + ); + if (sharedProjects.error) throw sharedProjects.error; + }); + + afterAll(async () => { + if (!admin) return; + await admin.from("projects").delete().in("id", myProjectIds); + await admin.from("projects").delete().in("id", sharedProjectIds); + }); + + it("paginates tied rows deterministically without duplicates", async () => { + const commonArgs = { + p_user_id: ownerId, + p_user_email: ownerEmail, + p_scope: "mine", + p_search_term: "needle", + p_sort_key: "name", + p_sort_direction: "asc", + p_practice: null, + p_owner_user_id: null, + }; + const firstPage = await admin.rpc("get_projects_overview", { + ...commonArgs, + p_limit: 20, + p_offset: 0, + }); + const secondPage = await admin.rpc("get_projects_overview", { + ...commonArgs, + p_limit: 20, + p_offset: 20, + }); + + expect(firstPage.error).toBeNull(); + expect(secondPage.error).toBeNull(); + expect(firstPage.data).toHaveLength(20); + expect(secondPage.data).toHaveLength(5); + + const firstIds = (firstPage.data ?? []).map((row) => row.id); + const secondIds = (secondPage.data ?? []).map((row) => row.id); + expect(new Set([...firstIds, ...secondIds]).size).toBe(25); + expect([...firstIds, ...secondIds]).toEqual([...myProjectIds].sort()); + }); + + it("filters by scope: mine vs shared", async () => { + const mine = await admin.rpc("get_projects_overview", { + p_user_id: ownerId, + p_user_email: ownerEmail, + p_scope: "mine", + p_limit: 100, + p_offset: 0, + p_search_term: "needle", + p_sort_key: "created", + p_sort_direction: "desc", + p_practice: null, + p_owner_user_id: null, + }); + const shared = await admin.rpc("get_projects_overview", { + p_user_id: ownerId, + p_user_email: ownerEmail, + p_scope: "shared", + p_limit: 100, + p_offset: 0, + p_search_term: "needle", + p_sort_key: "created", + p_sort_direction: "desc", + p_practice: null, + p_owner_user_id: null, + }); + + expect(mine.error).toBeNull(); + expect(shared.error).toBeNull(); + + const mineIds = new Set((mine.data ?? []).map((row) => row.id as string)); + const sharedIds = new Set( + (shared.data ?? []).map((row) => row.id as string), + ); + + for (const id of myProjectIds) expect(mineIds.has(id)).toBe(true); + for (const id of sharedProjectIds) expect(mineIds.has(id)).toBe(false); + + for (const id of sharedProjectIds) expect(sharedIds.has(id)).toBe(true); + for (const id of myProjectIds) expect(sharedIds.has(id)).toBe(false); + + expect((mine.data ?? []).every((row) => row.is_owner === true)).toBe( + true, + ); + expect( + (shared.data ?? []).every((row) => row.is_owner === false), + ).toBe(true); + }); + + it("filters by exact practice and owner match", async () => { + const byPractice = await admin.rpc("get_projects_overview", { + p_user_id: ownerId, + p_user_email: ownerEmail, + p_scope: "all", + p_limit: 100, + p_offset: 0, + p_search_term: "needle", + p_sort_key: "created", + p_sort_direction: "desc", + p_practice: "Corporate", + p_owner_user_id: null, + }); + const byOwner = await admin.rpc("get_projects_overview", { + p_user_id: ownerId, + p_user_email: ownerEmail, + p_scope: "all", + p_limit: 100, + p_offset: 0, + p_search_term: "needle", + p_sort_key: "created", + p_sort_direction: "desc", + p_practice: null, + p_owner_user_id: otherUserId, + }); + + expect(byPractice.error).toBeNull(); + expect(byOwner.error).toBeNull(); + expect( + new Set((byPractice.data ?? []).map((row) => row.id as string)), + ).toEqual(new Set(sharedProjectIds)); + expect( + new Set((byOwner.data ?? []).map((row) => row.id as string)), + ).toEqual(new Set(sharedProjectIds)); + }); + + it.each(["%", "_"])( + "treats %s as a literal search character", + async (searchTerm) => { + const projects = await admin.rpc("get_projects_overview", { + p_user_id: ownerId, + p_user_email: ownerEmail, + p_scope: "all", + p_limit: 100, + p_offset: 0, + p_search_term: searchTerm, + p_sort_key: "created", + p_sort_direction: "desc", + p_practice: null, + p_owner_user_id: null, + }); + const ids = await admin.rpc("get_project_ids_overview", { + p_user_id: ownerId, + p_user_email: ownerEmail, + p_scope: "all", + p_search_term: searchTerm, + p_practice: null, + p_owner_user_id: null, + p_limit: 100, + p_offset: 0, + }); + + expect(projects.error).toBeNull(); + expect(ids.error).toBeNull(); + expect(projects.data).toEqual([]); + expect(ids.data).toEqual([]); + }, + ); + + it("sorts the complete filtered set before pagination", async () => { + const result = await admin.rpc("get_projects_overview", { + p_user_id: ownerId, + p_user_email: ownerEmail, + p_scope: "mine", + p_limit: 25, + p_offset: 0, + p_search_term: null, + p_sort_key: "name", + p_sort_direction: "asc", + p_practice: null, + p_owner_user_id: null, + }); + + expect(result.error).toBeNull(); + const names = (result.data ?? []).map((row) => row.name as string); + expect(names).toEqual([...names].sort()); + }); + + it("returns ids + owner for every matching project within one page", async () => { + // Backs the "select all matching" bulk action: needs only id + + // user_id, not the full project payload, for the entire filtered set. + const result = await admin.rpc("get_project_ids_overview", { + p_user_id: ownerId, + p_user_email: ownerEmail, + p_scope: "mine", + p_search_term: "needle", + p_practice: null, + p_owner_user_id: null, + p_limit: 1000, + p_offset: 0, + }); + + expect(result.error).toBeNull(); + const rows = (result.data ?? []) as { id: string; user_id: string }[]; + expect(rows).toHaveLength(myProjectIds.length); + expect(new Set(rows.map((row) => row.id))).toEqual( + new Set(myProjectIds), + ); + expect(rows.every((row) => row.user_id === ownerId)).toBe(true); + }); + + it("paginates the ids RPC deterministically without duplicates or gaps", async () => { + // Proves the pagination contract the /projects/ids route relies on + // to page past PostgREST's own row cap: consecutive small pages must + // together cover the full filtered set with no overlap. + const pageSize = 10; + const collected: string[] = []; + for (let offset = 0; offset < myProjectIds.length; offset += pageSize) { + const page = await admin.rpc("get_project_ids_overview", { + p_user_id: ownerId, + p_user_email: ownerEmail, + p_scope: "mine", + p_search_term: "needle", + p_practice: null, + p_owner_user_id: null, + p_limit: pageSize, + p_offset: offset, + }); + expect(page.error).toBeNull(); + collected.push(...(page.data ?? []).map((row) => row.id as string)); + } + + expect(new Set(collected).size).toBe(myProjectIds.length); + expect([...collected].sort()).toEqual([...myProjectIds].sort()); + }); + + it("keeps the legacy two-argument RPC callable", async () => { + const result = await admin.rpc("get_projects_overview", { + p_user_id: ownerId, + p_user_email: ownerEmail, + }); + + expect(result.error).toBeNull(); + const returnedIds = new Set( + (result.data ?? []).map((row) => row.id as string), + ); + for (const id of [...myProjectIds, ...sharedProjectIds]) + expect(returnedIds.has(id)).toBe(true); + }); +}); diff --git a/backend/src/lib/__tests__/projectsOverview.test.ts b/backend/src/lib/__tests__/projectsOverview.test.ts new file mode 100644 index 000000000..1eeb04656 --- /dev/null +++ b/backend/src/lib/__tests__/projectsOverview.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; +import { + buildProjectIdsOverviewRpcArgs, + buildProjectsOverviewRpcArgs, + parseProjectScope, +} from "../projectsOverview"; + +describe("buildProjectsOverviewRpcArgs", () => { + it("builds the full RPC payload when the paginated signature is requested", () => { + expect( + buildProjectsOverviewRpcArgs({ + userId: "user-1", + userEmail: "user@example.com", + scope: "mine", + pagination: { limit: 25, offset: 10 }, + searchTerm: "merger", + sort: { key: "name", direction: "asc" }, + practice: "Litigation", + ownerUserId: "user-2", + }), + ).toEqual({ + p_user_id: "user-1", + p_user_email: "user@example.com", + p_scope: "mine", + p_limit: 25, + p_offset: 10, + p_search_term: "merger", + p_sort_key: "name", + p_sort_direction: "asc", + p_practice: "Litigation", + p_owner_user_id: "user-2", + }); + }); + + it("uses default pagination, sort, and filter values when omitted", () => { + expect( + buildProjectsOverviewRpcArgs({ + userId: "user-1", + userEmail: undefined, + }), + ).toEqual({ + p_user_id: "user-1", + p_user_email: null, + p_scope: "all", + p_limit: 20, + p_offset: 0, + p_search_term: null, + p_sort_key: "created", + p_sort_direction: "desc", + p_practice: null, + p_owner_user_id: null, + }); + }); +}); + +describe("buildProjectIdsOverviewRpcArgs", () => { + it("builds the ids-only RPC payload with no sort but with pagination", () => { + expect( + buildProjectIdsOverviewRpcArgs({ + userId: "user-1", + userEmail: "user@example.com", + scope: "shared", + searchTerm: "merger", + practice: "Litigation", + ownerUserId: "user-2", + pagination: { limit: 1000, offset: 2000 }, + }), + ).toEqual({ + p_user_id: "user-1", + p_user_email: "user@example.com", + p_scope: "shared", + p_search_term: "merger", + p_practice: "Litigation", + p_owner_user_id: "user-2", + p_limit: 1000, + p_offset: 2000, + }); + }); + + it("uses default scope and filter values when omitted", () => { + expect( + buildProjectIdsOverviewRpcArgs({ + userId: "user-1", + userEmail: undefined, + pagination: { limit: 1000, offset: 0 }, + }), + ).toEqual({ + p_user_id: "user-1", + p_user_email: null, + p_scope: "all", + p_search_term: null, + p_practice: null, + p_owner_user_id: null, + p_limit: 1000, + p_offset: 0, + }); + }); +}); + +describe("parseProjectScope", () => { + it("accepts supported project scopes", () => { + expect(parseProjectScope("mine")).toBe("mine"); + expect(parseProjectScope("shared")).toBe("shared"); + }); + + it("falls back to all for missing or unsupported scopes", () => { + expect(parseProjectScope(undefined)).toBe("all"); + expect(parseProjectScope("in-project")).toBe("all"); + }); +}); diff --git a/backend/src/lib/__tests__/sort.test.ts b/backend/src/lib/__tests__/sort.test.ts index a3eff3a87..1020c3acc 100644 --- a/backend/src/lib/__tests__/sort.test.ts +++ b/backend/src/lib/__tests__/sort.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { parseTabularReviewSort } from "../sort"; +import { parseProjectSort, parseTabularReviewSort } from "../sort"; describe("parseTabularReviewSort", () => { it("accepts a supported sort key and direction", () => { @@ -16,3 +16,25 @@ describe("parseTabularReviewSort", () => { }); }); }); + +describe("parseProjectSort", () => { + it("accepts a supported sort key and direction", () => { + expect(parseProjectSort({ key: "files", direction: "asc" })).toEqual({ + key: "files", + direction: "asc", + }); + }); + + it("accepts sort_key/sort_direction as well as key/direction", () => { + expect( + parseProjectSort({ sort_key: "reviews", sort_direction: "asc" }), + ).toEqual({ key: "reviews", direction: "asc" }); + }); + + it("falls back to created desc for unsupported values", () => { + expect(parseProjectSort({ key: "unknown", direction: "sideways" })).toEqual({ + key: "created", + direction: "desc", + }); + }); +}); diff --git a/backend/src/lib/projectsOverview.ts b/backend/src/lib/projectsOverview.ts new file mode 100644 index 000000000..7fb001f2e --- /dev/null +++ b/backend/src/lib/projectsOverview.ts @@ -0,0 +1,80 @@ +export type ProjectScope = "all" | "mine" | "shared"; + +export function parseProjectScope(value: unknown): ProjectScope { + if (value === "mine" || value === "shared") return value; + return "all"; +} + +export interface ProjectsOverviewRpcArgs { + p_user_id: string; + p_user_email: string | null; + p_scope: ProjectScope; + p_limit: number; + p_offset: number; + p_search_term: string | null; + p_sort_key: string; + p_sort_direction: string; + p_practice: string | null; + p_owner_user_id: string | null; +} + +export function buildProjectsOverviewRpcArgs(params: { + userId: string; + userEmail: string | undefined; + scope?: ProjectScope; + pagination?: { limit: number; offset: number }; + searchTerm?: string | null; + sort?: { key: string; direction: string }; + practice?: string | null; + ownerUserId?: string | null; +}): ProjectsOverviewRpcArgs { + return { + p_user_id: params.userId, + p_user_email: params.userEmail ?? null, + p_scope: params.scope ?? "all", + p_limit: params.pagination?.limit ?? 20, + p_offset: params.pagination?.offset ?? 0, + p_search_term: params.searchTerm ?? null, + p_sort_key: params.sort?.key ?? "created", + p_sort_direction: params.sort?.direction ?? "desc", + p_practice: params.practice ?? null, + p_owner_user_id: params.ownerUserId ?? null, + }; +} + +export interface ProjectIdsOverviewRpcArgs { + p_user_id: string; + p_user_email: string | null; + p_scope: ProjectScope; + p_search_term: string | null; + p_practice: string | null; + p_owner_user_id: string | null; + p_limit: number; + p_offset: number; +} + +// Lightweight sibling of buildProjectsOverviewRpcArgs for "select all +// matching" actions: no sort (order doesn't matter for a bulk id list), but +// still paginated — PostgREST enforces its own row cap on every RPC +// response, so a caller that skips pagination here will silently get a +// truncated id list back with no error. +export function buildProjectIdsOverviewRpcArgs(params: { + userId: string; + userEmail: string | undefined; + scope?: ProjectScope; + searchTerm?: string | null; + practice?: string | null; + ownerUserId?: string | null; + pagination: { limit: number; offset: number }; +}): ProjectIdsOverviewRpcArgs { + return { + p_user_id: params.userId, + p_user_email: params.userEmail ?? null, + p_scope: params.scope ?? "all", + p_search_term: params.searchTerm ?? null, + p_practice: params.practice ?? null, + p_owner_user_id: params.ownerUserId ?? null, + p_limit: params.pagination.limit, + p_offset: params.pagination.offset, + }; +} diff --git a/backend/src/lib/sort.ts b/backend/src/lib/sort.ts index e112e3e2c..452ab33db 100644 --- a/backend/src/lib/sort.ts +++ b/backend/src/lib/sort.ts @@ -21,3 +21,27 @@ export function parseTabularReviewSort(value: Record): TabularR return { key, direction }; } + +export type ProjectSortKey = "name" | "cm" | "files" | "chats" | "reviews" | "created"; +export type ProjectSortDirection = "asc" | "desc"; + +export interface ProjectSort { + key: ProjectSortKey; + direction: ProjectSortDirection; +} + +const PROJECT_SUPPORTED_KEYS: ProjectSortKey[] = ["name", "cm", "files", "chats", "reviews", "created"]; + +export function parseProjectSort(value: Record): ProjectSort { + const rawKey = typeof value.sort_key === "string" + ? value.sort_key + : typeof value.key === "string" + ? value.key + : null; + const key = rawKey && PROJECT_SUPPORTED_KEYS.includes(rawKey as ProjectSortKey) + ? (rawKey as ProjectSortKey) + : "created"; + const direction = value.sort_direction === "asc" || value.direction === "asc" ? "asc" : "desc"; + + return { key, direction }; +} diff --git a/backend/src/routes/projects.ts b/backend/src/routes/projects.ts index ed5750b29..1dfd62b7c 100644 --- a/backend/src/routes/projects.ts +++ b/backend/src/routes/projects.ts @@ -32,6 +32,14 @@ import { findMissingUserEmails, loadProfileUsersByEmail, } from "../lib/userLookup"; +import { parsePaginationQuery } from "../lib/pagination"; +import { normalizeSearchTerm } from "../lib/search"; +import { parseProjectSort } from "../lib/sort"; +import { + buildProjectIdsOverviewRpcArgs, + buildProjectsOverviewRpcArgs, + parseProjectScope, +} from "../lib/projectsOverview"; export const projectsRouter = Router(); @@ -164,16 +172,50 @@ async function attachChatCreatorLabels( // projects that burst — auth check plus several DB queries per request — // could overwhelm the Supabase gateway. Batching keeps it at one request // and a fixed number of queries regardless of project count. +// +// Pagination is opt-in via query params (limit/offset/search/sort_key or +// key/scope) — only ProjectsOverview.tsx sends them. Every other caller +// (sidebar nav, document-picker directory view, tabular-review project +// pickers) calls this with no query params at all and must keep getting the +// full, unpaginated list back, so the branch below must never default to +// paginating a request that didn't ask for it. +const PROJECT_PAGINATION_QUERY_KEYS = [ + "limit", + "offset", + "search", + "sort_key", + "key", + "sort_direction", + "direction", + "scope", + "practice", + "owner_user_id", +]; + projectsRouter.get("/", requireAuth, async (req, res) => { const userId = res.locals.userId as string; const userEmail = res.locals.userEmail as string | undefined; const includeDocuments = req.query.include === "documents"; const db = createServerSupabase(); - const { data, error } = await db.rpc("get_projects_overview", { - p_user_id: userId, - p_user_email: userEmail ?? null, - }); + const hasPaginationParams = PROJECT_PAGINATION_QUERY_KEYS.some( + (key) => req.query[key] !== undefined, + ); + + const rpcArgs = hasPaginationParams + ? buildProjectsOverviewRpcArgs({ + userId, + userEmail, + scope: parseProjectScope(req.query.scope), + pagination: parsePaginationQuery(req.query as Record), + searchTerm: normalizeSearchTerm(req.query.search), + sort: parseProjectSort(req.query as Record), + practice: normalizeSearchTerm(req.query.practice), + ownerUserId: normalizeSearchTerm(req.query.owner_user_id), + }) + : { p_user_id: userId, p_user_email: userEmail ?? null }; + + const { data, error } = await db.rpc("get_projects_overview", rpcArgs); if (error) return void res.status(500).json({ detail: error.message }); const projects = (data ?? []) as { id: string }[]; @@ -288,6 +330,56 @@ projectsRouter.post("/", requireAuth, async (req, res) => { res.status(201).json({ ...data, documents: [] }); }); +// GET /projects/ids (must come before /:projectId routes) +// Lightweight id + owner list for every project matching the current +// filters — backs "select all matching" bulk actions so the client doesn't +// have to page through full project payloads just to collect checkboxes. +// +// PostgREST enforces its own row cap on every RPC response (db-max-rows), +// independent of anything this route asks for, and truncates silently +// rather than failing. So this pages through the RPC itself — server-side, +// same-datacenter round trips — until a page comes back empty, rather than +// trusting one call to return everything. +const PROJECT_IDS_PAGE_SIZE = 1000; +const PROJECT_IDS_MAX_PAGES = 200; // guards a runaway loop, not a product limit + +projectsRouter.get("/ids", requireAuth, async (req, res) => { + const userId = res.locals.userId as string; + const userEmail = res.locals.userEmail as string | undefined; + const db = createServerSupabase(); + + const searchTerm = normalizeSearchTerm(req.query.search); + const scope = parseProjectScope(req.query.scope); + const practice = normalizeSearchTerm(req.query.practice); + const ownerUserId = normalizeSearchTerm(req.query.owner_user_id); + + const ids: { id: string; user_id: string }[] = []; + let offset = 0; + for (let page = 0; page < PROJECT_IDS_MAX_PAGES; page++) { + const rpcArgs = buildProjectIdsOverviewRpcArgs({ + userId, + userEmail, + scope, + searchTerm, + practice, + ownerUserId, + pagination: { limit: PROJECT_IDS_PAGE_SIZE, offset }, + }); + const { data, error } = await db.rpc("get_project_ids_overview", rpcArgs); + if (error) return void res.status(500).json({ detail: error.message }); + + const rows = (data ?? []) as { id: string; user_id: string }[]; + if (rows.length === 0) break; + ids.push(...rows); + // Advance by what actually came back, not the requested page size — if + // PostgREST's cap is lower than PROJECT_IDS_PAGE_SIZE this still + // converges correctly instead of skipping rows. + offset += rows.length; + } + + res.json(ids); +}); + // GET /projects/:projectId projectsRouter.get("/:projectId", requireAuth, async (req, res) => { const userId = res.locals.userId as string; diff --git a/frontend/src/app/(pages)/tabular-reviews/page.tsx b/frontend/src/app/(pages)/tabular-reviews/page.tsx index 645fc88de..a0e160910 100644 --- a/frontend/src/app/(pages)/tabular-reviews/page.tsx +++ b/frontend/src/app/(pages)/tabular-reviews/page.tsx @@ -8,6 +8,7 @@ import { RowActionMenuItems, RowActions, } from "@/app/components/shared/RowActions"; +import { TableLoadMoreRow } from "@/app/components/shared/TableLoadMoreRow"; import { deleteTabularReview, createTabularReview, @@ -683,24 +684,14 @@ export default function TabularReviewsPage() { })} )} - {!effectiveLoading && hasMore && filtered.length > 0 && ( -
- -
- )} + )} - {!loading && hasMore && reviews.length > 0 && ( -
- -
- )} + ); } diff --git a/frontend/src/app/components/projects/ProjectsOverview.tsx b/frontend/src/app/components/projects/ProjectsOverview.tsx index 1c087bb9b..547a4251e 100644 --- a/frontend/src/app/components/projects/ProjectsOverview.tsx +++ b/frontend/src/app/components/projects/ProjectsOverview.tsx @@ -8,6 +8,9 @@ import { updateProject, deleteProject, } from "@/app/lib/mikeApi"; +import { deleteTabularReviewsWithConcurrency } from "@/app/lib/deleteTabularReviewsWithConcurrency"; +import { useDebouncedValue } from "@/app/hooks/useDebouncedValue"; +import { usePaginatedProjects } from "@/app/hooks/usePaginatedProjects"; import { OwnerOnlyPopup } from "@/app/components/popups/OwnerOnlyPopup"; import { useAuth } from "@/app/contexts/AuthContext"; import type { Project } from "@/app/components/shared/types"; @@ -19,6 +22,7 @@ import { RowActions, } from "@/app/components/shared/RowActions"; import { PageHeader } from "@/app/components/shared/PageHeader"; +import { TableLoadMoreRow } from "@/app/components/shared/TableLoadMoreRow"; import { ClosedProjectSvgIcon, OpenProjectSvgIcon, @@ -75,9 +79,6 @@ const SORT_OPTIONS: TableFilterOption[] = [ ]; export function ProjectsOverview() { - const [projects, setProjects] = useState([]); - const [loading, setLoading] = useState(true); - const [loadError, setLoadError] = useState(null); const [modalOpen, setModalOpen] = useState(false); const [detailsProject, setDetailsProject] = useState(null); const [activeFilter, setActiveFilter] = useState("all"); @@ -87,15 +88,46 @@ export function ProjectsOverview() { key: ProjectSortKey; direction: TableSortDirection; } | null>(null); - const [selectedIds, setSelectedIds] = useState([]); const [actionsOpen, setActionsOpen] = useState(false); const [search, setSearch] = useState(""); const [ownerOnlyAction, setOwnerOnlyAction] = useState(null); + // A separate, always-unpaginated fetch used only to enumerate the + // practice/owner filter dropdown options — the paginated rows below + // won't necessarily include every distinct practice/owner once there + // are more projects than fit on the first page. + const [filterOptionsProjects, setFilterOptionsProjects] = useState< + Project[] + >([]); const actionsRef = useRef(null); const router = useRouter(); const searchParams = useSearchParams(); const { user, isAuthenticated, authLoading } = useAuth(); const previewEmptyStates = searchParams.get("emptyStates") === "1"; + const debouncedSearch = useDebouncedValue(search, 250); + + const { + projects, + setProjects, + loading, + loadingMore, + hasMore, + error: loadErrorObj, + loadMoreError, + loadMore, + retry, + selectedProjectIds: selectedIds, + setSelectedProjectIds: setSelectedIds, + selectAllMatching, + getProjectOwnerId, + } = usePaginatedProjects({ + search: debouncedSearch, + selectionKey: search, + scope: activeFilter === "shared-with-me" ? "shared" : activeFilter, + practiceFilter, + ownerUserIdFilter: ownerFilter, + sort, + }); + const loadError = loadErrorObj ? "Could not load projects." : null; const effectiveLoading = loading && !previewEmptyStates; const visibleProjects = useMemo( () => (previewEmptyStates ? [] : projects), @@ -103,44 +135,20 @@ export function ProjectsOverview() { ); useEffect(() => { + if (authLoading || !isAuthenticated) return; let cancelled = false; - - async function loadProjects() { - await Promise.resolve(); - if (cancelled) return; - if (authLoading) { - setLoading(true); - return; - } - if (!isAuthenticated) { - setProjects([]); - setLoadError(null); - setLoading(false); - return; - } - - setLoading(true); - setLoadError(null); - try { - const loaded = await listProjects(); - if (!cancelled) setProjects(loaded); - } catch (err) { - console.error("[projects] failed to load projects", err); - if (!cancelled) { - setProjects([]); - setLoadError("Could not load projects."); - } - } finally { - if (!cancelled) setLoading(false); - } - } - - void loadProjects(); - + listProjects() + .then((data) => { + if (!cancelled) setFilterOptionsProjects(data); + }) + .catch(() => { + // Filter option lists degrade to "no options" — not worth a + // user-facing error for a purely cosmetic dropdown. + }); return () => { cancelled = true; }; - }, [authLoading, isAuthenticated, user?.id]); + }, [authLoading, isAuthenticated]); useEffect(() => { function handleClick(e: MouseEvent) { @@ -154,125 +162,41 @@ export function ProjectsOverview() { return () => document.removeEventListener("mousedown", handleClick); }, [actionsOpen]); - const q = search.toLowerCase(); const practices = useMemo( () => Array.from( new Set( - visibleProjects + filterOptionsProjects .map((project) => project.practice?.trim()) .filter((practice): practice is string => !!practice), ), ).sort((a, b) => a.localeCompare(b)), - [visibleProjects], + [filterOptionsProjects], ); - const ownerOptions = useMemo( - () => - Array.from( - new Set( - visibleProjects.map((project) => - getProjectOwnerLabel(project, user?.id), - ), - ), - ) - .sort((a, b) => a.localeCompare(b)) - .map((owner) => ({ value: owner, label: owner })), - [visibleProjects, user?.id], - ); - const filtered = useMemo(() => { - const rows = ( - activeFilter === "all" - ? visibleProjects - : activeFilter === "mine" - ? visibleProjects.filter( - (p) => p.is_owner ?? p.user_id === user?.id, - ) - : visibleProjects.filter( - (p) => !(p.is_owner ?? p.user_id === user?.id), - ) - ) - .filter( - (p) => - !q || - p.name.toLowerCase().includes(q) || - (p.cm_number ?? "").toLowerCase().includes(q) || - (p.practice ?? "").toLowerCase().includes(q), - ) - .filter( - (p) => - !practiceFilter || - (p.practice?.trim() ?? "") === practiceFilter, - ) - .filter( - (p) => - !ownerFilter || - getProjectOwnerLabel(p, user?.id) === ownerFilter, - ); - - if (!sort) return rows; - - return [...rows].sort((a, b) => { - const multiplier = sort.direction === "asc" ? 1 : -1; - - if (sort.key === "cm") { - return ( - (a.cm_number ?? "").localeCompare(b.cm_number ?? "") * - multiplier - ); - } - - if (sort.key === "files") { - return ( - ((a.document_count ?? 0) - (b.document_count ?? 0)) * - multiplier - ); - } - - if (sort.key === "chats") { - return ( - ((a.chat_count ?? 0) - (b.chat_count ?? 0)) * multiplier - ); - } - - if (sort.key === "reviews") { - return ( - ((a.review_count ?? 0) - (b.review_count ?? 0)) * - multiplier - ); - } - - if (sort.key === "created") { - return ( - (new Date(a.created_at).getTime() - - new Date(b.created_at).getTime()) * - multiplier + const ownerOptions = useMemo(() => { + const labelByUserId = new Map(); + for (const project of filterOptionsProjects) { + if (!labelByUserId.has(project.user_id)) { + labelByUserId.set( + project.user_id, + getProjectOwnerLabel(project, user?.id), ); } - - return a.name.localeCompare(b.name) * multiplier; - }); - }, [ - activeFilter, - ownerFilter, - practiceFilter, - q, - sort, - user?.id, - visibleProjects, - ]); + } + return Array.from(labelByUserId.entries()) + .map(([value, label]) => ({ value, label })) + .sort((a, b) => a.label.localeCompare(b.label)); + }, [filterOptionsProjects, user?.id]); const allSelected = - filtered.length > 0 && - filtered.every((p) => selectedIds.includes(p.id)); + visibleProjects.length > 0 && + visibleProjects.every((p) => selectedIds.includes(p.id)); const someSelected = - !allSelected && filtered.some((p) => selectedIds.includes(p.id)); + !allSelected && visibleProjects.some((p) => selectedIds.includes(p.id)); function toggleAll() { - if (allSelected) { - setSelectedIds([]); - } else { - setSelectedIds(filtered.map((p) => p.id)); - } + if (allSelected) setSelectedIds([]); + else void selectAllMatching(); } function toggleOne(id: string) { @@ -438,15 +362,21 @@ export function ProjectsOverview() { setActionsOpen(false); // Only the project owner can delete; the per-row delete is hidden // for shared projects but the bulk action can still pick them up - // if a user toggled them across filters. Filter and warn. + // if a user toggled them across filters (or select-all-matching + // pulled in ids that were never paged into `projects`, which is why + // this uses getProjectOwnerId rather than looking the row up + // directly). Filter and warn. const owned = ids.filter((id) => { - const p = projects.find((pp) => pp.id === id); - return !p || (p.is_owner ?? p.user_id === user?.id); + const ownerId = getProjectOwnerId(id); + return !ownerId || ownerId === user?.id; }); const blocked = ids.length - owned.length; setSelectedIds([]); - await Promise.all(owned.map((id) => deleteProject(id).catch(() => {}))); - setProjects((prev) => prev.filter((p) => !owned.includes(p.id))); + const { deletedIds } = await deleteTabularReviewsWithConcurrency( + owned, + deleteProject, + ); + setProjects((prev) => prev.filter((p) => !deletedIds.includes(p.id))); if (blocked > 0) { setOwnerOnlyAction( `delete ${blocked} of the selected projects — only the project owner can delete a project`, @@ -512,6 +442,13 @@ export function ProjectsOverview() { {/* Table */} { + if (loading || loadingMore || !hasMore) return; + const el = event.currentTarget; + const distanceToBottom = + el.scrollHeight - el.scrollTop - el.clientHeight; + if (distanceToBottom < 200) void loadMore(); + }} header={ @@ -627,8 +564,16 @@ export function ProjectsOverview() {

{loadError}

+ + Try again + - ) : filtered.length === 0 ? ( + ) : visibleProjects.length === 0 ? ( {activeFilter === "all" || activeFilter === "mine" ? ( <> @@ -659,7 +604,7 @@ export function ProjectsOverview() { ) : ( - {filtered.map((project) => { + {visibleProjects.map((project) => { return ( )} + void loadMore()} + />
void; +}) { + if (loading || !hasMore || itemCount === 0) return null; + + return ( +
+ +
+ ); +} diff --git a/frontend/src/app/hooks/usePaginatedProjects.ts b/frontend/src/app/hooks/usePaginatedProjects.ts new file mode 100644 index 000000000..902e45c8b --- /dev/null +++ b/frontend/src/app/hooks/usePaginatedProjects.ts @@ -0,0 +1,312 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type Dispatch, + type SetStateAction, +} from "react"; +import type { Project } from "@/app/components/shared/types"; +import { listProjectIds, listProjectsPage } from "@/app/lib/mikeApi"; + +export type ProjectSortKey = "name" | "cm" | "files" | "chats" | "reviews" | "created"; +export type ProjectSortDirection = "asc" | "desc"; +export type ProjectScope = "all" | "mine" | "shared"; + +const PAGE_SIZE = 30; + +function pageRows(rows: Project[]) { + return { + hasMore: rows.length > PAGE_SIZE, + rows: rows.slice(0, PAGE_SIZE), + }; +} + +function asError(value: unknown) { + return value instanceof Error + ? value + : new Error("Unable to load projects"); +} + +// Server-side-paginated projects list, cloned from usePaginatedTabularReviews +// (same shape: limit+1 over-fetch to derive hasMore without a count query, +// queryKey-scoped selection state so changing filters can't leak stale +// selection, and a "select all matching" path that fetches ids only once +// everything visible has already been paged in). There's no shared paginated- +// list primitive in this codebase yet — cloning matches the existing +// per-entity convention rather than introducing a generic hook for two users. +export function usePaginatedProjects(options: { + search?: string; + selectionKey?: string; + scope?: ProjectScope; + practiceFilter?: string | null; + ownerUserIdFilter?: string | null; + sort?: { + key: ProjectSortKey; + direction: ProjectSortDirection; + } | null; +}) { + const [projects, setProjects] = useState([]); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [hasMore, setHasMore] = useState(true); + const [error, setError] = useState(null); + const [loadMoreError, setLoadMoreError] = useState(null); + const [selectingAllRequest, setSelectingAllRequest] = useState(false); + const [retryVersion, setRetryVersion] = useState(0); + const requestVersionRef = useRef(0); + const loadingMoreRef = useRef(false); + const loadMoreControllerRef = useRef(null); + + const { + search, + selectionKey, + scope = "all", + practiceFilter = null, + ownerUserIdFilter = null, + sort, + } = options; + const sortKey = sort?.key; + const sortDirection = sort?.direction; + const selectionQueryPending = + selectionKey !== undefined && selectionKey !== search; + const queryKey = JSON.stringify([ + selectionKey ?? null, + search ?? null, + scope, + practiceFilter, + ownerUserIdFilter, + sortKey ?? null, + sortDirection ?? null, + ]); + const [selection, setSelection] = useState<{ + queryKey: string; + ids: string[]; + }>({ queryKey, ids: [] }); + const selectedProjectIds = + selection.queryKey === queryKey ? selection.ids : []; + const setSelectedProjectIds: Dispatch> = + useCallback( + (value) => { + setSelection((current) => { + const currentIds = + current.queryKey === queryKey ? current.ids : []; + const ids = + typeof value === "function" ? value(currentIds) : value; + return { queryKey, ids }; + }); + }, + [queryKey], + ); + // Owner lookup for project ids that "select all matching" pulled in but + // that haven't been paged into `projects` yet — bulk actions (e.g. + // delete) need the owning user_id without fetching each project's full + // payload. + const [selectAllOwners, setSelectAllOwners] = useState<{ + queryKey: string; + ownerById: Record; + }>({ queryKey, ownerById: {} }); + const getProjectOwnerId = useCallback( + (id: string): string | undefined => { + const loaded = projects.find((project) => project.id === id); + if (loaded) return loaded.user_id; + return selectAllOwners.queryKey === queryKey + ? selectAllOwners.ownerById[id] + : undefined; + }, + [projects, selectAllOwners, queryKey], + ); + + useEffect(() => { + const requestVersion = ++requestVersionRef.current; + const controller = new AbortController(); + loadMoreControllerRef.current?.abort(); + loadMoreControllerRef.current = null; + loadingMoreRef.current = false; + setProjects([]); + setHasMore(true); + setLoadingMore(false); + setError(null); + setLoadMoreError(null); + setLoading(true); + + void listProjectsPage({ + limit: PAGE_SIZE + 1, + search: search || undefined, + scope, + practice: practiceFilter || undefined, + ownerUserId: ownerUserIdFilter || undefined, + sortKey, + sortDirection, + signal: controller.signal, + }) + .then((rows) => { + if (requestVersion !== requestVersionRef.current) return; + const firstPage = pageRows(rows); + setProjects(firstPage.rows); + setHasMore(firstPage.hasMore); + }) + .catch((error) => { + if ( + controller.signal.aborted || + requestVersion !== requestVersionRef.current + ) + return; + console.error("[projects] failed to load", error); + setError(asError(error)); + setHasMore(false); + }) + .finally(() => { + if ( + !controller.signal.aborted && + requestVersion === requestVersionRef.current + ) + setLoading(false); + }); + return () => { + controller.abort(); + loadMoreControllerRef.current?.abort(); + }; + }, [ + retryVersion, + scope, + practiceFilter, + ownerUserIdFilter, + search, + sortDirection, + sortKey, + ]); + + const loadMore = useCallback(async () => { + if (loading || loadingMoreRef.current || !hasMore) return; + + const requestVersion = requestVersionRef.current; + const offset = projects.length; + const controller = new AbortController(); + loadMoreControllerRef.current?.abort(); + loadMoreControllerRef.current = controller; + loadingMoreRef.current = true; + setLoadingMore(true); + setLoadMoreError(null); + + try { + const rows = await listProjectsPage({ + limit: PAGE_SIZE + 1, + offset, + search: search || undefined, + scope, + practice: practiceFilter || undefined, + ownerUserId: ownerUserIdFilter || undefined, + sortKey, + sortDirection, + signal: controller.signal, + }); + if (requestVersion !== requestVersionRef.current) return; + + const nextPage = pageRows(rows); + setProjects((current) => { + const existingIds = new Set(current.map((project) => project.id)); + return [ + ...current, + ...nextPage.rows.filter( + (project) => !existingIds.has(project.id), + ), + ]; + }); + setHasMore(nextPage.hasMore); + } catch (error) { + if ( + !controller.signal.aborted && + requestVersion === requestVersionRef.current + ) { + console.error("[projects] failed to load more", error); + setLoadMoreError(asError(error)); + } + } finally { + if ( + requestVersion === requestVersionRef.current && + loadMoreControllerRef.current === controller + ) { + loadMoreControllerRef.current = null; + loadingMoreRef.current = false; + setLoadingMore(false); + } + } + }, [ + hasMore, + loading, + projects.length, + scope, + practiceFilter, + ownerUserIdFilter, + search, + sortDirection, + sortKey, + ]); + const retry = useCallback(() => { + setRetryVersion((current) => current + 1); + }, []); + + // Selects every project matching the current filters, not just the + // page(s) already loaded — a plain "select loaded rows" checkbox is + // misleading once results span more than one page. Fetches only ids (+ + // owner), not full project payloads, since that's all a bulk selection + // needs. + const selectAllMatching = useCallback(async () => { + if (selectionQueryPending) return; + + if (!hasMore) { + setSelectedProjectIds(projects.map((project) => project.id)); + return; + } + + const requestVersion = requestVersionRef.current; + setSelectingAllRequest(true); + try { + const rows = await listProjectIds({ + search: search || undefined, + scope, + practice: practiceFilter || undefined, + ownerUserId: ownerUserIdFilter || undefined, + }); + if (requestVersion !== requestVersionRef.current) return; + + setSelectAllOwners({ + queryKey, + ownerById: Object.fromEntries( + rows.map((row) => [row.id, row.user_id]), + ), + }); + setSelectedProjectIds(rows.map((row) => row.id)); + } finally { + setSelectingAllRequest(false); + } + }, [ + hasMore, + queryKey, + projects, + scope, + practiceFilter, + ownerUserIdFilter, + search, + selectionQueryPending, + setSelectedProjectIds, + ]); + + return { + projects, + setProjects, + loading, + loadingMore, + hasMore, + error, + loadMoreError, + loadMore, + retry, + selectedProjectIds, + setSelectedProjectIds, + selectAllMatching, + selectingAll: selectingAllRequest || selectionQueryPending, + getProjectOwnerId, + }; +} diff --git a/frontend/src/app/lib/mikeApi.test.ts b/frontend/src/app/lib/mikeApi.test.ts index 36b06c82d..58ce5a070 100644 --- a/frontend/src/app/lib/mikeApi.test.ts +++ b/frontend/src/app/lib/mikeApi.test.ts @@ -72,7 +72,9 @@ import { listHiddenWorkflows, listMcpConnectors, listProjectChats, + listProjectIds, listProjects, + listProjectsPage, listStandaloneDocuments, listTabularReviewIds, listTabularReviews, @@ -260,6 +262,21 @@ describe("apiRequest plumbing (via thin wrappers)", () => { ); }); + // Regression guard: the sidebar nav, the document-picker directory view, + // and the tabular-review project pickers all call listProjects() with no + // arguments and need every project back. The backend route decides + // whether to paginate purely by checking whether pagination-related + // query params are present at all — if listProjects() ever started + // sending one, those callers would silently start seeing a truncated + // list instead of an error. + it("sends no query string at all, so the backend never paginates it", async () => { + fetchMock.mockResolvedValue(jsonResponse([])); + + await listProjects(); + + expect(lastFetchCall().url).toBe("http://localhost:3001/projects"); + }); + it("returns undefined for 204 responses", async () => { fetchMock.mockResolvedValue(new Response(null, { status: 204 })); @@ -787,6 +804,95 @@ describe("listTabularReviewIds", () => { }); }); +describe("listProjectsPage", () => { + it("requests the bare collection when no filters are given", async () => { + fetchMock.mockResolvedValue(jsonResponse([])); + + await listProjectsPage(); + + const { url, init } = lastFetchCall(); + expect(url).toBe("http://localhost:3001/projects"); + expect(init.signal).toBeUndefined(); + }); + + it("serializes every pagination knob and forwards the abort signal", async () => { + fetchMock.mockResolvedValue(jsonResponse([])); + const controller = new AbortController(); + + await listProjectsPage({ + limit: 30, + offset: 60, + search: "acquisitions", + sortKey: "files", + sortDirection: "desc", + scope: "mine", + practice: "Litigation", + ownerUserId: "user-2", + signal: controller.signal, + }); + + const { url, init } = lastFetchCall(); + expect(url).toBe( + "http://localhost:3001/projects" + + "?limit=30&offset=60&search=acquisitions" + + "&sort_key=files&sort_direction=desc&scope=mine" + + "&practice=Litigation&owner_user_id=user-2", + ); + expect(init.signal).toBe(controller.signal); + }); + + it('omits the scope param for "all" — the backend default', async () => { + fetchMock.mockResolvedValue(jsonResponse([])); + + await listProjectsPage({ scope: "all", limit: 10 }); + + expect(lastFetchCall().url).toBe( + "http://localhost:3001/projects?limit=10", + ); + }); +}); + +describe("listProjectIds", () => { + it("requests the bare id list when no filters are given", async () => { + fetchMock.mockResolvedValue(jsonResponse([])); + + await listProjectIds(); + + expect(lastFetchCall().url).toBe("http://localhost:3001/projects/ids"); + }); + + it("scopes ids by search, scope, practice, and owner so select-all matches the visible filter", async () => { + fetchMock.mockResolvedValue(jsonResponse([{ id: "p1", user_id: "u1" }])); + const controller = new AbortController(); + + const ids = await listProjectIds({ + search: "nda", + scope: "mine", + practice: "Litigation", + ownerUserId: "user-2", + signal: controller.signal, + }); + + expect(ids).toEqual([{ id: "p1", user_id: "u1" }]); + const { url, init } = lastFetchCall(); + // Select-all-then-delete deletes whatever this returns; if the query + // here is broader than the list query, users delete unseen projects. + expect(url).toBe( + "http://localhost:3001/projects/ids?search=nda&scope=mine" + + "&practice=Litigation&owner_user_id=user-2", + ); + expect(init.signal).toBe(controller.signal); + }); + + it('omits the scope param for "all"', async () => { + fetchMock.mockResolvedValue(jsonResponse([])); + + await listProjectIds({ scope: "all" }); + + expect(lastFetchCall().url).toBe("http://localhost:3001/projects/ids"); + }); +}); + describe("tabular review CRUD", () => { it("createTabularReview posts the folder grouping mode through unchanged", async () => { fetchMock.mockResolvedValue(jsonResponse({ id: "r1" })); diff --git a/frontend/src/app/lib/mikeApi.ts b/frontend/src/app/lib/mikeApi.ts index c143c1c8f..c56fc21f0 100644 --- a/frontend/src/app/lib/mikeApi.ts +++ b/frontend/src/app/lib/mikeApi.ts @@ -170,6 +170,60 @@ export async function listProjects(options?: { return apiRequest(`/projects${query}`); } +// Paginated sibling of listProjects() used only by ProjectsOverview.tsx. +// Deliberately a separate function, not an overload of listProjects — the +// backend route decides whether to paginate based on whether any of these +// query params are present at all, so listProjects() must keep sending none +// of them (every other caller — the sidebar, the document-picker directory +// view, the tabular-review project pickers — needs the full unpaginated list). +export async function listProjectsPage(pagination?: { + limit?: number; + offset?: number; + search?: string; + sortKey?: string; + sortDirection?: "asc" | "desc"; + scope?: "all" | "mine" | "shared"; + practice?: string; + ownerUserId?: string; + signal?: AbortSignal; +}): Promise { + const params = new URLSearchParams(); + if (pagination?.limit) params.set("limit", String(pagination.limit)); + if (pagination?.offset) params.set("offset", String(pagination.offset)); + if (pagination?.search) params.set("search", pagination.search); + if (pagination?.sortKey) params.set("sort_key", pagination.sortKey); + if (pagination?.sortDirection) params.set("sort_direction", pagination.sortDirection); + if (pagination?.scope && pagination.scope !== "all") + params.set("scope", pagination.scope); + if (pagination?.practice) params.set("practice", pagination.practice); + if (pagination?.ownerUserId) params.set("owner_user_id", pagination.ownerUserId); + + const qs = params.toString() ? `?${params.toString()}` : ""; + return apiRequest(`/projects${qs}`, { + signal: pagination?.signal, + }); +} + +export async function listProjectIds(options?: { + search?: string; + scope?: "all" | "mine" | "shared"; + practice?: string; + ownerUserId?: string; + signal?: AbortSignal; +}): Promise<{ id: string; user_id: string }[]> { + const params = new URLSearchParams(); + if (options?.search) params.set("search", options.search); + if (options?.scope && options.scope !== "all") params.set("scope", options.scope); + if (options?.practice) params.set("practice", options.practice); + if (options?.ownerUserId) params.set("owner_user_id", options.ownerUserId); + + const qs = params.toString() ? `?${params.toString()}` : ""; + return apiRequest<{ id: string; user_id: string }[]>( + `/projects/ids${qs}`, + { signal: options?.signal }, + ); +} + export async function createProject( name: string, cm_number?: string, diff --git a/package.json b/package.json index 7367cb57b..2ac225789 100644 --- a/package.json +++ b/package.json @@ -16,5 +16,8 @@ "engines": { "node": ">=22" }, - "license": "AGPL-3.0-only" + "license": "AGPL-3.0-only", + "allowScripts": { + "fsevents@2.3.2": true + } } From 7b5ba2fe580209bc02cc11e8f1fd53275d544635 Mon Sep 17 00:00:00 2001 From: Anthony May Date: Tue, 11 Aug 2026 11:43:40 +1000 Subject: [PATCH 2/6] Feat: workflows pagination --- ...60807_02_workflows_overview_pagination.sql | 197 ++++++++++ .../integration/workflows.routes.test.ts | 279 ++++++++++++++ backend/src/lib/__tests__/sort.test.ts | 24 +- .../lib/__tests__/workflowsOverview.test.ts | 122 +++++++ backend/src/lib/sort.ts | 24 ++ backend/src/lib/workflowsOverview.ts | 97 +++++ backend/src/routes/workflows.ts | 130 +++++++ .../components/workflows/UseWorkflowModal.tsx | 29 +- .../workflows/WorkflowDetailPage.tsx | 1 - .../app/components/workflows/WorkflowList.tsx | 230 ++++++++---- .../src/app/hooks/usePaginatedWorkflows.ts | 345 ++++++++++++++++++ frontend/src/app/lib/mikeApi.test.ts | 122 +++++++ frontend/src/app/lib/mikeApi.ts | 74 ++++ 13 files changed, 1595 insertions(+), 79 deletions(-) create mode 100644 backend/migrations/20260807_02_workflows_overview_pagination.sql create mode 100644 backend/src/__tests__/integration/workflows.routes.test.ts create mode 100644 backend/src/lib/__tests__/workflowsOverview.test.ts create mode 100644 backend/src/lib/workflowsOverview.ts create mode 100644 frontend/src/app/hooks/usePaginatedWorkflows.ts diff --git a/backend/migrations/20260807_02_workflows_overview_pagination.sql b/backend/migrations/20260807_02_workflows_overview_pagination.sql new file mode 100644 index 000000000..de5b2f014 --- /dev/null +++ b/backend/migrations/20260807_02_workflows_overview_pagination.sql @@ -0,0 +1,197 @@ +-- Migration date: 2026-08-07 + +-- Server-side pagination for the Workflows list page (/workflows), mirroring +-- the pattern built earlier the same day for Projects +-- (20260807_01_projects_overview_pagination.sql). System workflows are a +-- static, code-generated TypeScript constant (backend/src/lib/systemWorkflows.ts) +-- with zero user-data growth — they are deliberately NOT part of this RPC and +-- stay fetched/filtered client-side exactly as before. This migration only +-- paginates the one part of /workflows with real growth: a user's owned + +-- shared workflows, currently served by the 3-arg get_workflows_overview +-- defined in 20260625_01_workflow_metadata.sql, which is left completely +-- untouched — every other caller of GET /workflows (the workflow picker +-- modal, the chat slash-menu picker) keeps hitting that exact unpaginated +-- path, since the route only takes the new paginated branch when a +-- pagination-related query param is present. + +create extension if not exists pg_trgm; + +create index if not exists workflows_title_trgm_idx + on public.workflows using gin (lower(title) gin_trgm_ops); + +create index if not exists workflows_jurisdictions_gin_idx + on public.workflows using gin (jurisdictions); + +-- p_scope here is 'all' | 'owned' | 'shared' — deliberately different +-- vocabulary from Projects' 'mine'/'shared', since this RPC (unlike +-- Projects' single source of truth) never includes system workflows at all; +-- keeping the words distinct avoids conflating this RPC-level scope with the +-- UI's separate "source" filter (system/user/shared), which does include +-- system rows client-side. +create or replace function public.get_workflows_overview( + p_user_id text, + p_user_email text, + p_type 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_language text, + p_jurisdiction text +) +returns table ( + id uuid, + user_id text, + title text, + type text, + prompt_md text, + columns_config jsonb, + language text, + practice text, + jurisdictions 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.id, w.user_id::text as user_id, w.title, w.type, w.prompt_md, + w.columns_config, w.language, w.practice, w.jurisdictions, + false as is_system, w.created_at, + 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::text = p_user_id + and (p_type is null or w.type = p_type) + ), + shared as ( + select + w.id, w.user_id::text as user_id, w.title, w.type, w.prompt_md, + w.columns_config, w.language, w.practice, w.jurisdictions, + false as is_system, w.created_at, + 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::text + 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.language, vw.practice, vw.jurisdictions, vw.is_system, vw.created_at, + vw.allow_edit, vw.is_owner, vw.shared_by_name + from visible_workflows vw + where ( + coalesce(p_scope, 'all') = 'all' + or (p_scope = 'owned' and vw.sort_bucket = 0) + or (p_scope = 'shared' and vw.sort_bucket = 1) + ) + and ( + p_search_term is null + or p_search_term = '' + or lower(vw.title) like + '%' || replace(replace(replace(lower(p_search_term), '\', '\\'), '%', '\%'), '_', '\_') || '%' + escape '\' + ) + and (p_practice is null or vw.practice = p_practice) + and (p_language is null or vw.language = p_language) + and (p_jurisdiction is null or vw.jurisdictions @> array[p_jurisdiction]) + order by + case when p_sort_key = 'name' and p_sort_direction = 'asc' then lower(coalesce(vw.title, '')) else null end asc, + case when p_sort_key = 'name' and p_sort_direction = 'desc' then lower(coalesce(vw.title, '')) else null end desc, + case when p_sort_key = 'type' and p_sort_direction = 'asc' then vw.type else null end asc, + case when p_sort_key = 'type' and p_sort_direction = 'desc' then vw.type else null end desc, + case when p_sort_key = 'created' and p_sort_direction = 'asc' then vw.created_at else null end asc, + case when p_sort_key = 'created' and p_sort_direction = 'desc' then vw.created_at else null end desc, + vw.sort_bucket asc, + vw.created_at desc, + vw.id asc + limit greatest(coalesce(p_limit, 20), 1) + offset greatest(coalesce(p_offset, 0), 0); +$$; + +-- Lightweight companion for bulk "select all matching" actions (owned +-- workflows only — see the route/hook layer; shared workflows are excluded +-- from bulk-delete eligibility since only the owner can delete, and system +-- workflows never need this since all 37 are always already in memory). +-- Duplicates the owned predicate directly rather than delegating to +-- get_workflows_overview, same rationale as get_project_ids_overview: no +-- need for the shared-by-name join when the caller only wants ids. +create or replace function public.get_workflow_ids_overview( + p_user_id text, + p_user_email text, + p_type text, + p_scope text, + p_search_term text, + p_practice text, + p_language text, + p_jurisdiction text, + p_limit integer, + p_offset integer +) +returns table ( + id uuid, + user_id text +) +language sql +stable +as $$ + with owned as ( + select w.id, w.user_id::text as user_id, w.title, w.practice, w.language, w.jurisdictions, + w.created_at, 0 as sort_bucket + from public.workflows w + where w.user_id::text = p_user_id + and (p_type is null or w.type = p_type) + ), + shared as ( + select w.id, w.user_id::text as user_id, w.title, w.practice, w.language, w.jurisdictions, + w.created_at, 1 as sort_bucket + from public.workflow_shares ws + join public.workflows w + on w.id = ws.workflow_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 + from visible_workflows vw + where ( + coalesce(p_scope, 'all') = 'all' + or (p_scope = 'owned' and vw.sort_bucket = 0) + or (p_scope = 'shared' and vw.sort_bucket = 1) + ) + and ( + p_search_term is null + or p_search_term = '' + or lower(vw.title) like + '%' || replace(replace(replace(lower(p_search_term), '\', '\\'), '%', '\%'), '_', '\_') || '%' + escape '\' + ) + and (p_practice is null or vw.practice = p_practice) + and (p_language is null or vw.language = p_language) + and (p_jurisdiction is null or vw.jurisdictions @> array[p_jurisdiction]) + order by vw.sort_bucket asc, vw.created_at desc, vw.id asc + limit greatest(coalesce(p_limit, 1000), 1) + offset greatest(coalesce(p_offset, 0), 0); +$$; diff --git a/backend/src/__tests__/integration/workflows.routes.test.ts b/backend/src/__tests__/integration/workflows.routes.test.ts new file mode 100644 index 000000000..d062bfdff --- /dev/null +++ b/backend/src/__tests__/integration/workflows.routes.test.ts @@ -0,0 +1,279 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import request from "supertest"; + +// --------------------------------------------------------------------------- +// Hoisted mock fns we want to reconfigure per-test. +// --------------------------------------------------------------------------- +const { checkProjectAccess, deleteUserProjects } = vi.hoisted(() => ({ + checkProjectAccess: vi.fn(), + deleteUserProjects: vi.fn(), +})); + +// --------------------------------------------------------------------------- +// Configurable Supabase stub — same shape as projects.routes.test.ts's, since +// both exercise the same `app` import (which loads every router). +// --------------------------------------------------------------------------- +type QueryResult = { data: unknown; error: unknown }; + +let supabaseState: { + rpc: QueryResult; + tables: Record; + inserts: { table: string; payload: unknown }[]; +}; + +function resetSupabaseState() { + supabaseState = { + rpc: { data: [], error: null }, + tables: {}, + inserts: [], + }; +} +resetSupabaseState(); + +function resultForTable(table: string): QueryResult { + return supabaseState.tables[table] ?? { data: null, error: null }; +} + +function makeQuery(table: string) { + const q: Record = {}; + const chain = [ + "select", "update", "delete", "upsert", + "eq", "neq", "in", "is", "or", "not", "lt", "gt", "gte", "lte", + "filter", "order", "limit", "range", "contains", + ]; + for (const m of chain) q[m] = vi.fn(() => q); + q.insert = vi.fn((payload: unknown) => { + supabaseState.inserts.push({ table, payload }); + return q; + }); + q.single = vi.fn(() => Promise.resolve(resultForTable(table))); + q.maybeSingle = vi.fn(() => Promise.resolve(resultForTable(table))); + q.then = (resolve: (v: unknown) => unknown, reject?: (e: unknown) => unknown) => + Promise.resolve(resultForTable(table)).then(resolve, reject); + return q; +} + +function mockSupabase() { + return { + from: vi.fn((table: string) => makeQuery(table)), + rpc: vi.fn(() => Promise.resolve(supabaseState.rpc)), + auth: { + getUser: () => + Promise.resolve({ data: { user: { id: "u1" } }, error: null }), + }, + }; +} + +vi.mock("../../lib/supabase", () => ({ + createServerSupabase: vi.fn(() => mockSupabase()), +})); + +vi.mock("../../middleware/auth", () => ({ + requireAuth: ( + _req: unknown, + res: { locals: Record }, + next: () => void, + ) => { + res.locals.userId = "u1"; + res.locals.userEmail = "u1@test.local"; + next(); + }, + requireMfaIfEnrolled: (_req: unknown, _res: unknown, next: () => void) => + next(), +})); + +vi.mock("../../lib/access", () => ({ + checkProjectAccess: (...args: unknown[]) => checkProjectAccess(...args), + ensureDocAccess: vi.fn(async () => ({ ok: true, isOwner: true })), + ensureReviewAccess: vi.fn(async () => ({ ok: true, isOwner: true })), + filterAccessibleDocumentIds: vi.fn(async (ids: string[]) => ids), + listAccessibleProjectIds: vi.fn(async () => []), +})); + +vi.mock("../../lib/userDataCleanup", () => ({ + deleteUserProjects: (...args: unknown[]) => deleteUserProjects(...args), + deleteAllUserChats: vi.fn(async () => {}), + deleteAllUserTabularReviews: vi.fn(async () => {}), + deleteUserAccountData: vi.fn(async () => {}), +})); + +vi.mock("../../lib/documentVersions", () => ({ + attachActiveVersionPaths: vi.fn(async () => {}), + attachLatestVersionNumbers: vi.fn(async () => {}), + contentSha256: vi.fn(() => "0".repeat(64)), + loadActiveVersion: vi.fn(async () => null), +})); + +import { app } from "../../app"; +import { createServerSupabase } from "../../lib/supabase"; + +const AUTH = ["Authorization", "Bearer test"] as const; + +function captureRpcArgs(): { args: unknown; name: string | undefined } { + const captured: { args: unknown; name: string | undefined } = { + args: undefined, + name: undefined, + }; + vi.mocked(createServerSupabase).mockImplementationOnce(() => { + const db = mockSupabase(); + const originalRpc = db.rpc; + db.rpc = vi.fn((name: string, args: unknown) => { + captured.name = name; + captured.args = args; + return originalRpc(name, args as never); + }); + return db as unknown as ReturnType; + }); + return captured; +} + +describe("workflows.routes", () => { + beforeEach(() => { + vi.clearAllMocks(); + resetSupabaseState(); + }); + + // ── GET /workflows (overview) ───────────────────────────────────────── + describe("GET /workflows", () => { + it("returns system workflows prepended to the RPC's rows when no pagination params are present", async () => { + supabaseState.rpc = { + data: [{ id: "w1", title: "My workflow" }], + error: null, + }; + + const res = await request(app) + .get("/workflows?type=assistant") + .set(...AUTH); + + expect(res.status).toBe(200); + // System workflows (static, non-uuid ids) come first, then the + // DB row (mapped through withDatabaseWorkflow) — exact system + // count isn't pinned here since it's a generated constant, just + // that the DB row survived untouched. + expect(res.body.at(-1)).toMatchObject({ + id: "w1", + is_system: false, + metadata: { title: "My workflow" }, + }); + }); + + // Regression guard: the workflow picker modal, the chat slash-menu + // picker, and UseWorkflowModal's own independent fetch all call + // GET /workflows with no pagination params and need the exact + // legacy response shape (system workflows included) back. If this + // ever silently switched to the paginated RPC shape by default, + // those callers would start seeing a truncated, system-workflow-free + // list with no error. + it("calls the legacy 3-arg RPC shape when no pagination params are present", async () => { + const captured = captureRpcArgs(); + supabaseState.rpc = { data: [], error: null }; + + await request(app).get("/workflows?type=tabular").set(...AUTH); + + expect(captured.name).toBe("get_workflows_overview"); + expect(captured.args).toEqual({ + p_user_id: "u1", + p_user_email: "u1@test.local", + p_type: "tabular", + }); + }); + + it("calls the paginated RPC shape with every filter parsed once any pagination param is present, and omits system workflows", async () => { + const captured = captureRpcArgs(); + supabaseState.rpc = { data: [], error: null }; + + const res = await request(app) + .get( + "/workflows?limit=10&scope=owned&sort_key=name&sort_direction=asc" + + "&search=nda&practice=Litigation&language=English&jurisdiction=NSW", + ) + .set(...AUTH); + + expect(res.status).toBe(200); + expect(res.body).toEqual([]); + expect(captured.name).toBe("get_workflows_overview"); + expect(captured.args).toEqual({ + p_user_id: "u1", + p_user_email: "u1@test.local", + p_type: null, + p_scope: "owned", + p_limit: 10, + p_offset: 0, + p_search_term: "nda", + p_sort_key: "name", + p_sort_direction: "asc", + p_practice: "Litigation", + p_language: "English", + p_jurisdiction: "NSW", + }); + }); + + it("returns 500 with detail when the RPC errors", async () => { + supabaseState.rpc = { data: null, error: { message: "boom" } }; + + const res = await request(app).get("/workflows?type=assistant").set(...AUTH); + + expect(res.status).toBe(500); + expect(res.body.detail).toBe("boom"); + }); + }); + + // ── GET /workflows/system ────────────────────────────────────────────── + describe("GET /workflows/system", () => { + it("returns only system workflows, filtered by type, with no RPC call", async () => { + // Deliberately does NOT touch createServerSupabase's mock at + // all — this route makes no DB call whatsoever, so overriding + // it here (even just to assert non-invocation) would leave a + // queued mockImplementationOnce that the route never consumes, + // shifting every later test's mock by one call. The fact that + // this route resolves correctly using only the untouched + // module-level createServerSupabase mock (never called) is + // itself the proof no RPC/DB access happened. + const res = await request(app) + .get("/workflows/system?type=assistant") + .set(...AUTH); + + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + expect(res.body.length).toBeGreaterThan(0); + expect(res.body.every((w: { is_system: boolean; metadata: { type: string } }) => + w.is_system && w.metadata.type === "assistant", + )).toBe(true); + expect(createServerSupabase).not.toHaveBeenCalled(); + }); + }); + + // ── GET /workflows/ids (select-all-matching support) ────────────────── + describe("GET /workflows/ids", () => { + it("pages through the RPC until an empty page is returned", async () => { + const rpcMock = vi + .fn() + .mockResolvedValueOnce({ + data: [{ id: "w1", user_id: "u1" }], + error: null, + }) + .mockResolvedValueOnce({ data: [], error: null }); + vi.mocked(createServerSupabase).mockImplementationOnce(() => { + const db = mockSupabase(); + db.rpc = rpcMock; + return db as unknown as ReturnType; + }); + + const res = await request(app).get("/workflows/ids").set(...AUTH); + + expect(res.status).toBe(200); + expect(res.body).toEqual([{ id: "w1", user_id: "u1" }]); + expect(rpcMock).toHaveBeenCalledTimes(2); + expect(rpcMock.mock.calls[0][0]).toBe("get_workflow_ids_overview"); + }); + + it("returns 500 with detail when the RPC errors", async () => { + supabaseState.rpc = { data: null, error: { message: "boom" } }; + + const res = await request(app).get("/workflows/ids").set(...AUTH); + + expect(res.status).toBe(500); + expect(res.body.detail).toBe("boom"); + }); + }); +}); diff --git a/backend/src/lib/__tests__/sort.test.ts b/backend/src/lib/__tests__/sort.test.ts index 1020c3acc..0ee3b049f 100644 --- a/backend/src/lib/__tests__/sort.test.ts +++ b/backend/src/lib/__tests__/sort.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { parseProjectSort, parseTabularReviewSort } from "../sort"; +import { parseProjectSort, parseTabularReviewSort, parseWorkflowSort } from "../sort"; describe("parseTabularReviewSort", () => { it("accepts a supported sort key and direction", () => { @@ -38,3 +38,25 @@ describe("parseProjectSort", () => { }); }); }); + +describe("parseWorkflowSort", () => { + it("accepts a supported sort key and direction", () => { + expect(parseWorkflowSort({ key: "type", direction: "asc" })).toEqual({ + key: "type", + direction: "asc", + }); + }); + + it("accepts sort_key/sort_direction as well as key/direction", () => { + expect( + parseWorkflowSort({ sort_key: "name", sort_direction: "asc" }), + ).toEqual({ key: "name", direction: "asc" }); + }); + + it("falls back to created desc for unsupported values", () => { + expect(parseWorkflowSort({ key: "unknown", direction: "sideways" })).toEqual({ + key: "created", + direction: "desc", + }); + }); +}); diff --git a/backend/src/lib/__tests__/workflowsOverview.test.ts b/backend/src/lib/__tests__/workflowsOverview.test.ts new file mode 100644 index 000000000..0e94f2500 --- /dev/null +++ b/backend/src/lib/__tests__/workflowsOverview.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; +import { + buildWorkflowIdsOverviewRpcArgs, + buildWorkflowsOverviewRpcArgs, + parseWorkflowScope, +} from "../workflowsOverview"; + +describe("buildWorkflowsOverviewRpcArgs", () => { + it("builds the full RPC payload when the paginated signature is requested", () => { + expect( + buildWorkflowsOverviewRpcArgs({ + userId: "user-1", + userEmail: "user@example.com", + type: "tabular", + scope: "owned", + pagination: { limit: 25, offset: 10 }, + searchTerm: "merger", + sort: { key: "name", direction: "asc" }, + practice: "Litigation", + language: "English", + jurisdiction: "NSW", + }), + ).toEqual({ + p_user_id: "user-1", + p_user_email: "user@example.com", + p_type: "tabular", + p_scope: "owned", + p_limit: 25, + p_offset: 10, + p_search_term: "merger", + p_sort_key: "name", + p_sort_direction: "asc", + p_practice: "Litigation", + p_language: "English", + p_jurisdiction: "NSW", + }); + }); + + it("uses default pagination, sort, and filter values when omitted", () => { + expect( + buildWorkflowsOverviewRpcArgs({ + userId: "user-1", + userEmail: undefined, + }), + ).toEqual({ + p_user_id: "user-1", + p_user_email: null, + p_type: null, + p_scope: "all", + p_limit: 20, + p_offset: 0, + p_search_term: null, + p_sort_key: "created", + p_sort_direction: "desc", + p_practice: null, + p_language: null, + p_jurisdiction: null, + }); + }); +}); + +describe("buildWorkflowIdsOverviewRpcArgs", () => { + it("builds the ids-only RPC payload with no sort but with pagination", () => { + expect( + buildWorkflowIdsOverviewRpcArgs({ + userId: "user-1", + userEmail: "user@example.com", + type: "assistant", + scope: "shared", + searchTerm: "merger", + practice: "Litigation", + language: "English", + jurisdiction: "NSW", + pagination: { limit: 1000, offset: 2000 }, + }), + ).toEqual({ + p_user_id: "user-1", + p_user_email: "user@example.com", + p_type: "assistant", + p_scope: "shared", + p_search_term: "merger", + p_practice: "Litigation", + p_language: "English", + p_jurisdiction: "NSW", + p_limit: 1000, + p_offset: 2000, + }); + }); + + it("uses default scope and filter values when omitted", () => { + expect( + buildWorkflowIdsOverviewRpcArgs({ + userId: "user-1", + userEmail: undefined, + pagination: { limit: 1000, offset: 0 }, + }), + ).toEqual({ + p_user_id: "user-1", + p_user_email: null, + p_type: null, + p_scope: "all", + p_search_term: null, + p_practice: null, + p_language: null, + p_jurisdiction: null, + p_limit: 1000, + p_offset: 0, + }); + }); +}); + +describe("parseWorkflowScope", () => { + it("accepts supported workflow scopes", () => { + expect(parseWorkflowScope("owned")).toBe("owned"); + expect(parseWorkflowScope("shared")).toBe("shared"); + }); + + it("falls back to all for missing or unsupported scopes", () => { + expect(parseWorkflowScope(undefined)).toBe("all"); + expect(parseWorkflowScope("mine")).toBe("all"); + }); +}); diff --git a/backend/src/lib/sort.ts b/backend/src/lib/sort.ts index 452ab33db..8e637ddac 100644 --- a/backend/src/lib/sort.ts +++ b/backend/src/lib/sort.ts @@ -45,3 +45,27 @@ export function parseProjectSort(value: Record): ProjectSort { return { key, direction }; } + +export type WorkflowSortKey = "name" | "type" | "created"; +export type WorkflowSortDirection = "asc" | "desc"; + +export interface WorkflowSort { + key: WorkflowSortKey; + direction: WorkflowSortDirection; +} + +const WORKFLOW_SUPPORTED_KEYS: WorkflowSortKey[] = ["name", "type", "created"]; + +export function parseWorkflowSort(value: Record): WorkflowSort { + const rawKey = typeof value.sort_key === "string" + ? value.sort_key + : typeof value.key === "string" + ? value.key + : null; + const key = rawKey && WORKFLOW_SUPPORTED_KEYS.includes(rawKey as WorkflowSortKey) + ? (rawKey as WorkflowSortKey) + : "created"; + const direction = value.sort_direction === "asc" || value.direction === "asc" ? "asc" : "desc"; + + return { key, direction }; +} diff --git a/backend/src/lib/workflowsOverview.ts b/backend/src/lib/workflowsOverview.ts new file mode 100644 index 000000000..33b246244 --- /dev/null +++ b/backend/src/lib/workflowsOverview.ts @@ -0,0 +1,97 @@ +// Scope vocabulary here ('all'|'owned'|'shared') is deliberately distinct +// from Projects' ('all'|'mine'|'shared') — this RPC never includes system +// workflows, so its scope only ever needs to distinguish owned vs. shared +// DB rows. The UI's separate "source" filter (system/user/shared) is a +// different, client-side-only concept layered on top. +export type WorkflowScope = "all" | "owned" | "shared"; + +export function parseWorkflowScope(value: unknown): WorkflowScope { + if (value === "owned" || value === "shared") return value; + return "all"; +} + +export interface WorkflowsOverviewRpcArgs { + p_user_id: string; + p_user_email: string | null; + p_type: string | null; + p_scope: WorkflowScope; + p_limit: number; + p_offset: number; + p_search_term: string | null; + p_sort_key: string; + p_sort_direction: string; + p_practice: string | null; + p_language: string | null; + p_jurisdiction: string | null; +} + +export function buildWorkflowsOverviewRpcArgs(params: { + userId: string; + userEmail: string | undefined; + type?: string | null; + scope?: WorkflowScope; + pagination?: { limit: number; offset: number }; + searchTerm?: string | null; + sort?: { key: string; direction: string }; + practice?: string | null; + language?: string | null; + jurisdiction?: string | null; +}): WorkflowsOverviewRpcArgs { + return { + p_user_id: params.userId, + p_user_email: params.userEmail ?? null, + p_type: params.type ?? null, + p_scope: params.scope ?? "all", + p_limit: params.pagination?.limit ?? 20, + p_offset: params.pagination?.offset ?? 0, + p_search_term: params.searchTerm ?? null, + p_sort_key: params.sort?.key ?? "created", + p_sort_direction: params.sort?.direction ?? "desc", + p_practice: params.practice ?? null, + p_language: params.language ?? null, + p_jurisdiction: params.jurisdiction ?? null, + }; +} + +export interface WorkflowIdsOverviewRpcArgs { + p_user_id: string; + p_user_email: string | null; + p_type: string | null; + p_scope: WorkflowScope; + p_search_term: string | null; + p_practice: string | null; + p_language: string | null; + p_jurisdiction: string | null; + p_limit: number; + p_offset: number; +} + +// Lightweight sibling of buildWorkflowsOverviewRpcArgs for "select all +// matching" actions: no sort (order doesn't matter for a bulk id list), but +// still paginated — PostgREST enforces its own row cap on every RPC +// response, so a caller that skips pagination here will silently get a +// truncated id list back with no error. +export function buildWorkflowIdsOverviewRpcArgs(params: { + userId: string; + userEmail: string | undefined; + type?: string | null; + scope?: WorkflowScope; + searchTerm?: string | null; + practice?: string | null; + language?: string | null; + jurisdiction?: string | null; + pagination: { limit: number; offset: number }; +}): WorkflowIdsOverviewRpcArgs { + return { + p_user_id: params.userId, + p_user_email: params.userEmail ?? null, + p_type: params.type ?? null, + p_scope: params.scope ?? "all", + p_search_term: params.searchTerm ?? null, + p_practice: params.practice ?? null, + p_language: params.language ?? null, + p_jurisdiction: params.jurisdiction ?? null, + p_limit: params.pagination.limit, + p_offset: params.pagination.offset, + }; +} diff --git a/backend/src/routes/workflows.ts b/backend/src/routes/workflows.ts index 6dbeda6ea..cc6d38bf4 100644 --- a/backend/src/routes/workflows.ts +++ b/backend/src/routes/workflows.ts @@ -8,6 +8,14 @@ import { } from "../lib/systemWorkflows"; import { findMissingUserEmails } from "../lib/userLookup"; import { workflowNameFromSkillMd } from "../lib/workflowName"; +import { parsePaginationQuery } from "../lib/pagination"; +import { normalizeSearchTerm } from "../lib/search"; +import { parseWorkflowSort } from "../lib/sort"; +import { + buildWorkflowIdsOverviewRpcArgs, + buildWorkflowsOverviewRpcArgs, + parseWorkflowScope, +} from "../lib/workflowsOverview"; export const workflowsRouter = Router(); @@ -309,6 +317,28 @@ function validateOpenSourceWorkflow(workflow: WorkflowRecord): string | null { } // GET /workflows +// Pagination is opt-in via query params (limit/offset/search/sort_key or +// key/scope/practice/language/jurisdiction) — only WorkflowList.tsx sends +// them. Every other caller (the workflow picker modal, the chat slash-menu +// picker, UseWorkflowModal's own independent fetch) calls this with no +// query params at all and must keep getting the exact legacy response shape +// (system workflows prepended, full unpaginated owned+shared set) back, so +// the branch below must never default to paginating a request that didn't +// ask for it. +const WORKFLOW_PAGINATION_QUERY_KEYS = [ + "limit", + "offset", + "search", + "sort_key", + "key", + "sort_direction", + "direction", + "scope", + "practice", + "language", + "jurisdiction", +]; + workflowsRouter.get("/", requireAuth, asyncRoute(async (req, res) => { const userId = res.locals.userId as string; const userEmail = res.locals.userEmail as string | undefined; @@ -316,6 +346,36 @@ workflowsRouter.get("/", requireAuth, asyncRoute(async (req, res) => { const db = createServerSupabase(); const workflowType = typeof type === "string" && type ? type : null; + const hasPaginationParams = WORKFLOW_PAGINATION_QUERY_KEYS.some( + (key) => req.query[key] !== undefined, + ); + + if (hasPaginationParams) { + // Paginated path: DB-backed owned+shared workflows only. System + // workflows are deliberately NOT prepended here — the hybrid design + // keeps them fetched separately (GET /workflows/system) and merged + // client-side, since there are only 37 of them and they never grow + // from user data. + const rpcArgs = buildWorkflowsOverviewRpcArgs({ + userId, + userEmail, + type: workflowType, + scope: parseWorkflowScope(req.query.scope), + pagination: parsePaginationQuery(req.query as Record), + searchTerm: normalizeSearchTerm(req.query.search), + sort: parseWorkflowSort(req.query as Record), + practice: normalizeSearchTerm(req.query.practice), + language: normalizeSearchTerm(req.query.language), + jurisdiction: normalizeSearchTerm(req.query.jurisdiction), + }); + const { data, error } = await db.rpc("get_workflows_overview", rpcArgs); + if (error) return void res.status(500).json({ detail: error.message }); + const databaseWorkflows = ((data ?? []) as WorkflowRecord[]) + .filter((workflow) => !SYSTEM_WORKFLOW_IDS.has(workflow.id)) + .map(withDatabaseWorkflow); + return void res.json(databaseWorkflows); + } + const { data, error } = await db.rpc("get_workflows_overview", { p_user_id: userId, p_user_email: userEmail ?? null, @@ -335,6 +395,76 @@ workflowsRouter.get("/", requireAuth, asyncRoute(async (req, res) => { res.json([...systemWorkflows, ...databaseWorkflows]); })); +// GET /workflows/system (must come before /:workflowId routes) +// Returns just the static system-workflow list, with no RPC call — the +// hybrid pagination design keeps this bucket always fully loaded client-side +// (only 37 entries, code-generated, zero user-data growth) rather than +// trying to fold it into the paginated RPC above. +workflowsRouter.get("/system", requireAuth, asyncRoute(async (req, res) => { + const { type } = req.query as { type?: string }; + const workflowType = typeof type === "string" && type ? type : null; + const systemWorkflows = SYSTEM_WORKFLOWS.filter( + (workflow) => !workflowType || workflow.metadata.type === workflowType, + ).map(withSystemWorkflowAccess); + res.json(systemWorkflows); +})); + +// GET /workflows/ids (must come before /:workflowId routes) +// Lightweight id + owner list for every owned/shared workflow matching the +// current filters — backs "select all matching" bulk actions so the client +// doesn't have to page through full workflow payloads just to collect +// checkboxes. System workflows never need this (always fully in memory). +// +// PostgREST enforces its own row cap on every RPC response (db-max-rows), +// independent of anything this route asks for, and truncates silently +// rather than failing. So this pages through the RPC itself — server-side, +// same-datacenter round trips — until a page comes back empty, rather than +// trusting one call to return everything. +const WORKFLOW_IDS_PAGE_SIZE = 1000; +const WORKFLOW_IDS_MAX_PAGES = 200; // guards a runaway loop, not a product limit + +workflowsRouter.get("/ids", requireAuth, asyncRoute(async (req, res) => { + const userId = res.locals.userId as string; + const userEmail = res.locals.userEmail as string | undefined; + const db = createServerSupabase(); + + const { type } = req.query as { type?: string }; + const workflowType = typeof type === "string" && type ? type : null; + const searchTerm = normalizeSearchTerm(req.query.search); + const scope = parseWorkflowScope(req.query.scope); + const practice = normalizeSearchTerm(req.query.practice); + const language = normalizeSearchTerm(req.query.language); + const jurisdiction = normalizeSearchTerm(req.query.jurisdiction); + + const ids: { id: string; user_id: string }[] = []; + let offset = 0; + for (let page = 0; page < WORKFLOW_IDS_MAX_PAGES; page++) { + const rpcArgs = buildWorkflowIdsOverviewRpcArgs({ + userId, + userEmail, + type: workflowType, + scope, + searchTerm, + practice, + language, + jurisdiction, + pagination: { limit: WORKFLOW_IDS_PAGE_SIZE, offset }, + }); + const { data, error } = await db.rpc("get_workflow_ids_overview", rpcArgs); + if (error) return void res.status(500).json({ detail: error.message }); + + const rows = (data ?? []) as { id: string; user_id: string }[]; + if (rows.length === 0) break; + ids.push(...rows); + // Advance by what actually came back, not the requested page size — if + // PostgREST's cap is lower than WORKFLOW_IDS_PAGE_SIZE this still + // converges correctly instead of skipping rows. + offset += rows.length; + } + + res.json(ids); +})); + // POST /workflows workflowsRouter.post("/", requireAuth, asyncRoute(async (req, res) => { const userId = res.locals.userId as string; diff --git a/frontend/src/app/components/workflows/UseWorkflowModal.tsx b/frontend/src/app/components/workflows/UseWorkflowModal.tsx index 3475264dd..089dba628 100644 --- a/frontend/src/app/components/workflows/UseWorkflowModal.tsx +++ b/frontend/src/app/components/workflows/UseWorkflowModal.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import type { Document, Workflow } from "../shared/types"; -import { createTabularReview } from "@/app/lib/mikeApi"; +import { createTabularReview, listWorkflows } from "@/app/lib/mikeApi"; import { useRouter } from "next/navigation"; import { useDirectoryData } from "../shared/useDirectoryData"; import { FileDirectory } from "../shared/FileDirectory"; @@ -16,7 +16,6 @@ import { WorkflowPickerContent } from "./WorkflowPickerContent"; import { workflowDetailPath } from "./workflowRoutes"; interface Props { - workflows: Workflow[]; workflow: Workflow | null; onClose: () => void; skipSelect?: boolean; @@ -38,10 +37,32 @@ function SelectedWorkflowSummary({ workflow }: { workflow: Workflow }) { // --------------------------------------------------------------------------- // UseWorkflowModal // --------------------------------------------------------------------------- -export function UseWorkflowModal({ workflows, workflow, onClose, skipSelect = false }: Props) { +export function UseWorkflowModal({ workflow, onClose, skipSelect = false }: Props) { const [screen, setScreen] = useState<"select" | "details" | "documents">("select"); const [selected, setSelected] = useState(workflow); const [listSearch, setListSearch] = useState(""); + // Self-fetched rather than received from the parent's (now paginated, + // partial) workflow list — mirrors WorkflowPickerModal.tsx's existing + // independent fetch pattern. Merges both types since this modal's + // "switch workflow" screen supports any workflow, unlike + // WorkflowPickerModal which is always scoped to one type. + const [pickerWorkflows, setPickerWorkflows] = useState([]); + + useEffect(() => { + if (!workflow) return; + let cancelled = false; + Promise.all([listWorkflows("assistant"), listWorkflows("tabular")]) + .then(([assistant, tabular]) => { + if (!cancelled) setPickerWorkflows([...assistant, ...tabular]); + }) + .catch(() => { + if (!cancelled) setPickerWorkflows([]); + }); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [workflow?.id]); // Configure screen state const [inProject, setInProject] = useState(false); @@ -259,7 +280,7 @@ export function UseWorkflowModal({ workflows, workflow, onClose, skipSelect = fa {/* ── SELECT SCREEN ── */} {screen === "select" && ( { if (next) setSelected(next); diff --git a/frontend/src/app/components/workflows/WorkflowDetailPage.tsx b/frontend/src/app/components/workflows/WorkflowDetailPage.tsx index 15827db98..3289099a1 100644 --- a/frontend/src/app/components/workflows/WorkflowDetailPage.tsx +++ b/frontend/src/app/components/workflows/WorkflowDetailPage.tsx @@ -481,7 +481,6 @@ export function WorkflowDetailPage({ id, workflowType }: Props) { ]} /> setUseOpen(false)} skipSelect diff --git a/frontend/src/app/components/workflows/WorkflowList.tsx b/frontend/src/app/components/workflows/WorkflowList.tsx index 944a64b64..9bc255db9 100644 --- a/frontend/src/app/components/workflows/WorkflowList.tsx +++ b/frontend/src/app/components/workflows/WorkflowList.tsx @@ -14,15 +14,19 @@ import { hideWorkflow, unhideWorkflow, } from "@/app/lib/mikeApi"; +import { useDebouncedValue } from "@/app/hooks/useDebouncedValue"; +import { usePaginatedWorkflows } from "@/app/hooks/usePaginatedWorkflows"; import type { Workflow } from "../shared/types"; import { UseWorkflowModal } from "./UseWorkflowModal"; import { NewWorkflowModal } from "./NewWorkflowModal"; import { TableToolbar } from "../shared/TableToolbar"; import { RowActionMenuItems, RowActions } from "../shared/RowActions"; +import { OwnerOnlyPopup } from "@/app/components/popups/OwnerOnlyPopup"; import { MikeIcon } from "@/app/components/chat/mike-icon"; import { PageHeader } from "@/app/components/shared/PageHeader"; import { PillButton } from "@/app/components/ui/pill-button"; import { TabPillButton } from "@/app/components/ui/tab-pill-button"; +import { TableLoadMoreRow } from "@/app/components/shared/TableLoadMoreRow"; import { LiquidDropdownButton, LiquidDropdownSurface, @@ -66,23 +70,15 @@ const SORT_OPTIONS: TableFilterOption[] = [ { value: "desc", label: "Descending" }, ]; -const isDev = process.env.NODE_ENV !== "production"; -const devLog = (...args: Parameters) => { - if (isDev) console.log(...args); -}; - export function WorkflowList() { const router = useRouter(); const searchParams = useSearchParams(); - const [workflows, setWorkflows] = useState([]); - const [loading, setLoading] = useState(true); const [selected, setSelected] = useState(null); const [newModalOpen, setNewModalOpen] = useState(false); const [editingWorkflow, setEditingWorkflow] = useState( null, ); const [hiddenSystemIds, setHiddenSystemIds] = useState([]); - const [selectedIds, setSelectedIds] = useState([]); const [actionsOpen, setActionsOpen] = useState(false); const [activeTab, setActiveTab] = useState("all"); const [practiceFilter, setPracticeFilter] = useState(null); @@ -97,47 +93,57 @@ export function WorkflowList() { direction: TableSortDirection; } | null>(null); const [search, setSearch] = useState(""); + const [ownerOnlyAction, setOwnerOnlyAction] = useState(null); + // A separate, always-unpaginated fetch used only to enumerate the + // practice/jurisdiction/language filter dropdown options — the paginated + // owned+shared rows below won't necessarily include every distinct + // value once there's more than one page. + const [filterOptionsWorkflows, setFilterOptionsWorkflows] = useState< + Workflow[] + >([]); const actionsRef = useRef(null); const previewEmptyStates = searchParams.get("emptyStates") === "1"; + const debouncedSearch = useDebouncedValue(search, 250); + + const { + systemWorkflows, + dbWorkflows, + setDbWorkflows, + loading, + loadingMore, + hasMore, + loadMoreError, + loadMore, + selectedWorkflowIds: selectedIds, + setSelectedWorkflowIds: setSelectedIds, + selectAllMatchingOwned, + } = usePaginatedWorkflows({ + type: activeTab === "assistant" || activeTab === "tabular" ? activeTab : undefined, + search: debouncedSearch, + selectionKey: search, + scope: sourceFilter === "shared" ? "shared" : sourceFilter === "user" ? "owned" : "all", + practiceFilter, + languageFilter, + jurisdictionFilter, + sort, + }); const effectiveLoading = loading && !previewEmptyStates; - const visibleWorkflows = previewEmptyStates ? [] : workflows; useEffect(() => { - Promise.all([ - listWorkflows("assistant"), - listWorkflows("tabular"), - listHiddenWorkflows(), - ]) - .then(([assistant, tabular, hidden]) => { - devLog("[workflows/ui:list] loaded", { - assistantCount: assistant.length, - tabularCount: tabular.length, - hiddenCount: hidden.length, - assistantSample: assistant.slice(0, 5).map((workflow) => ({ - id: workflow.id, - title: workflow.metadata.title, - type: workflow.metadata.type, - user_id: workflow.user_id, - is_system: workflow.is_system, - is_owner: workflow.is_owner, - })), - tabularSample: tabular.slice(0, 5).map((workflow) => ({ - id: workflow.id, - title: workflow.metadata.title, - type: workflow.metadata.type, - user_id: workflow.user_id, - is_system: workflow.is_system, - is_owner: workflow.is_owner, - })), - }); - setWorkflows([...assistant, ...tabular]); - setHiddenSystemIds(hidden); - }) - .catch((error) => { - devLog("[workflows/ui:list] failed; showing no workflows", error); - setWorkflows([]); - }) - .finally(() => setLoading(false)); + listHiddenWorkflows() + .then(setHiddenSystemIds) + .catch(() => setHiddenSystemIds([])); + }, []); + + useEffect(() => { + Promise.all([listWorkflows("assistant"), listWorkflows("tabular")]) + .then(([assistant, tabular]) => + setFilterOptionsWorkflows([...assistant, ...tabular]), + ) + .catch(() => { + // Filter option lists degrade to "no options" — not worth a + // user-facing error for purely cosmetic dropdowns. + }); }, []); useEffect(() => { @@ -153,22 +159,19 @@ export function WorkflowList() { return () => document.removeEventListener("mousedown", handleClick); }, [actionsOpen]); - const systemWorkflows = visibleWorkflows.filter((wf) => wf.is_system); - const userWorkflows = visibleWorkflows.filter( - (wf) => !wf.is_system && wf.is_owner !== false, - ); - const sharedWorkflows = visibleWorkflows.filter( - (wf) => !wf.is_system && wf.is_owner === false, - ); - const hiddenSystem = systemWorkflows.filter((wf) => + const visibleDbWorkflows = previewEmptyStates ? [] : dbWorkflows; + const visibleSystemAll = previewEmptyStates ? [] : systemWorkflows; + + const userWorkflows = visibleDbWorkflows.filter((wf) => wf.is_owner !== false); + const sharedWorkflows = visibleDbWorkflows.filter((wf) => wf.is_owner === false); + const hiddenSystem = visibleSystemAll.filter((wf) => hiddenSystemIds.includes(wf.id), ); - const visibleSystem = systemWorkflows.filter( + const visibleSystem = visibleSystemAll.filter( (wf) => !hiddenSystemIds.includes(wf.id), ); const systemRows = [...visibleSystem, ...hiddenSystem]; const activeRows = [...userWorkflows, ...sharedWorkflows, ...visibleSystem]; - const allRows = [...userWorkflows, ...sharedWorkflows, ...systemRows]; const tabRows = activeTab === "all" ? activeRows @@ -181,25 +184,59 @@ export function WorkflowList() { : tabRows.filter( (workflow) => getWorkflowSource(workflow) === sourceFilter, ); + + // Parallel derivation over the always-complete filterOptionsWorkflows + // fetch, purely to enumerate dropdown options — mirrors the render + // pipeline above exactly (same tab/source bucketing rules) but never + // sees a partial page, so options stay complete regardless of how many + // pages of owned/shared workflows have been loaded. + const optSystem = filterOptionsWorkflows.filter((wf) => wf.is_system); + const optUser = filterOptionsWorkflows.filter( + (wf) => !wf.is_system && wf.is_owner !== false, + ); + const optShared = filterOptionsWorkflows.filter( + (wf) => !wf.is_system && wf.is_owner === false, + ); + const optVisibleSystem = optSystem.filter( + (wf) => !hiddenSystemIds.includes(wf.id), + ); + const optHiddenSystem = optSystem.filter((wf) => + hiddenSystemIds.includes(wf.id), + ); + const optActiveRows = [...optUser, ...optShared, ...optVisibleSystem]; + const optAllRows = [...optUser, ...optShared, ...optVisibleSystem, ...optHiddenSystem]; + const optTabRows = + activeTab === "all" + ? optActiveRows + : activeTab === "system" + ? [...optVisibleSystem, ...optHiddenSystem] + : optActiveRows.filter((workflow) => workflow.metadata.type === activeTab); + const optSourceRows = + sourceFilter === null + ? optTabRows + : optTabRows.filter( + (workflow) => getWorkflowSource(workflow) === sourceFilter, + ); const practices = Array.from( new Set( - sourceRows.map((wf) => wf.metadata.practice).filter((p): p is string => !!p), + optSourceRows.map((wf) => wf.metadata.practice).filter((p): p is string => !!p), ), ).sort(); const jurisdictions = Array.from( new Set( - allRows + optAllRows .flatMap((wf) => wf.metadata.jurisdictions ?? []) .filter((jurisdiction): jurisdiction is string => !!jurisdiction), ), ).sort(); const languages = Array.from( new Set( - allRows + optAllRows .map((wf) => wf.metadata.language) .filter((language): language is string => !!language), ), ).sort(); + const q = search.toLowerCase(); const filtered = sourceRows .filter((wf) => !practiceFilter || wf.metadata.practice === practiceFilter) @@ -219,8 +256,19 @@ export function WorkflowList() { !allSelected && filtered.some((wf) => selectedIds.includes(wf.id)); function toggleAll() { - if (allSelected) setSelectedIds([]); - else setSelectedIds(filtered.map((wf) => wf.id)); + if (allSelected) { + setSelectedIds([]); + return; + } + // If everything currently displayable is already fully in memory + // (a pure-system view, or every DB page has already been loaded), + // just select what's visible — no network round-trip needed. + const allSystemView = filtered.length > 0 && filtered.every((wf) => wf.is_system); + if (allSystemView || !hasMore) { + setSelectedIds(filtered.map((wf) => wf.id)); + } else { + void selectAllMatchingOwned(); + } } function toggleOne(id: string) { @@ -285,10 +333,21 @@ export function WorkflowList() { const ids = [...selectedIds]; setActionsOpen(false); setSelectedIds([]); - const systemIds = ids.filter( - (id) => workflows.find((workflow) => workflow.id === id)?.is_system, + const systemIds = ids.filter((id) => + systemWorkflows.some((workflow) => workflow.id === id), + ); + const nonSystemIds = ids.filter((id) => !systemIds.includes(id)); + // Any non-system id still loaded in dbWorkflows carries its own + // is_owner flag — trust it directly. Any id NOT found there can only + // have arrived via selectAllMatchingOwned (scope-restricted to + // owned rows), so it's safe to treat as owned without a lookup. + const ownedIds = nonSystemIds.filter((id) => { + const loaded = dbWorkflows.find((workflow) => workflow.id === id); + return loaded ? loaded.is_owner !== false : true; + }); + const blockedSharedIds = nonSystemIds.filter( + (id) => !ownedIds.includes(id), ); - const customIds = ids.filter((id) => !systemIds.includes(id)); if (systemIds.length > 0) { setHiddenSystemIds((prev) => [ ...prev, @@ -298,12 +357,17 @@ export function WorkflowList() { systemIds.map((id) => hideWorkflow(id).catch(() => {})), ); } - if (customIds.length > 0) { + if (ownedIds.length > 0) { await Promise.all( - customIds.map((id) => deleteWorkflow(id).catch(() => {})), + ownedIds.map((id) => deleteWorkflow(id).catch(() => {})), + ); + setDbWorkflows((prev) => + prev.filter((w) => !ownedIds.includes(w.id)), ); - setWorkflows((prev) => - prev.filter((w) => !customIds.includes(w.id)), + } + if (blockedSharedIds.length > 0) { + setOwnerOnlyAction( + `delete ${blockedSharedIds.length} of the selected workflows — only the workflow owner can delete a workflow`, ); } } @@ -409,8 +473,8 @@ export function WorkflowList() { const selectedHiddenSystemIds = selectedIds.filter((id) => hiddenSystemIds.includes(id), ); - const selectedSystemIds = selectedIds.filter( - (id) => workflows.find((workflow) => workflow.id === id)?.is_system, + const selectedSystemIds = selectedIds.filter((id) => + systemWorkflows.some((workflow) => workflow.id === id), ); const selectedOnlySystem = selectedIds.length > 0 && selectedIds.length === selectedSystemIds.length; @@ -483,6 +547,13 @@ export function WorkflowList() { {/* Table */} { + if (loading || loadingMore || !hasMore) return; + const el = event.currentTarget; + const distanceToBottom = + el.scrollHeight - el.scrollTop - el.clientHeight; + if (distanceToBottom < 200) void loadMore(); + }} header={ @@ -668,7 +739,7 @@ export function WorkflowList() { await deleteWorkflow( wf.id, ); - setWorkflows((prev) => + setDbWorkflows((prev) => prev.filter( (w) => w.id !== @@ -783,7 +854,7 @@ export function WorkflowList() { } onDelete={async () => { await deleteWorkflow(wf.id); - setWorkflows((prev) => + setDbWorkflows((prev) => prev.filter( (w) => w.id !== wf.id, ), @@ -797,10 +868,17 @@ export function WorkflowList() { })} )} + void loadMore()} + /> setSelected(null)} /> @@ -809,7 +887,7 @@ export function WorkflowList() { open={newModalOpen} onClose={() => setNewModalOpen(false)} onCreated={(wf) => { - setWorkflows((prev) => [wf, ...prev]); + setDbWorkflows((prev) => [wf, ...prev]); setNewModalOpen(false); router.push(workflowDetailPath(wf)); }} @@ -821,7 +899,7 @@ export function WorkflowList() { onCreated={() => undefined} editWorkflow={editingWorkflow ?? undefined} onUpdated={(updated) => { - setWorkflows((prev) => + setDbWorkflows((prev) => prev.map((workflow) => workflow.id === updated.id ? { ...workflow, ...updated } @@ -831,6 +909,12 @@ export function WorkflowList() { setEditingWorkflow(null); }} /> + + setOwnerOnlyAction(null)} + /> ); } diff --git a/frontend/src/app/hooks/usePaginatedWorkflows.ts b/frontend/src/app/hooks/usePaginatedWorkflows.ts new file mode 100644 index 000000000..5e48679c8 --- /dev/null +++ b/frontend/src/app/hooks/usePaginatedWorkflows.ts @@ -0,0 +1,345 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type Dispatch, + type SetStateAction, +} from "react"; +import type { Workflow } from "@/app/components/shared/types"; +import { + listSystemWorkflows, + listWorkflowIds, + listWorkflowsPage, +} from "@/app/lib/mikeApi"; + +export type WorkflowSortKey = "name" | "type" | "created"; +export type WorkflowSortDirection = "asc" | "desc"; +export type WorkflowScope = "all" | "owned" | "shared"; +type WorkflowTypeFilter = "assistant" | "tabular" | undefined; + +const PAGE_SIZE = 30; + +function pageRows(rows: Workflow[]) { + return { + hasMore: rows.length > PAGE_SIZE, + rows: rows.slice(0, PAGE_SIZE), + }; +} + +function asError(value: unknown) { + return value instanceof Error + ? value + : new Error("Unable to load workflows"); +} + +/** + * Server-side-paginated owned+shared workflows, plus the always-eager, + * always-fully-loaded static system-workflow bucket (37 entries, no + * user-data growth — never paginated, fetched once). Cloned from + * usePaginatedProjects/usePaginatedTabularReviews, adapted for this hybrid + * two-source merge: the consumer is expected to concatenate `systemWorkflows` + * (filtered/sorted entirely client-side, as before) with `dbWorkflows` (the + * paginated, server-filtered/sorted portion) for display. + * + * Selection is deliberately NOT one uniform "select all matching" the way + * Projects/Tabular Reviews have it: bulk Delete only ever applies to owned + * DB rows (needs `selectAllMatchingOwned`, which may hit the network), + * while bulk Hide/Unhide only ever applies to system rows (always fully in + * memory already — the consumer can select those directly via + * `setSelectedWorkflowIds` with no hook involvement needed). + */ +export function usePaginatedWorkflows(options: { + type?: WorkflowTypeFilter; + search?: string; + selectionKey?: string; + scope?: WorkflowScope; + practiceFilter?: string | null; + languageFilter?: string | null; + jurisdictionFilter?: string | null; + sort?: { + key: WorkflowSortKey; + direction: WorkflowSortDirection; + } | null; +}) { + const [systemWorkflows, setSystemWorkflows] = useState([]); + const [systemLoading, setSystemLoading] = useState(true); + + const [dbWorkflows, setDbWorkflows] = useState([]); + const [dbLoading, setDbLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [hasMore, setHasMore] = useState(true); + const [error, setError] = useState(null); + const [loadMoreError, setLoadMoreError] = useState(null); + const [selectingAllRequest, setSelectingAllRequest] = useState(false); + const [retryVersion, setRetryVersion] = useState(0); + const requestVersionRef = useRef(0); + const loadingMoreRef = useRef(false); + const loadMoreControllerRef = useRef(null); + + const { + type, + search, + selectionKey, + scope = "all", + practiceFilter = null, + languageFilter = null, + jurisdictionFilter = null, + sort, + } = options; + const sortKey = sort?.key; + const sortDirection = sort?.direction; + const selectionQueryPending = + selectionKey !== undefined && selectionKey !== search; + const queryKey = JSON.stringify([ + type ?? null, + selectionKey ?? null, + search ?? null, + scope, + practiceFilter, + languageFilter, + jurisdictionFilter, + sortKey ?? null, + sortDirection ?? null, + ]); + const [selection, setSelection] = useState<{ + queryKey: string; + ids: string[]; + }>({ queryKey, ids: [] }); + const selectedWorkflowIds = + selection.queryKey === queryKey ? selection.ids : []; + const setSelectedWorkflowIds: Dispatch> = + useCallback( + (value) => { + setSelection((current) => { + const currentIds = + current.queryKey === queryKey ? current.ids : []; + const ids = + typeof value === "function" ? value(currentIds) : value; + return { queryKey, ids }; + }); + }, + [queryKey], + ); + // System workflows: fetched once, never re-fetched on filter change — + // there are only 37 of them and they never grow from user data. + useEffect(() => { + let cancelled = false; + void listSystemWorkflows() + .then((rows) => { + if (!cancelled) setSystemWorkflows(rows); + }) + .catch((error) => { + if (cancelled) return; + console.error("[workflows] failed to load system workflows", error); + setSystemWorkflows([]); + }) + .finally(() => { + if (!cancelled) setSystemLoading(false); + }); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + const requestVersion = ++requestVersionRef.current; + const controller = new AbortController(); + loadMoreControllerRef.current?.abort(); + loadMoreControllerRef.current = null; + loadingMoreRef.current = false; + setDbWorkflows([]); + setHasMore(true); + setLoadingMore(false); + setError(null); + setLoadMoreError(null); + setDbLoading(true); + + void listWorkflowsPage({ + limit: PAGE_SIZE + 1, + type, + search: search || undefined, + scope, + practice: practiceFilter || undefined, + language: languageFilter || undefined, + jurisdiction: jurisdictionFilter || undefined, + sortKey, + sortDirection, + signal: controller.signal, + }) + .then((rows) => { + if (requestVersion !== requestVersionRef.current) return; + const firstPage = pageRows(rows); + setDbWorkflows(firstPage.rows); + setHasMore(firstPage.hasMore); + }) + .catch((error) => { + if ( + controller.signal.aborted || + requestVersion !== requestVersionRef.current + ) + return; + console.error("[workflows] failed to load", error); + setError(asError(error)); + setHasMore(false); + }) + .finally(() => { + if ( + !controller.signal.aborted && + requestVersion === requestVersionRef.current + ) + setDbLoading(false); + }); + return () => { + controller.abort(); + loadMoreControllerRef.current?.abort(); + }; + }, [ + type, + retryVersion, + scope, + practiceFilter, + languageFilter, + jurisdictionFilter, + search, + sortDirection, + sortKey, + ]); + + const loadMore = useCallback(async () => { + if (dbLoading || loadingMoreRef.current || !hasMore) return; + + const requestVersion = requestVersionRef.current; + const offset = dbWorkflows.length; + const controller = new AbortController(); + loadMoreControllerRef.current?.abort(); + loadMoreControllerRef.current = controller; + loadingMoreRef.current = true; + setLoadingMore(true); + setLoadMoreError(null); + + try { + const rows = await listWorkflowsPage({ + limit: PAGE_SIZE + 1, + offset, + type, + search: search || undefined, + scope, + practice: practiceFilter || undefined, + language: languageFilter || undefined, + jurisdiction: jurisdictionFilter || undefined, + sortKey, + sortDirection, + signal: controller.signal, + }); + if (requestVersion !== requestVersionRef.current) return; + + const nextPage = pageRows(rows); + setDbWorkflows((current) => { + const existingIds = new Set(current.map((workflow) => workflow.id)); + return [ + ...current, + ...nextPage.rows.filter( + (workflow) => !existingIds.has(workflow.id), + ), + ]; + }); + setHasMore(nextPage.hasMore); + } catch (error) { + if ( + !controller.signal.aborted && + requestVersion === requestVersionRef.current + ) { + console.error("[workflows] failed to load more", error); + setLoadMoreError(asError(error)); + } + } finally { + if ( + requestVersion === requestVersionRef.current && + loadMoreControllerRef.current === controller + ) { + loadMoreControllerRef.current = null; + loadingMoreRef.current = false; + setLoadingMore(false); + } + } + }, [ + dbLoading, + hasMore, + type, + dbWorkflows.length, + scope, + practiceFilter, + languageFilter, + jurisdictionFilter, + search, + sortDirection, + sortKey, + ]); + const retry = useCallback(() => { + setRetryVersion((current) => current + 1); + }, []); + + // Selects every OWNED workflow matching the current filters, not just + // the page(s) already loaded. Shared workflows are deliberately excluded + // — bulk delete is owner-only, so "select all" for that action should + // never pull in workflows the user can't actually delete. Fetches only + // ids (+ owner), not full workflow payloads. + const selectAllMatchingOwned = useCallback(async () => { + if (selectionQueryPending) return; + + if (!hasMore) { + setSelectedWorkflowIds( + dbWorkflows + .filter((workflow) => workflow.is_owner !== false) + .map((workflow) => workflow.id), + ); + return; + } + + const requestVersion = requestVersionRef.current; + setSelectingAllRequest(true); + try { + const rows = await listWorkflowIds({ + type, + search: search || undefined, + scope: "owned", + practice: practiceFilter || undefined, + language: languageFilter || undefined, + jurisdiction: jurisdictionFilter || undefined, + }); + if (requestVersion !== requestVersionRef.current) return; + + setSelectedWorkflowIds(rows.map((row) => row.id)); + } finally { + setSelectingAllRequest(false); + } + }, [ + hasMore, + type, + dbWorkflows, + practiceFilter, + languageFilter, + jurisdictionFilter, + search, + selectionQueryPending, + setSelectedWorkflowIds, + ]); + + return { + systemWorkflows, + dbWorkflows, + setDbWorkflows, + loading: systemLoading || dbLoading, + loadingMore, + hasMore, + error, + loadMoreError, + loadMore, + retry, + selectedWorkflowIds, + setSelectedWorkflowIds, + selectAllMatchingOwned, + selectingAll: selectingAllRequest || selectionQueryPending, + }; +} diff --git a/frontend/src/app/lib/mikeApi.test.ts b/frontend/src/app/lib/mikeApi.test.ts index 58ce5a070..4ff2822a7 100644 --- a/frontend/src/app/lib/mikeApi.test.ts +++ b/frontend/src/app/lib/mikeApi.test.ts @@ -76,10 +76,13 @@ import { listProjects, listProjectsPage, listStandaloneDocuments, + listSystemWorkflows, listTabularReviewIds, listTabularReviews, + listWorkflowIds, listWorkflowShares, listWorkflows, + listWorkflowsPage, lookupUserByEmail, mapTRMessages, moveDocumentToFolder, @@ -893,6 +896,125 @@ describe("listProjectIds", () => { }); }); +describe("listWorkflows", () => { + // Regression guard: the workflow picker modal, the chat slash-menu + // picker, and UseWorkflowModal's own independent fetch all rely on this + // function sending zero pagination-related query params — the backend + // route decides whether to paginate purely on their presence. If this + // ever grew a stray param, those callers would start getting a + // truncated, system-workflow-free list back with no error. + it("sends only the type param, never a pagination knob", async () => { + fetchMock.mockResolvedValue(jsonResponse([])); + + await listWorkflows("assistant"); + + expect(lastFetchCall().url).toBe( + "http://localhost:3001/workflows?type=assistant", + ); + }); +}); + +describe("listWorkflowsPage", () => { + it("requests the bare collection when no filters are given", async () => { + fetchMock.mockResolvedValue(jsonResponse([])); + + await listWorkflowsPage(); + + const { url, init } = lastFetchCall(); + expect(url).toBe("http://localhost:3001/workflows"); + expect(init.signal).toBeUndefined(); + }); + + it("serializes every pagination knob and forwards the abort signal", async () => { + fetchMock.mockResolvedValue(jsonResponse([])); + const controller = new AbortController(); + + await listWorkflowsPage({ + limit: 30, + offset: 60, + search: "nda", + sortKey: "name", + sortDirection: "desc", + scope: "owned", + type: "assistant", + practice: "Litigation", + language: "English", + jurisdiction: "NSW", + signal: controller.signal, + }); + + const { url, init } = lastFetchCall(); + expect(url).toBe( + "http://localhost:3001/workflows" + + "?type=assistant&limit=30&offset=60&search=nda" + + "&sort_key=name&sort_direction=desc&scope=owned" + + "&practice=Litigation&language=English&jurisdiction=NSW", + ); + expect(init.signal).toBe(controller.signal); + }); + + it('omits the scope param for "all" — the backend default', async () => { + fetchMock.mockResolvedValue(jsonResponse([])); + + await listWorkflowsPage({ scope: "all", limit: 10 }); + + expect(lastFetchCall().url).toBe( + "http://localhost:3001/workflows?limit=10", + ); + }); +}); + +describe("listWorkflowIds", () => { + it("requests the bare id list when no filters are given", async () => { + fetchMock.mockResolvedValue(jsonResponse([])); + + await listWorkflowIds(); + + expect(lastFetchCall().url).toBe("http://localhost:3001/workflows/ids"); + }); + + it("scopes ids by every active filter so select-all matches the visible list", async () => { + fetchMock.mockResolvedValue(jsonResponse([{ id: "w1", user_id: "u1" }])); + + const ids = await listWorkflowIds({ + search: "nda", + scope: "owned", + type: "tabular", + practice: "Litigation", + language: "English", + jurisdiction: "NSW", + }); + + expect(ids).toEqual([{ id: "w1", user_id: "u1" }]); + expect(lastFetchCall().url).toBe( + "http://localhost:3001/workflows/ids?type=tabular&search=nda" + + "&scope=owned&practice=Litigation&language=English&jurisdiction=NSW", + ); + }); +}); + +describe("listSystemWorkflows", () => { + it("requests the unfiltered system list when no type is given", async () => { + fetchMock.mockResolvedValue(jsonResponse([])); + + await listSystemWorkflows(); + + expect(lastFetchCall().url).toBe( + "http://localhost:3001/workflows/system", + ); + }); + + it("appends the type filter when given", async () => { + fetchMock.mockResolvedValue(jsonResponse([])); + + await listSystemWorkflows("tabular"); + + expect(lastFetchCall().url).toBe( + "http://localhost:3001/workflows/system?type=tabular", + ); + }); +}); + describe("tabular review CRUD", () => { it("createTabularReview posts the folder grouping mode through unchanged", async () => { fetchMock.mockResolvedValue(jsonResponse({ id: "r1" })); diff --git a/frontend/src/app/lib/mikeApi.ts b/frontend/src/app/lib/mikeApi.ts index c56fc21f0..396884f34 100644 --- a/frontend/src/app/lib/mikeApi.ts +++ b/frontend/src/app/lib/mikeApi.ts @@ -1415,6 +1415,80 @@ export async function listWorkflows( return apiRequest(`/workflows?type=${type}`); } +// Paginated sibling of listWorkflows() used only by WorkflowList.tsx. +// Deliberately a separate function, not an overload — the backend route +// decides whether to paginate based on whether any of these query params +// are present at all, so listWorkflows() must keep sending none of them +// (every other caller — the workflow picker modal, the chat slash-menu +// picker, UseWorkflowModal's own independent fetch — needs the exact legacy +// response shape, system workflows included). Returns DB-backed rows only +// (always is_system: false) — system workflows come from listSystemWorkflows. +export async function listWorkflowsPage(pagination?: { + limit?: number; + offset?: number; + search?: string; + sortKey?: string; + sortDirection?: "asc" | "desc"; + scope?: "all" | "owned" | "shared"; + type?: WorkflowType; + practice?: string; + language?: string; + jurisdiction?: string; + signal?: AbortSignal; +}): Promise { + const params = new URLSearchParams(); + if (pagination?.type) params.set("type", pagination.type); + if (pagination?.limit) params.set("limit", String(pagination.limit)); + if (pagination?.offset) params.set("offset", String(pagination.offset)); + if (pagination?.search) params.set("search", pagination.search); + if (pagination?.sortKey) params.set("sort_key", pagination.sortKey); + if (pagination?.sortDirection) params.set("sort_direction", pagination.sortDirection); + if (pagination?.scope && pagination.scope !== "all") + params.set("scope", pagination.scope); + if (pagination?.practice) params.set("practice", pagination.practice); + if (pagination?.language) params.set("language", pagination.language); + if (pagination?.jurisdiction) params.set("jurisdiction", pagination.jurisdiction); + + const qs = params.toString() ? `?${params.toString()}` : ""; + return apiRequest(`/workflows${qs}`, { + signal: pagination?.signal, + }); +} + +export async function listWorkflowIds(options?: { + search?: string; + scope?: "all" | "owned" | "shared"; + type?: WorkflowType; + practice?: string; + language?: string; + jurisdiction?: string; + signal?: AbortSignal; +}): Promise<{ id: string; user_id: string }[]> { + const params = new URLSearchParams(); + if (options?.type) params.set("type", options.type); + if (options?.search) params.set("search", options.search); + if (options?.scope && options.scope !== "all") params.set("scope", options.scope); + if (options?.practice) params.set("practice", options.practice); + if (options?.language) params.set("language", options.language); + if (options?.jurisdiction) params.set("jurisdiction", options.jurisdiction); + + const qs = params.toString() ? `?${params.toString()}` : ""; + return apiRequest<{ id: string; user_id: string }[]>( + `/workflows/ids${qs}`, + { signal: options?.signal }, + ); +} + +// Always-unpaginated: the static, code-generated system-workflow list (37 +// entries, zero user-data growth). Fetched once by usePaginatedWorkflows and +// kept fully in memory rather than folded into the paginated RPC above. +export async function listSystemWorkflows( + type?: WorkflowType, +): Promise { + const qs = type ? `?type=${type}` : ""; + return apiRequest(`/workflows/system${qs}`); +} + export async function getWorkflow(workflowId: string): Promise { return apiRequest(`/workflows/${workflowId}`); } From 86aeccd9c097e6710ad6ee9a406d01772222f7ff Mon Sep 17 00:00:00 2001 From: Anthony May Date: Tue, 11 Aug 2026 13:28:25 +1000 Subject: [PATCH 3/6] Feat: pagination for library/files --- backend/src/routes/library.ts | 72 +++++++--- .../src/app/components/documents/DocTable.tsx | 52 ++++++- .../components/library/LibraryWorkspace.tsx | 133 +++++++++++++++++- frontend/src/app/lib/mikeApi.test.ts | 6 + frontend/src/app/lib/mikeApi.ts | 9 ++ 5 files changed, 246 insertions(+), 26 deletions(-) diff --git a/backend/src/routes/library.ts b/backend/src/routes/library.ts index d46bf7174..1bbf53370 100644 --- a/backend/src/routes/library.ts +++ b/backend/src/routes/library.ts @@ -91,35 +91,44 @@ async function deleteLibraryDocumentsAndVersionFiles( return error ?? null; } -// GET /library/:kind -libraryRouter.get("/:kind", requireAuth, async (req, res) => { - const userId = res.locals.userId as string; - const kind = normalizeLibraryKind(req.params.kind); - if (!kind) return void res.status(404).json({ detail: "Library not found" }); - - const db = createServerSupabase(); +async function loadLibraryLevel( + db: ReturnType, + userId: string, + kind: LibraryKind, + parentFolderId: string | null, +) { let documentsQuery = db .from("documents") .select("*") .eq("user_id", userId) .is("project_id", null); + documentsQuery = + parentFolderId === null + ? documentsQuery.is("library_folder_id", null) + : documentsQuery.eq("library_folder_id", parentFolderId); documentsQuery = kind === "file" ? documentsQuery.or("library_kind.eq.file,library_kind.is.null") : documentsQuery.eq("library_kind", kind); + + let foldersQuery = db + .from("library_folders") + .select("*") + .eq("user_id", userId) + .eq("library_kind", kind); + foldersQuery = + parentFolderId === null + ? foldersQuery.is("parent_folder_id", null) + : foldersQuery.eq("parent_folder_id", parentFolderId); + const [{ data: docs, error: docsError }, { data: folders, error: foldersError }] = await Promise.all([ documentsQuery.order("created_at", { ascending: true }), - db - .from("library_folders") - .select("*") - .eq("user_id", userId) - .eq("library_kind", kind) - .order("created_at", { ascending: true }), + foldersQuery.order("created_at", { ascending: true }), ]); - if (docsError) return void res.status(500).json({ detail: docsError.message }); + if (docsError) return { error: docsError.message, documents: [], folders: [] }; if (foldersError) - return void res.status(500).json({ detail: foldersError.message }); + return { error: foldersError.message, documents: [], folders: [] }; const docsTyped = (docs ?? []).map(mapLibraryDocument) as { id: string; @@ -127,9 +136,40 @@ libraryRouter.get("/:kind", requireAuth, async (req, res) => { }[]; await attachLatestVersionNumbers(db, docsTyped); await attachActiveVersionPaths(db, docsTyped); - res.json({ documents: docsTyped, folders: folders ?? [] }); + return { error: null, documents: docsTyped, folders: folders ?? [] }; +} + +// GET /library/:kind +libraryRouter.get("/:kind", requireAuth, async (req, res) => { + const userId = res.locals.userId as string; + const kind = normalizeLibraryKind(req.params.kind); + if (!kind) return void res.status(404).json({ detail: "Library not found" }); + + const db = createServerSupabase(); + const result = await loadLibraryLevel(db, userId, kind, null); + if (result.error) return void res.status(500).json({ detail: result.error }); + res.json({ documents: result.documents, folders: result.folders }); }); +// GET /library/:kind/folders/:folderId/children +libraryRouter.get( + "/:kind/folders/:folderId/children", + requireAuth, + async (req, res) => { + const userId = res.locals.userId as string; + const kind = normalizeLibraryKind(req.params.kind); + if (!kind) return void res.status(404).json({ detail: "Library not found" }); + + const db = createServerSupabase(); + const folder = await loadLibraryFolder(db, userId, kind, req.params.folderId); + if (!folder) return void res.status(404).json({ detail: "Folder not found" }); + + const result = await loadLibraryLevel(db, userId, kind, folder.id); + if (result.error) return void res.status(500).json({ detail: result.error }); + res.json({ documents: result.documents, folders: result.folders }); + }, +); + // POST /library/:kind/documents libraryRouter.post( "/:kind/documents", diff --git a/frontend/src/app/components/documents/DocTable.tsx b/frontend/src/app/components/documents/DocTable.tsx index 050bdb90b..3c0d30ef1 100644 --- a/frontend/src/app/components/documents/DocTable.tsx +++ b/frontend/src/app/components/documents/DocTable.tsx @@ -139,6 +139,10 @@ interface DocTableProps { onSelectionActionsChange?: (actions: DocTableSelectionActions | null) => void; onOwnerOnlyAction?: Dispatch>; enableHeaderFilters?: boolean; + // When provided, folder contents are fetched on demand as folders are + // expanded (instead of the whole tree being loaded and auto-expanded + // up front). Called once per folder id the first time it's expanded. + onExpandFolder?: (folderId: string) => void | Promise; } function apiErrorDetail(error: unknown): string | null { @@ -272,6 +276,7 @@ export function DocTable({ onSelectionActionsChange, onOwnerOnlyAction, enableHeaderFilters = false, + onExpandFolder, }: DocTableProps) { const [addDocsOpen, setAddDocsOpen] = useState(false); const { user } = useAuth(); @@ -539,6 +544,9 @@ export function DocTable({ const [expandedFolderIds, setExpandedFolderIds] = useState>( new Set(), ); + const [loadingChildFolderIds, setLoadingChildFolderIds] = useState< + Set + >(() => new Set()); // undefined = not creating; null = creating at root; string = creating inside that folder id const [creatingFolderIn, setCreatingFolderIn] = useState< string | null | undefined @@ -617,9 +625,11 @@ export function DocTable({ }, [onCreateFolderActionChange, openCreateFolder]); useEffect(() => { - if (loading) return; + // In lazy mode, folders start collapsed and their contents are + // fetched via onExpandFolder as the user opens them. + if (loading || onExpandFolder) return; setExpandedFolderIds(new Set(folders.map((f) => f.id))); - }, [loading, folders]); + }, [loading, folders, onExpandFolder]); useEffect(() => { setSelectedDocIds([]); @@ -667,13 +677,31 @@ export function DocTable({ // ── Folder handlers ─────────────────────────────────────────────────────── + async function expandFolderChildren(folderId: string) { + if (!onExpandFolder) return; + setLoadingChildFolderIds((prev) => new Set([...prev, folderId])); + try { + await onExpandFolder(folderId); + } catch (e) { + console.error("expand folder failed", e); + } finally { + setLoadingChildFolderIds((prev) => { + const next = new Set(prev); + next.delete(folderId); + return next; + }); + } + } + function toggleFolder(id: string) { + const opening = !expandedFolderIds.has(id); setExpandedFolderIds((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); + if (opening) void expandFolderChildren(id); } async function handleCreateFolder(parentId: string | null) { @@ -697,8 +725,11 @@ export function DocTable({ } as DocTableFolder; setFolders((prev) => [...prev, optimistic]); setExpandedFolderIds((prev) => new Set([...prev, tempId])); - if (parentId) + if (parentId) { + const wasExpanded = expandedFolderIds.has(parentId); setExpandedFolderIds((prev) => new Set([...prev, parentId])); + if (!wasExpanded) void expandFolderChildren(parentId); + } // Replace with real folder from API const folder = await operations.createFolder(name, parentId ?? null); @@ -1768,6 +1799,9 @@ export function DocTable({ {childFolders.map((folder) => { const isExpanded = expandedFolderIds.has(folder.id); const isRenaming = renamingFolderId === folder.id; + const isLoadingChildren = loadingChildFolderIds.has( + folder.id, + ); return (
- {isExpanded ? ( + {isLoadingChildren ? ( + + ) : isExpanded ? ( ) : ( @@ -3074,6 +3110,14 @@ export function DocTable({ if ( contextMenu.folderId ) { + const wasExpanded = + expandedFolderIds.has( + contextMenu.folderId, + ); + if (!wasExpanded) + void expandFolderChildren( + contextMenu.folderId, + ); setExpandedFolderIds( (prev) => new Set([ diff --git a/frontend/src/app/components/library/LibraryWorkspace.tsx b/frontend/src/app/components/library/LibraryWorkspace.tsx index 17f0c46ad..95a0b31be 100644 --- a/frontend/src/app/components/library/LibraryWorkspace.tsx +++ b/frontend/src/app/components/library/LibraryWorkspace.tsx @@ -9,6 +9,7 @@ import { useContext, useEffect, useMemo, + useRef, useState, } from "react"; import { useRouter } from "next/navigation"; @@ -22,6 +23,7 @@ import { createLibraryFolder, deleteLibraryFolder, getLibrary, + getLibraryFolderChildren, moveLibraryDocument, moveLibraryFolder, renameLibraryDocument, @@ -40,10 +42,12 @@ type LibraryWorkspaceContextValue = { collections: Record; loadingByKind: Record; searchByKind: Record; + loadedFolderIdsByKind: Record>; loadLibrary: ( kind: LibraryKind, options?: { showLoading?: boolean }, ) => Promise; + loadFolderChildren: (kind: LibraryKind, folderId: string) => Promise; setSearchForKind: (kind: LibraryKind, value: string) => void; setDocumentsForKind: ( kind: LibraryKind, @@ -101,20 +105,63 @@ export function LibraryWorkspaceProvider({ files: "", templates: "", }); + const [loadedFolderIdsByKind, setLoadedFolderIdsByKind] = useState< + Record> + >({ + files: new Set(), + templates: new Set(), + }); + const folderChildrenRequestsRef = useRef>>( + new Map(), + ); + // Refetches root-level content plus every folder level already lazy-loaded + // for this kind, so a refresh (e.g. after uploading a new document + // version) doesn't drop the contents of folders the user has expanded. const loadLibrary = useCallback( async (kind: LibraryKind, options: { showLoading?: boolean } = {}) => { if (options.showLoading) { setLoadingByKind((prev) => ({ ...prev, [kind]: true })); } try { - const loaded = await getLibrary(kind); + const loadedFolderIds = [...loadedFolderIdsByKind[kind]]; + const [root, childResults] = await Promise.all([ + getLibrary(kind), + Promise.allSettled( + loadedFolderIds.map((folderId) => + getLibraryFolderChildren(kind, folderId), + ), + ), + ]); + + const documents = [...root.documents]; + const folders = [...root.folders]; + const seenDocIds = new Set(documents.map((d) => d.id)); + const seenFolderIds = new Set(folders.map((f) => f.id)); + const stillLoaded = new Set(); + + childResults.forEach((settled, index) => { + if (settled.status !== "fulfilled") return; + stillLoaded.add(loadedFolderIds[index]); + for (const doc of settled.value.documents) { + if (seenDocIds.has(doc.id)) continue; + seenDocIds.add(doc.id); + documents.push(doc); + } + for (const folder of settled.value.folders) { + if (seenFolderIds.has(folder.id)) continue; + seenFolderIds.add(folder.id); + folders.push(folder); + } + }); + setCollections((prev) => ({ ...prev, - [kind]: { - documents: loaded.documents, - folders: loaded.folders, - }, + [kind]: { documents, folders }, + })); + setLoadedFolderIdsByKind((prev) => ({ + ...prev, + [kind]: stillLoaded, })); } catch (error) { console.error("[library] failed to load", error); @@ -122,13 +169,76 @@ export function LibraryWorkspaceProvider({ ...prev, [kind]: EMPTY_COLLECTION, })); + setLoadedFolderIdsByKind((prev) => ({ + ...prev, + [kind]: new Set(), + })); } finally { if (options.showLoading) { setLoadingByKind((prev) => ({ ...prev, [kind]: false })); } } }, - [], + [loadedFolderIdsByKind], + ); + + const loadFolderChildren = useCallback( + async (kind: LibraryKind, folderId: string) => { + if (loadedFolderIdsByKind[kind].has(folderId)) return; + const key = `${kind}:${folderId}`; + const inFlight = folderChildrenRequestsRef.current.get(key); + if (inFlight) return inFlight; + + const request = (async () => { + try { + const children = await getLibraryFolderChildren( + kind, + folderId, + ); + setCollections((prev) => { + const current = prev[kind] ?? EMPTY_COLLECTION; + const existingDocIds = new Set( + current.documents.map((d) => d.id), + ); + const existingFolderIds = new Set( + current.folders.map((f) => f.id), + ); + return { + ...prev, + [kind]: { + documents: [ + ...current.documents, + ...children.documents.filter( + (d) => !existingDocIds.has(d.id), + ), + ], + folders: [ + ...current.folders, + ...children.folders.filter( + (f) => !existingFolderIds.has(f.id), + ), + ], + }, + }; + }); + setLoadedFolderIdsByKind((prev) => { + const next = new Set(prev[kind]); + next.add(folderId); + return { ...prev, [kind]: next }; + }); + } catch (error) { + console.error( + "[library] failed to load folder children", + error, + ); + } finally { + folderChildrenRequestsRef.current.delete(key); + } + })(); + folderChildrenRequestsRef.current.set(key, request); + return request; + }, + [loadedFolderIdsByKind], ); const setSearchForKind = useCallback((kind: LibraryKind, value: string) => { @@ -180,7 +290,9 @@ export function LibraryWorkspaceProvider({ collections, loadingByKind, searchByKind, + loadedFolderIdsByKind, loadLibrary, + loadFolderChildren, setSearchForKind, setDocumentsForKind, setFoldersForKind, @@ -188,7 +300,9 @@ export function LibraryWorkspaceProvider({ [ collections, loadingByKind, + loadedFolderIdsByKind, loadLibrary, + loadFolderChildren, searchByKind, setDocumentsForKind, setFoldersForKind, @@ -214,6 +328,7 @@ export function LibraryCollectionPage({ kind }: { kind: LibraryKind }) { loadingByKind, searchByKind, loadLibrary, + loadFolderChildren, setSearchForKind, setDocumentsForKind, setFoldersForKind, @@ -258,6 +373,11 @@ export function LibraryCollectionPage({ kind }: { kind: LibraryKind }) { [], ); + const handleExpandFolder = useCallback( + (folderId: string) => loadFolderChildren(kind, folderId), + [kind, loadFolderChildren], + ); + const operations = useMemo( () => ({ uploadDocument: (file: File) => uploadLibraryDocument(kind, file), @@ -344,6 +464,7 @@ export function LibraryCollectionPage({ kind }: { kind: LibraryKind }) { onCreateFolderActionChange={ handleCreateFolderActionChange } + onExpandFolder={handleExpandFolder} enableHeaderFilters emptyDropLabel={ kind === "templates" diff --git a/frontend/src/app/lib/mikeApi.test.ts b/frontend/src/app/lib/mikeApi.test.ts index 4ff2822a7..4a342460a 100644 --- a/frontend/src/app/lib/mikeApi.test.ts +++ b/frontend/src/app/lib/mikeApi.test.ts @@ -55,6 +55,7 @@ import { getCourtlistenerOpinions, getDocumentUrl, getLibrary, + getLibraryFolderChildren, getMcpConnector, getOllamaModels, getProject, @@ -1624,6 +1625,11 @@ describe("thin endpoint wrappers", () => { call: () => getLibrary("templates"), url: "/library/templates", }, + { + name: "getLibraryFolderChildren", + call: () => getLibraryFolderChildren("files", "f1"), + url: "/library/files/folders/f1/children", + }, { name: "renameLibraryFolder", call: () => renameLibraryFolder("files", "f1", "Precedents"), diff --git a/frontend/src/app/lib/mikeApi.ts b/frontend/src/app/lib/mikeApi.ts index 396884f34..8f8cd1b1e 100644 --- a/frontend/src/app/lib/mikeApi.ts +++ b/frontend/src/app/lib/mikeApi.ts @@ -635,6 +635,15 @@ export async function getLibrary( return apiRequest(`/library/${kind}`); } +export async function getLibraryFolderChildren( + kind: LibraryKind, + folderId: string, +): Promise { + return apiRequest( + `/library/${kind}/folders/${folderId}/children`, + ); +} + export async function uploadLibraryDocument( kind: LibraryKind, file: File, From d64e3d70452e078f15ae72ba3dd4db2b2b4fad2a Mon Sep 17 00:00:00 2001 From: Anthony May Date: Tue, 11 Aug 2026 13:58:53 +1000 Subject: [PATCH 4/6] Feat: Added document-level pagination to the Library view --- ...cts_and_workflows_overview_pagination.sql} | 206 +++++++++++++++++- ...60807_02_workflows_overview_pagination.sql | 197 ----------------- backend/src/routes/library.ts | 67 +++++- .../src/app/components/documents/DocTable.tsx | 33 +++ .../components/library/LibraryWorkspace.tsx | 168 +++++++++++++- frontend/src/app/lib/mikeApi.test.ts | 11 + frontend/src/app/lib/mikeApi.ts | 23 +- 7 files changed, 488 insertions(+), 217 deletions(-) rename backend/migrations/{20260807_01_projects_overview_pagination.sql => 20260807_01_projects_and_workflows_overview_pagination.sql} (50%) delete mode 100644 backend/migrations/20260807_02_workflows_overview_pagination.sql diff --git a/backend/migrations/20260807_01_projects_overview_pagination.sql b/backend/migrations/20260807_01_projects_and_workflows_overview_pagination.sql similarity index 50% rename from backend/migrations/20260807_01_projects_overview_pagination.sql rename to backend/migrations/20260807_01_projects_and_workflows_overview_pagination.sql index 69f929678..fbe9c267f 100644 --- a/backend/migrations/20260807_01_projects_overview_pagination.sql +++ b/backend/migrations/20260807_01_projects_and_workflows_overview_pagination.sql @@ -1,9 +1,14 @@ -- Migration date: 2026-08-07 --- Server-side pagination for the Projects overview page (/projects), mirroring --- the pattern already built for Tabular Reviews in +-- Server-side pagination for the Projects overview page (/projects) and the +-- Workflows list page (/workflows), added the same day and combined into one +-- migration. Both mirror the pattern already built for Tabular Reviews in -- 20260726_01_tabular_reviews_pagination.sql / --- 20260727_01_tabular_review_ids_overview.sql: +-- 20260727_01_tabular_review_ids_overview.sql. + +-- ============================================================================ +-- Projects overview pagination +-- ============================================================================ -- * a trigram index so leading-wildcard search can use an index scan -- * a new, higher-arity overload of get_projects_overview that adds -- scope/search/practice/owner filters, server-side sort, and limit/offset @@ -205,3 +210,198 @@ as $$ limit greatest(coalesce(p_limit, 1000), 1) offset greatest(coalesce(p_offset, 0), 0); $$; + +-- ============================================================================ +-- Workflows overview pagination +-- ============================================================================ +-- Mirrors the Projects pagination above. System workflows are a static, +-- code-generated TypeScript constant (backend/src/lib/systemWorkflows.ts) +-- with zero user-data growth — they are deliberately NOT part of this RPC and +-- stay fetched/filtered client-side exactly as before. This migration only +-- paginates the one part of /workflows with real growth: a user's owned + +-- shared workflows, currently served by the 3-arg get_workflows_overview +-- defined in 20260625_01_workflow_metadata.sql, which is left completely +-- untouched — every other caller of GET /workflows (the workflow picker +-- modal, the chat slash-menu picker) keeps hitting that exact unpaginated +-- path, since the route only takes the new paginated branch when a +-- pagination-related query param is present. + +create index if not exists workflows_title_trgm_idx + on public.workflows using gin (lower(title) gin_trgm_ops); + +create index if not exists workflows_jurisdictions_gin_idx + on public.workflows using gin (jurisdictions); + +-- p_scope here is 'all' | 'owned' | 'shared' — deliberately different +-- vocabulary from Projects' 'mine'/'shared', since this RPC (unlike +-- Projects' single source of truth) never includes system workflows at all; +-- keeping the words distinct avoids conflating this RPC-level scope with the +-- UI's separate "source" filter (system/user/shared), which does include +-- system rows client-side. +create or replace function public.get_workflows_overview( + p_user_id text, + p_user_email text, + p_type 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_language text, + p_jurisdiction text +) +returns table ( + id uuid, + user_id text, + title text, + type text, + prompt_md text, + columns_config jsonb, + language text, + practice text, + jurisdictions 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.id, w.user_id::text as user_id, w.title, w.type, w.prompt_md, + w.columns_config, w.language, w.practice, w.jurisdictions, + false as is_system, w.created_at, + 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::text = p_user_id + and (p_type is null or w.type = p_type) + ), + shared as ( + select + w.id, w.user_id::text as user_id, w.title, w.type, w.prompt_md, + w.columns_config, w.language, w.practice, w.jurisdictions, + false as is_system, w.created_at, + 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::text + 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.language, vw.practice, vw.jurisdictions, vw.is_system, vw.created_at, + vw.allow_edit, vw.is_owner, vw.shared_by_name + from visible_workflows vw + where ( + coalesce(p_scope, 'all') = 'all' + or (p_scope = 'owned' and vw.sort_bucket = 0) + or (p_scope = 'shared' and vw.sort_bucket = 1) + ) + and ( + p_search_term is null + or p_search_term = '' + or lower(vw.title) like + '%' || replace(replace(replace(lower(p_search_term), '\', '\\'), '%', '\%'), '_', '\_') || '%' + escape '\' + ) + and (p_practice is null or vw.practice = p_practice) + and (p_language is null or vw.language = p_language) + and (p_jurisdiction is null or vw.jurisdictions @> array[p_jurisdiction]) + order by + case when p_sort_key = 'name' and p_sort_direction = 'asc' then lower(coalesce(vw.title, '')) else null end asc, + case when p_sort_key = 'name' and p_sort_direction = 'desc' then lower(coalesce(vw.title, '')) else null end desc, + case when p_sort_key = 'type' and p_sort_direction = 'asc' then vw.type else null end asc, + case when p_sort_key = 'type' and p_sort_direction = 'desc' then vw.type else null end desc, + case when p_sort_key = 'created' and p_sort_direction = 'asc' then vw.created_at else null end asc, + case when p_sort_key = 'created' and p_sort_direction = 'desc' then vw.created_at else null end desc, + vw.sort_bucket asc, + vw.created_at desc, + vw.id asc + limit greatest(coalesce(p_limit, 20), 1) + offset greatest(coalesce(p_offset, 0), 0); +$$; + +-- Lightweight companion for bulk "select all matching" actions (owned +-- workflows only — see the route/hook layer; shared workflows are excluded +-- from bulk-delete eligibility since only the owner can delete, and system +-- workflows never need this since all 37 are always already in memory). +-- Duplicates the owned predicate directly rather than delegating to +-- get_workflows_overview, same rationale as get_project_ids_overview: no +-- need for the shared-by-name join when the caller only wants ids. +create or replace function public.get_workflow_ids_overview( + p_user_id text, + p_user_email text, + p_type text, + p_scope text, + p_search_term text, + p_practice text, + p_language text, + p_jurisdiction text, + p_limit integer, + p_offset integer +) +returns table ( + id uuid, + user_id text +) +language sql +stable +as $$ + with owned as ( + select w.id, w.user_id::text as user_id, w.title, w.practice, w.language, w.jurisdictions, + w.created_at, 0 as sort_bucket + from public.workflows w + where w.user_id::text = p_user_id + and (p_type is null or w.type = p_type) + ), + shared as ( + select w.id, w.user_id::text as user_id, w.title, w.practice, w.language, w.jurisdictions, + w.created_at, 1 as sort_bucket + from public.workflow_shares ws + join public.workflows w + on w.id = ws.workflow_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 + from visible_workflows vw + where ( + coalesce(p_scope, 'all') = 'all' + or (p_scope = 'owned' and vw.sort_bucket = 0) + or (p_scope = 'shared' and vw.sort_bucket = 1) + ) + and ( + p_search_term is null + or p_search_term = '' + or lower(vw.title) like + '%' || replace(replace(replace(lower(p_search_term), '\', '\\'), '%', '\%'), '_', '\_') || '%' + escape '\' + ) + and (p_practice is null or vw.practice = p_practice) + and (p_language is null or vw.language = p_language) + and (p_jurisdiction is null or vw.jurisdictions @> array[p_jurisdiction]) + order by vw.sort_bucket asc, vw.created_at desc, vw.id asc + limit greatest(coalesce(p_limit, 1000), 1) + offset greatest(coalesce(p_offset, 0), 0); +$$; diff --git a/backend/migrations/20260807_02_workflows_overview_pagination.sql b/backend/migrations/20260807_02_workflows_overview_pagination.sql deleted file mode 100644 index de5b2f014..000000000 --- a/backend/migrations/20260807_02_workflows_overview_pagination.sql +++ /dev/null @@ -1,197 +0,0 @@ --- Migration date: 2026-08-07 - --- Server-side pagination for the Workflows list page (/workflows), mirroring --- the pattern built earlier the same day for Projects --- (20260807_01_projects_overview_pagination.sql). System workflows are a --- static, code-generated TypeScript constant (backend/src/lib/systemWorkflows.ts) --- with zero user-data growth — they are deliberately NOT part of this RPC and --- stay fetched/filtered client-side exactly as before. This migration only --- paginates the one part of /workflows with real growth: a user's owned + --- shared workflows, currently served by the 3-arg get_workflows_overview --- defined in 20260625_01_workflow_metadata.sql, which is left completely --- untouched — every other caller of GET /workflows (the workflow picker --- modal, the chat slash-menu picker) keeps hitting that exact unpaginated --- path, since the route only takes the new paginated branch when a --- pagination-related query param is present. - -create extension if not exists pg_trgm; - -create index if not exists workflows_title_trgm_idx - on public.workflows using gin (lower(title) gin_trgm_ops); - -create index if not exists workflows_jurisdictions_gin_idx - on public.workflows using gin (jurisdictions); - --- p_scope here is 'all' | 'owned' | 'shared' — deliberately different --- vocabulary from Projects' 'mine'/'shared', since this RPC (unlike --- Projects' single source of truth) never includes system workflows at all; --- keeping the words distinct avoids conflating this RPC-level scope with the --- UI's separate "source" filter (system/user/shared), which does include --- system rows client-side. -create or replace function public.get_workflows_overview( - p_user_id text, - p_user_email text, - p_type 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_language text, - p_jurisdiction text -) -returns table ( - id uuid, - user_id text, - title text, - type text, - prompt_md text, - columns_config jsonb, - language text, - practice text, - jurisdictions 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.id, w.user_id::text as user_id, w.title, w.type, w.prompt_md, - w.columns_config, w.language, w.practice, w.jurisdictions, - false as is_system, w.created_at, - 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::text = p_user_id - and (p_type is null or w.type = p_type) - ), - shared as ( - select - w.id, w.user_id::text as user_id, w.title, w.type, w.prompt_md, - w.columns_config, w.language, w.practice, w.jurisdictions, - false as is_system, w.created_at, - 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::text - 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.language, vw.practice, vw.jurisdictions, vw.is_system, vw.created_at, - vw.allow_edit, vw.is_owner, vw.shared_by_name - from visible_workflows vw - where ( - coalesce(p_scope, 'all') = 'all' - or (p_scope = 'owned' and vw.sort_bucket = 0) - or (p_scope = 'shared' and vw.sort_bucket = 1) - ) - and ( - p_search_term is null - or p_search_term = '' - or lower(vw.title) like - '%' || replace(replace(replace(lower(p_search_term), '\', '\\'), '%', '\%'), '_', '\_') || '%' - escape '\' - ) - and (p_practice is null or vw.practice = p_practice) - and (p_language is null or vw.language = p_language) - and (p_jurisdiction is null or vw.jurisdictions @> array[p_jurisdiction]) - order by - case when p_sort_key = 'name' and p_sort_direction = 'asc' then lower(coalesce(vw.title, '')) else null end asc, - case when p_sort_key = 'name' and p_sort_direction = 'desc' then lower(coalesce(vw.title, '')) else null end desc, - case when p_sort_key = 'type' and p_sort_direction = 'asc' then vw.type else null end asc, - case when p_sort_key = 'type' and p_sort_direction = 'desc' then vw.type else null end desc, - case when p_sort_key = 'created' and p_sort_direction = 'asc' then vw.created_at else null end asc, - case when p_sort_key = 'created' and p_sort_direction = 'desc' then vw.created_at else null end desc, - vw.sort_bucket asc, - vw.created_at desc, - vw.id asc - limit greatest(coalesce(p_limit, 20), 1) - offset greatest(coalesce(p_offset, 0), 0); -$$; - --- Lightweight companion for bulk "select all matching" actions (owned --- workflows only — see the route/hook layer; shared workflows are excluded --- from bulk-delete eligibility since only the owner can delete, and system --- workflows never need this since all 37 are always already in memory). --- Duplicates the owned predicate directly rather than delegating to --- get_workflows_overview, same rationale as get_project_ids_overview: no --- need for the shared-by-name join when the caller only wants ids. -create or replace function public.get_workflow_ids_overview( - p_user_id text, - p_user_email text, - p_type text, - p_scope text, - p_search_term text, - p_practice text, - p_language text, - p_jurisdiction text, - p_limit integer, - p_offset integer -) -returns table ( - id uuid, - user_id text -) -language sql -stable -as $$ - with owned as ( - select w.id, w.user_id::text as user_id, w.title, w.practice, w.language, w.jurisdictions, - w.created_at, 0 as sort_bucket - from public.workflows w - where w.user_id::text = p_user_id - and (p_type is null or w.type = p_type) - ), - shared as ( - select w.id, w.user_id::text as user_id, w.title, w.practice, w.language, w.jurisdictions, - w.created_at, 1 as sort_bucket - from public.workflow_shares ws - join public.workflows w - on w.id = ws.workflow_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 - from visible_workflows vw - where ( - coalesce(p_scope, 'all') = 'all' - or (p_scope = 'owned' and vw.sort_bucket = 0) - or (p_scope = 'shared' and vw.sort_bucket = 1) - ) - and ( - p_search_term is null - or p_search_term = '' - or lower(vw.title) like - '%' || replace(replace(replace(lower(p_search_term), '\', '\\'), '%', '\%'), '_', '\_') || '%' - escape '\' - ) - and (p_practice is null or vw.practice = p_practice) - and (p_language is null or vw.language = p_language) - and (p_jurisdiction is null or vw.jurisdictions @> array[p_jurisdiction]) - order by vw.sort_bucket asc, vw.created_at desc, vw.id asc - limit greatest(coalesce(p_limit, 1000), 1) - offset greatest(coalesce(p_offset, 0), 0); -$$; diff --git a/backend/src/routes/library.ts b/backend/src/routes/library.ts index 1bbf53370..c8a817514 100644 --- a/backend/src/routes/library.ts +++ b/backend/src/routes/library.ts @@ -8,6 +8,7 @@ import { } from "../lib/documentVersions"; import { singleFileUpload } from "../lib/upload"; import { handleDocumentUpload } from "./documents"; +import { parsePaginationQuery, type PaginationParams } from "../lib/pagination"; export const libraryRouter = Router(); @@ -91,11 +92,17 @@ async function deleteLibraryDocumentsAndVersionFiles( return error ?? null; } +// Folders per level are assumed to stay small (organizational containers, +// not user data that grows unbounded) and are always returned in full. +// Documents are the part that can grow into the thousands, so only they're +// paginated — one extra row is fetched over `limit` to detect `hasMore` +// without a separate count query. async function loadLibraryLevel( db: ReturnType, userId: string, kind: LibraryKind, parentFolderId: string | null, + pagination: PaginationParams, ) { let documentsQuery = db .from("documents") @@ -110,6 +117,10 @@ async function loadLibraryLevel( kind === "file" ? documentsQuery.or("library_kind.eq.file,library_kind.is.null") : documentsQuery.eq("library_kind", kind); + documentsQuery = documentsQuery.range( + pagination.offset, + pagination.offset + pagination.limit, + ); let foldersQuery = db .from("library_folders") @@ -126,17 +137,39 @@ async function loadLibraryLevel( documentsQuery.order("created_at", { ascending: true }), foldersQuery.order("created_at", { ascending: true }), ]); - if (docsError) return { error: docsError.message, documents: [], folders: [] }; + if (docsError) + return { + error: docsError.message, + documents: [], + folders: [], + documentsHasMore: false, + }; if (foldersError) - return { error: foldersError.message, documents: [], folders: [] }; - - const docsTyped = (docs ?? []).map(mapLibraryDocument) as { + return { + error: foldersError.message, + documents: [], + folders: [], + documentsHasMore: false, + }; + + const rawDocs = docs ?? []; + const documentsHasMore = rawDocs.length > pagination.limit; + const pageDocs = documentsHasMore + ? rawDocs.slice(0, pagination.limit) + : rawDocs; + + const docsTyped = pageDocs.map(mapLibraryDocument) as { id: string; current_version_id?: string | null; }[]; await attachLatestVersionNumbers(db, docsTyped); await attachActiveVersionPaths(db, docsTyped); - return { error: null, documents: docsTyped, folders: folders ?? [] }; + return { + error: null, + documents: docsTyped, + folders: folders ?? [], + documentsHasMore, + }; } // GET /library/:kind @@ -146,9 +179,14 @@ libraryRouter.get("/:kind", requireAuth, async (req, res) => { if (!kind) return void res.status(404).json({ detail: "Library not found" }); const db = createServerSupabase(); - const result = await loadLibraryLevel(db, userId, kind, null); + const pagination = parsePaginationQuery(req.query as Record); + const result = await loadLibraryLevel(db, userId, kind, null, pagination); if (result.error) return void res.status(500).json({ detail: result.error }); - res.json({ documents: result.documents, folders: result.folders }); + res.json({ + documents: result.documents, + folders: result.folders, + documentsHasMore: result.documentsHasMore, + }); }); // GET /library/:kind/folders/:folderId/children @@ -164,9 +202,20 @@ libraryRouter.get( const folder = await loadLibraryFolder(db, userId, kind, req.params.folderId); if (!folder) return void res.status(404).json({ detail: "Folder not found" }); - const result = await loadLibraryLevel(db, userId, kind, folder.id); + const pagination = parsePaginationQuery(req.query as Record); + const result = await loadLibraryLevel( + db, + userId, + kind, + folder.id, + pagination, + ); if (result.error) return void res.status(500).json({ detail: result.error }); - res.json({ documents: result.documents, folders: result.folders }); + res.json({ + documents: result.documents, + folders: result.folders, + documentsHasMore: result.documentsHasMore, + }); }, ); diff --git a/frontend/src/app/components/documents/DocTable.tsx b/frontend/src/app/components/documents/DocTable.tsx index 3c0d30ef1..e37dfe809 100644 --- a/frontend/src/app/components/documents/DocTable.tsx +++ b/frontend/src/app/components/documents/DocTable.tsx @@ -63,6 +63,7 @@ import { type ProjectContextMenu, } from "@/app/components/projects/ProjectPageParts"; import { DocumentSidePanel } from "@/app/components/shared/DocumentSidePanel"; +import { TableLoadMoreRow } from "@/app/components/shared/TableLoadMoreRow"; import { LibrarySkeuoIcon } from "@/app/components/shared/AppSidebarSkeuoIcons"; import { APP_SURFACE_ACTIVE_CLASS, @@ -143,6 +144,12 @@ interface DocTableProps { // expanded (instead of the whole tree being loaded and auto-expanded // up front). Called once per folder id the first time it's expanded. onExpandFolder?: (folderId: string) => void | Promise; + // Per-level document pagination, keyed by parent folder id (root uses + // the sentinel "root"). When onLoadMoreDocuments is provided, a level + // with more documents than are currently loaded shows a "load more" row. + documentsHasMoreByLevel?: Record; + loadingMoreDocumentsByLevel?: Record; + onLoadMoreDocuments?: (parentId: string | null) => void; } function apiErrorDetail(error: unknown): string | null { @@ -277,6 +284,9 @@ export function DocTable({ onOwnerOnlyAction, enableHeaderFilters = false, onExpandFolder, + documentsHasMoreByLevel, + loadingMoreDocumentsByLevel, + onLoadMoreDocuments, }: DocTableProps) { const [addDocsOpen, setAddDocsOpen] = useState(false); const { user } = useAuth(); @@ -1795,6 +1805,29 @@ export function DocTable({ ); })} + {onLoadMoreDocuments && ( +
+ + onLoadMoreDocuments(parentId) + } + /> +
+ )} + {/* Subfolders after files, sorted alphabetically */} {childFolders.map((folder) => { const isExpanded = expandedFolderIds.has(folder.id); diff --git a/frontend/src/app/components/library/LibraryWorkspace.tsx b/frontend/src/app/components/library/LibraryWorkspace.tsx index 95a0b31be..4a7396ac1 100644 --- a/frontend/src/app/components/library/LibraryWorkspace.tsx +++ b/frontend/src/app/components/library/LibraryWorkspace.tsx @@ -43,11 +43,17 @@ type LibraryWorkspaceContextValue = { loadingByKind: Record; searchByKind: Record; loadedFolderIdsByKind: Record>; + documentsHasMoreByKind: Record>; + loadingMoreDocumentsByKind: Record>; loadLibrary: ( kind: LibraryKind, options?: { showLoading?: boolean }, ) => Promise; loadFolderChildren: (kind: LibraryKind, folderId: string) => Promise; + loadMoreDocuments: ( + kind: LibraryKind, + parentId: string | null, + ) => Promise; setSearchForKind: (kind: LibraryKind, value: string) => void; setDocumentsForKind: ( kind: LibraryKind, @@ -69,6 +75,16 @@ const EMPTY_COLLECTION: LibraryViewCollection = { folders: [], }; +// Sentinel key identifying the root level in the per-level pagination maps +// below (folder levels are keyed by their real folder id, which is always a +// uuid and so can never collide with this). +const ROOT_LEVEL_KEY = "root"; +const DOCUMENT_PAGE_SIZE = 50; + +function libraryLevelKey(parentId: string | null): string { + return parentId ?? ROOT_LEVEL_KEY; +} + const LibraryWorkspaceContext = createContext(null); @@ -111,13 +127,32 @@ export function LibraryWorkspaceProvider({ files: new Set(), templates: new Set(), }); + // Per-level (root or folder id) document paging state: how many + // documents are currently requested for that level, whether the server + // has more beyond that, and whether a "load more" fetch is in flight. + const [documentLimitByKind, setDocumentLimitByKind] = useState< + Record> + >({ files: {}, templates: {} }); + const [documentsHasMoreByKind, setDocumentsHasMoreByKind] = useState< + Record> + >({ files: {}, templates: {} }); + const [loadingMoreDocumentsByKind, setLoadingMoreDocumentsByKind] = + useState>>({ + files: {}, + templates: {}, + }); const folderChildrenRequestsRef = useRef>>( new Map(), ); + const loadMoreDocumentsRequestsRef = useRef>>( + new Map(), + ); // Refetches root-level content plus every folder level already lazy-loaded - // for this kind, so a refresh (e.g. after uploading a new document - // version) doesn't drop the contents of folders the user has expanded. + // for this kind (each level re-requested at its current page size), so a + // refresh (e.g. after uploading a new document version) doesn't drop the + // contents of folders the user has expanded, or documents loaded beyond + // the first page. const loadLibrary = useCallback( async (kind: LibraryKind, options: { showLoading?: boolean } = {}) => { if (options.showLoading) { @@ -125,11 +160,16 @@ export function LibraryWorkspaceProvider({ } try { const loadedFolderIds = [...loadedFolderIdsByKind[kind]]; + const limits = documentLimitByKind[kind]; const [root, childResults] = await Promise.all([ - getLibrary(kind), + getLibrary(kind, { + limit: limits[ROOT_LEVEL_KEY] ?? DOCUMENT_PAGE_SIZE, + }), Promise.allSettled( loadedFolderIds.map((folderId) => - getLibraryFolderChildren(kind, folderId), + getLibraryFolderChildren(kind, folderId, { + limit: limits[folderId] ?? DOCUMENT_PAGE_SIZE, + }), ), ), ]); @@ -139,10 +179,15 @@ export function LibraryWorkspaceProvider({ const seenDocIds = new Set(documents.map((d) => d.id)); const seenFolderIds = new Set(folders.map((f) => f.id)); const stillLoaded = new Set(); + const nextHasMore: Record = { + [ROOT_LEVEL_KEY]: root.documentsHasMore, + }; childResults.forEach((settled, index) => { if (settled.status !== "fulfilled") return; - stillLoaded.add(loadedFolderIds[index]); + const folderId = loadedFolderIds[index]; + stillLoaded.add(folderId); + nextHasMore[folderId] = settled.value.documentsHasMore; for (const doc of settled.value.documents) { if (seenDocIds.has(doc.id)) continue; seenDocIds.add(doc.id); @@ -163,6 +208,10 @@ export function LibraryWorkspaceProvider({ ...prev, [kind]: stillLoaded, })); + setDocumentsHasMoreByKind((prev) => ({ + ...prev, + [kind]: nextHasMore, + })); } catch (error) { console.error("[library] failed to load", error); setCollections((prev) => ({ @@ -173,13 +222,15 @@ export function LibraryWorkspaceProvider({ ...prev, [kind]: new Set(), })); + setDocumentLimitByKind((prev) => ({ ...prev, [kind]: {} })); + setDocumentsHasMoreByKind((prev) => ({ ...prev, [kind]: {} })); } finally { if (options.showLoading) { setLoadingByKind((prev) => ({ ...prev, [kind]: false })); } } }, - [loadedFolderIdsByKind], + [loadedFolderIdsByKind, documentLimitByKind], ); const loadFolderChildren = useCallback( @@ -194,6 +245,7 @@ export function LibraryWorkspaceProvider({ const children = await getLibraryFolderChildren( kind, folderId, + { limit: DOCUMENT_PAGE_SIZE }, ); setCollections((prev) => { const current = prev[kind] ?? EMPTY_COLLECTION; @@ -226,6 +278,17 @@ export function LibraryWorkspaceProvider({ next.add(folderId); return { ...prev, [kind]: next }; }); + setDocumentLimitByKind((prev) => ({ + ...prev, + [kind]: { ...prev[kind], [folderId]: DOCUMENT_PAGE_SIZE }, + })); + setDocumentsHasMoreByKind((prev) => ({ + ...prev, + [kind]: { + ...prev[kind], + [folderId]: children.documentsHasMore, + }, + })); } catch (error) { console.error( "[library] failed to load folder children", @@ -241,6 +304,80 @@ export function LibraryWorkspaceProvider({ [loadedFolderIdsByKind], ); + // Fetches the next page of documents for a single level (root or one + // folder), replacing just that level's documents/folders in place — + // everything belonging to other levels is left untouched. + const loadMoreDocuments = useCallback( + async (kind: LibraryKind, parentId: string | null) => { + const levelKey = libraryLevelKey(parentId); + const requestKey = `${kind}:${levelKey}`; + const inFlight = loadMoreDocumentsRequestsRef.current.get(requestKey); + if (inFlight) return inFlight; + + const nextLimit = + (documentLimitByKind[kind][levelKey] ?? DOCUMENT_PAGE_SIZE) + + DOCUMENT_PAGE_SIZE; + setLoadingMoreDocumentsByKind((prev) => ({ + ...prev, + [kind]: { ...prev[kind], [levelKey]: true }, + })); + + const request = (async () => { + try { + const page = + parentId === null + ? await getLibrary(kind, { limit: nextLimit }) + : await getLibraryFolderChildren(kind, parentId, { + limit: nextLimit, + }); + + setCollections((prev) => { + const current = prev[kind] ?? EMPTY_COLLECTION; + const documents = [ + ...current.documents.filter( + (d) => (d.folder_id ?? null) !== parentId, + ), + ...page.documents, + ]; + const folders = [ + ...current.folders.filter( + (f) => + (f.parent_folder_id ?? null) !== parentId, + ), + ...page.folders, + ]; + return { ...prev, [kind]: { documents, folders } }; + }); + setDocumentLimitByKind((prev) => ({ + ...prev, + [kind]: { ...prev[kind], [levelKey]: nextLimit }, + })); + setDocumentsHasMoreByKind((prev) => ({ + ...prev, + [kind]: { + ...prev[kind], + [levelKey]: page.documentsHasMore, + }, + })); + } catch (error) { + console.error( + "[library] failed to load more documents", + error, + ); + } finally { + setLoadingMoreDocumentsByKind((prev) => ({ + ...prev, + [kind]: { ...prev[kind], [levelKey]: false }, + })); + loadMoreDocumentsRequestsRef.current.delete(requestKey); + } + })(); + loadMoreDocumentsRequestsRef.current.set(requestKey, request); + return request; + }, + [documentLimitByKind], + ); + const setSearchForKind = useCallback((kind: LibraryKind, value: string) => { setSearchByKind((prev) => ({ ...prev, [kind]: value })); }, []); @@ -291,8 +428,11 @@ export function LibraryWorkspaceProvider({ loadingByKind, searchByKind, loadedFolderIdsByKind, + documentsHasMoreByKind, + loadingMoreDocumentsByKind, loadLibrary, loadFolderChildren, + loadMoreDocuments, setSearchForKind, setDocumentsForKind, setFoldersForKind, @@ -301,8 +441,11 @@ export function LibraryWorkspaceProvider({ collections, loadingByKind, loadedFolderIdsByKind, + documentsHasMoreByKind, + loadingMoreDocumentsByKind, loadLibrary, loadFolderChildren, + loadMoreDocuments, searchByKind, setDocumentsForKind, setFoldersForKind, @@ -327,8 +470,11 @@ export function LibraryCollectionPage({ kind }: { kind: LibraryKind }) { collections, loadingByKind, searchByKind, + documentsHasMoreByKind, + loadingMoreDocumentsByKind, loadLibrary, loadFolderChildren, + loadMoreDocuments, setSearchForKind, setDocumentsForKind, setFoldersForKind, @@ -378,6 +524,11 @@ export function LibraryCollectionPage({ kind }: { kind: LibraryKind }) { [kind, loadFolderChildren], ); + const handleLoadMoreDocuments = useCallback( + (parentId: string | null) => loadMoreDocuments(kind, parentId), + [kind, loadMoreDocuments], + ); + const operations = useMemo( () => ({ uploadDocument: (file: File) => uploadLibraryDocument(kind, file), @@ -465,6 +616,11 @@ export function LibraryCollectionPage({ kind }: { kind: LibraryKind }) { handleCreateFolderActionChange } onExpandFolder={handleExpandFolder} + documentsHasMoreByLevel={documentsHasMoreByKind[kind]} + loadingMoreDocumentsByLevel={ + loadingMoreDocumentsByKind[kind] + } + onLoadMoreDocuments={handleLoadMoreDocuments} enableHeaderFilters emptyDropLabel={ kind === "templates" diff --git a/frontend/src/app/lib/mikeApi.test.ts b/frontend/src/app/lib/mikeApi.test.ts index 4a342460a..d2c01bf06 100644 --- a/frontend/src/app/lib/mikeApi.test.ts +++ b/frontend/src/app/lib/mikeApi.test.ts @@ -1630,6 +1630,17 @@ describe("thin endpoint wrappers", () => { call: () => getLibraryFolderChildren("files", "f1"), url: "/library/files/folders/f1/children", }, + { + name: "getLibrary with pagination", + call: () => getLibrary("files", { limit: 50, offset: 100 }), + url: "/library/files?limit=50&offset=100", + }, + { + name: "getLibraryFolderChildren with pagination", + call: () => + getLibraryFolderChildren("files", "f1", { limit: 50 }), + url: "/library/files/folders/f1/children?limit=50", + }, { name: "renameLibraryFolder", call: () => renameLibraryFolder("files", "f1", "Precedents"), diff --git a/frontend/src/app/lib/mikeApi.ts b/frontend/src/app/lib/mikeApi.ts index 8f8cd1b1e..d8d406b03 100644 --- a/frontend/src/app/lib/mikeApi.ts +++ b/frontend/src/app/lib/mikeApi.ts @@ -627,20 +627,39 @@ export type LibraryKind = "files" | "templates"; export interface LibraryCollection { documents: Document[]; folders: LibraryFolder[]; + documentsHasMore: boolean; +} + +export interface LibraryPagination { + limit?: number; + offset?: number; +} + +function libraryPaginationQuery(pagination?: LibraryPagination): string { + const params = new URLSearchParams(); + if (pagination?.limit != null) params.set("limit", String(pagination.limit)); + if (pagination?.offset != null) + params.set("offset", String(pagination.offset)); + const qs = params.toString(); + return qs ? `?${qs}` : ""; } export async function getLibrary( kind: LibraryKind, + pagination?: LibraryPagination, ): Promise { - return apiRequest(`/library/${kind}`); + return apiRequest( + `/library/${kind}${libraryPaginationQuery(pagination)}`, + ); } export async function getLibraryFolderChildren( kind: LibraryKind, folderId: string, + pagination?: LibraryPagination, ): Promise { return apiRequest( - `/library/${kind}/folders/${folderId}/children`, + `/library/${kind}/folders/${folderId}/children${libraryPaginationQuery(pagination)}`, ); } From 57d754cc593ac42b5a3fe6ddb5ed8b16c861e441 Mon Sep 17 00:00:00 2001 From: willchen96 Date: Wed, 12 Aug 2026 17:39:16 +0800 Subject: [PATCH 5/6] fix: harden collection pagination and directory loading --- ...ects_and_workflows_overview_pagination.sql | 9 +- ...60812_01_collection_pagination_queries.sql | 384 +++ backend/schema.sql | 759 +++++- .../integration/projects.routes.test.ts | 258 +- .../integration/tabular.routes.test.ts | 92 +- .../integration/workflows.routes.test.ts | 85 +- backend/src/lib/sort.ts | 34 +- backend/src/routes/chat.ts | 84 +- backend/src/routes/library.ts | 352 ++- backend/src/routes/projects.ts | 481 +++- backend/src/routes/tabular.ts | 127 +- backend/src/routes/workflows.ts | 291 ++- .../src/app/(pages)/tabular-reviews/page.tsx | 36 +- .../src/app/components/documents/DocTable.tsx | 2212 ++++++----------- .../components/library/LibraryWorkspace.tsx | 366 ++- .../components/projects/ProjectsOverview.tsx | 85 +- .../src/app/components/shared/AppSidebar.tsx | 248 +- .../components/shared/FileDirectory.test.tsx | 46 +- .../app/components/shared/FileDirectory.tsx | 533 +++- frontend/src/app/components/shared/types.ts | 18 +- .../app/components/shared/useDirectoryData.ts | 434 +++- .../components/workflows/UseWorkflowModal.tsx | 176 +- .../app/components/workflows/WorkflowList.tsx | 772 +++--- .../src/app/contexts/ChatHistoryContext.tsx | 69 +- .../src/app/hooks/usePaginatedProjects.ts | 53 +- .../app/hooks/usePaginatedTabularReviews.ts | 47 +- .../src/app/hooks/usePaginatedWorkflows.ts | 107 +- frontend/src/app/lib/mikeApi.test.ts | 195 +- frontend/src/app/lib/mikeApi.ts | 274 +- frontend/src/app/lib/paginatedRows.ts | 18 + 30 files changed, 5659 insertions(+), 2986 deletions(-) create mode 100644 backend/migrations/20260812_01_collection_pagination_queries.sql create mode 100644 frontend/src/app/lib/paginatedRows.ts diff --git a/backend/migrations/20260807_01_projects_and_workflows_overview_pagination.sql b/backend/migrations/20260807_01_projects_and_workflows_overview_pagination.sql index fbe9c267f..ffa7c0035 100644 --- a/backend/migrations/20260807_01_projects_and_workflows_overview_pagination.sql +++ b/backend/migrations/20260807_01_projects_and_workflows_overview_pagination.sql @@ -14,8 +14,8 @@ -- scope/search/practice/owner filters, server-side sort, and limit/offset -- * the existing 2-arg get_projects_overview (from 20260703_02_project_practice.sql) -- is left completely untouched as the back-compat path for every caller --- that doesn't ask for pagination (sidebar nav, document-picker directory --- view, tabular-review project pickers) — see backend/src/routes/projects.ts +-- that doesn't ask for pagination (document-picker directory view and +-- tabular-review project pickers) — see backend/src/routes/projects.ts -- for the routing logic that decides which overload to call. -- * a lightweight get_project_ids_overview companion for "select all -- matching" bulk actions. @@ -25,6 +25,9 @@ create extension if not exists pg_trgm; create index if not exists projects_name_trgm_idx on public.projects using gin (lower(name) gin_trgm_ops); +create index if not exists projects_updated_at_idx + on public.projects(updated_at desc, id); + create or replace function public.get_projects_overview( p_user_id text, p_user_email text, @@ -143,6 +146,8 @@ as $$ 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) diff --git a/backend/migrations/20260812_01_collection_pagination_queries.sql b/backend/migrations/20260812_01_collection_pagination_queries.sql new file mode 100644 index 000000000..7b00d0c65 --- /dev/null +++ b/backend/migrations/20260812_01_collection_pagination_queries.sql @@ -0,0 +1,384 @@ +-- Migration date: 2026-08-12 + +-- Server-side pagination, search/filter facets, and lightweight collection +-- queries introduced by the overview and directory refactor. + +-- ============================================================================ +-- Library search and filter facets +-- ============================================================================ + +-- Flat, server-side Library search used when the Library table has an active +-- search, file-type filter, or sort. Browsing remains level-based through the +-- hierarchical directory view on the existing /library/:kind endpoint. +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, + 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 = 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 = 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 + ); +$$; + +-- Small, distinct filter facets for Projects. This avoids downloading every +-- project overview row (and calculating all of its counts) just to populate +-- two dropdown menus. +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 = 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) + ) + ), + distinct_owners as ( + select distinct vp.user_id + from visible_projects vp + ), + owner_options as ( + select + o.user_id, + case + when o.user_id = 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 + ) + 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; +$$; + +-- Scope/type-aware Workflow facets. Static system-workflow facets stay in +-- memory on the frontend and are merged with these DB-backed values. +create or replace function public.get_workflow_filter_options( + p_user_id text, + p_user_email text default null, + p_type text default null, + p_scope text default 'all' +) +returns table ( + practices text[], + languages text[], + jurisdictions text[] +) +language sql +stable +as $$ + with owned as ( + select w.practice, w.language, w.jurisdictions, 'owned'::text as source + from public.workflows w + where w.user_id::text = p_user_id + and (p_type is null or w.type = p_type) + ), + shared as ( + select w.practice, w.language, w.jurisdictions, 'shared'::text as source + from public.workflow_shares ws + join public.workflows w on w.id = ws.workflow_id + where lower(ws.shared_with_email) = lower(coalesce(p_user_email, '')) + and (p_type is null or w.type = p_type) + ), + visible as ( + select * from owned + union all + select * from shared + ), + scoped as ( + select * from visible + where coalesce(p_scope, 'all') = 'all' or source = p_scope + ) + select + coalesce( + array_agg(distinct nullif(trim(practice), '') order by nullif(trim(practice), '')) + filter (where nullif(trim(practice), '') is not null), + array[]::text[] + ) as practices, + coalesce( + array_agg(distinct nullif(trim(language), '') order by nullif(trim(language), '')) + filter (where nullif(trim(language), '') is not null), + array[]::text[] + ) as languages, + coalesce( + (select array_agg(distinct jurisdiction order by jurisdiction) + from scoped s + cross join lateral unnest(coalesce(s.jurisdictions, array[]::text[])) jurisdiction + where nullif(trim(jurisdiction), '') is not null), + array[]::text[] + ) as jurisdictions + from scoped; +$$; + +create index if not exists document_versions_filename_trgm_idx + on public.document_versions using gin (lower(filename) gin_trgm_ops) + where deleted_at is null; + +-- ============================================================================ +-- Chat sidebar pagination +-- ============================================================================ + +-- Offset pagination for the sidebar's recent-chat list. Project names are +-- returned with each row so the sidebar does not need to download every +-- project merely to label project chats. +create index if not exists chats_user_created_idx + on public.chats(user_id, created_at desc, id); + +drop function if exists public.get_chats_overview(text, integer); + +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, + 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 = p_user_id + or ( + p.id is not null + and p.user_id = 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); +$$; + +-- ============================================================================ +-- Recent projects and Library bulk-selection helpers +-- ============================================================================ + +-- Lightweight sidebar project feed. The Projects overview RPC intentionally +-- computes file/chat/review counts for table sorting; the sidebar needs none +-- of those aggregates. +drop function if exists public.get_recent_projects(text, text, integer, integer); + +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, + p.name, + p.created_at, + p.updated_at, + p.user_id = p_user_id as is_owner + 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) + ) + 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); +$$; + +-- ID-only Library query for select-all and bulk actions. This mirrors the +-- flat Library search predicate without returning document/version payloads. +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 + 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 = 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); +$$; diff --git a/backend/schema.sql b/backend/schema.sql index 97ad9672e..de99a2b07 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -207,6 +207,9 @@ create table if not exists public.projects ( create index if not exists idx_projects_user on public.projects(user_id); +create index if not exists projects_updated_at_idx + on public.projects(updated_at desc, id); + create index if not exists projects_shared_with_idx on public.projects using gin (shared_with); @@ -508,19 +511,24 @@ create table if not exists public.chats ( create index if not exists idx_chats_user on public.chats(user_id); +create index if not exists chats_user_created_idx + on public.chats(user_id, created_at desc, id); + create index if not exists idx_chats_project on public.chats(project_id); create or replace function public.get_chats_overview( p_user_id text, - p_limit integer default null + 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 + created_at timestamptz, + project_name text ) language sql stable @@ -530,20 +538,18 @@ as $$ c.project_id, c.user_id, c.title, - c.created_at + 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 = 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 + or (p.id is not null and p.user_id = 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; + end + offset greatest(coalesce(p_offset, 0), 0); $$; create table if not exists public.chat_messages ( @@ -1081,6 +1087,737 @@ create table if not exists public.courtlistener_opinion_cluster_index ( alter table public.courtlistener_opinion_cluster_index enable row level security; +-- --------------------------------------------------------------------------- +-- Library search and lightweight overview facets +-- --------------------------------------------------------------------------- + +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, + 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 = 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 = 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 = 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) + ) + ), + distinct_owners as ( + select distinct vp.user_id + from visible_projects vp + ), + owner_options as ( + select + o.user_id, + case + when o.user_id = 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 + ) + 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_workflow_filter_options( + p_user_id text, + p_user_email text default null, + p_type text default null, + p_scope text default 'all' +) +returns table ( + practices text[], + languages text[], + jurisdictions text[] +) +language sql +stable +as $$ + with owned as ( + select w.practice, w.language, w.jurisdictions, 'owned'::text as source + from public.workflows w + where w.user_id::text = p_user_id + and (p_type is null or w.type = p_type) + ), + shared as ( + select w.practice, w.language, w.jurisdictions, 'shared'::text as source + from public.workflow_shares ws + join public.workflows w on w.id = ws.workflow_id + where lower(ws.shared_with_email) = lower(coalesce(p_user_email, '')) + and (p_type is null or w.type = p_type) + ), + visible as ( + select * from owned + union all + select * from shared + ), + scoped as ( + select * from visible + where coalesce(p_scope, 'all') = 'all' or source = p_scope + ) + select + coalesce( + array_agg(distinct nullif(trim(practice), '') order by nullif(trim(practice), '')) + filter (where nullif(trim(practice), '') is not null), + array[]::text[] + ) as practices, + coalesce( + array_agg(distinct nullif(trim(language), '') order by nullif(trim(language), '')) + filter (where nullif(trim(language), '') is not null), + array[]::text[] + ) as languages, + coalesce( + (select array_agg(distinct jurisdiction order by jurisdiction) + from scoped s + cross join lateral unnest(coalesce(s.jurisdictions, array[]::text[])) jurisdiction + where nullif(trim(jurisdiction), '') is not null), + array[]::text[] + ) as jurisdictions + from scoped; +$$; + +create index if not exists document_versions_filename_trgm_idx + on public.document_versions using gin (lower(filename) gin_trgm_ops) + where deleted_at is null; + +-- --------------------------------------------------------------------------- +-- Paginated project/workflow overviews and collection summary helpers +-- --------------------------------------------------------------------------- + +-- Server-side pagination for the Projects overview page (/projects) and the +-- Workflows list page (/workflows), added the same day and combined into one +-- migration. Both mirror the pattern already built for Tabular Reviews in +-- 20260726_01_tabular_reviews_pagination.sql / +-- 20260727_01_tabular_review_ids_overview.sql. + +-- ============================================================================ +-- Projects overview pagination +-- ============================================================================ +-- * a trigram index so leading-wildcard search can use an index scan +-- * a new, higher-arity overload of get_projects_overview that adds +-- scope/search/practice/owner filters, server-side sort, and limit/offset +-- * the existing 2-arg get_projects_overview (from 20260703_02_project_practice.sql) +-- is left completely untouched as the back-compat path for every caller +-- that doesn't ask for pagination (document-picker directory view and +-- tabular-review project pickers) — see backend/src/routes/projects.ts +-- for the routing logic that decides which overload to call. +-- * a lightweight get_project_ids_overview companion for "select all +-- matching" bulk actions. + +create extension if not exists pg_trgm; + +create index if not exists projects_name_trgm_idx + on public.projects using gin (lower(name) gin_trgm_ops); + +create index if not exists projects_updated_at_idx + on public.projects(updated_at desc, id); + +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 = 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) + ) + ) + and ( + coalesce(p_scope, 'all') = 'all' + or (p_scope = 'mine' and p.user_id = p_user_id) + or (p_scope = 'shared' and p.user_id <> 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 = 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, + vp.name, + vp.cm_number, + vp.practice, + 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 + 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); +$$; + +-- Lightweight companion for bulk "select all matching" actions — id + owning +-- user only, no count joins. Duplicates visible_projects' predicate rather +-- than delegating to get_projects_overview (same rationale as +-- get_tabular_review_ids_overview: the count CTEs there would be pure waste +-- for a caller that only wants ids). Keep this predicate in sync by hand if +-- visible_projects above ever changes. +-- +-- Paginated (not "return everything") because PostgREST enforces its own +-- row cap on every RPC response and truncates silently rather than erroring; +-- backend/src/routes/projects.ts pages through this on the caller's behalf. +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 + 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) + ) + ) + and ( + coalesce(p_scope, 'all') = 'all' + or (p_scope = 'mine' and p.user_id = p_user_id) + or (p_scope = 'shared' and p.user_id <> 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 = 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); +$$; + +-- ============================================================================ +-- Workflows overview pagination +-- ============================================================================ +-- Mirrors the Projects pagination above. System workflows are a static, +-- code-generated TypeScript constant (backend/src/lib/systemWorkflows.ts) +-- with zero user-data growth — they are deliberately NOT part of this RPC and +-- stay fetched/filtered client-side exactly as before. This migration only +-- paginates the one part of /workflows with real growth: a user's owned + +-- shared workflows, currently served by the 3-arg get_workflows_overview +-- defined in 20260625_01_workflow_metadata.sql, which is left completely +-- untouched — every other caller of GET /workflows (the workflow picker +-- modal, the chat slash-menu picker) keeps hitting that exact unpaginated +-- path, since the route only takes the new paginated branch when a +-- pagination-related query param is present. + +create index if not exists workflows_title_trgm_idx + on public.workflows using gin (lower(title) gin_trgm_ops); + +create index if not exists workflows_jurisdictions_gin_idx + on public.workflows using gin (jurisdictions); + +-- p_scope here is 'all' | 'owned' | 'shared' — deliberately different +-- vocabulary from Projects' 'mine'/'shared', since this RPC (unlike +-- Projects' single source of truth) never includes system workflows at all; +-- keeping the words distinct avoids conflating this RPC-level scope with the +-- UI's separate "source" filter (system/user/shared), which does include +-- system rows client-side. +create or replace function public.get_workflows_overview( + p_user_id text, + p_user_email text, + p_type 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_language text, + p_jurisdiction text +) +returns table ( + id uuid, + user_id text, + title text, + type text, + prompt_md text, + columns_config jsonb, + language text, + practice text, + jurisdictions 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.id, w.user_id::text as user_id, w.title, w.type, w.prompt_md, + w.columns_config, w.language, w.practice, w.jurisdictions, + false as is_system, w.created_at, + 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::text = p_user_id + and (p_type is null or w.type = p_type) + ), + shared as ( + select + w.id, w.user_id::text as user_id, w.title, w.type, w.prompt_md, + w.columns_config, w.language, w.practice, w.jurisdictions, + false as is_system, w.created_at, + 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::text + 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.language, vw.practice, vw.jurisdictions, vw.is_system, vw.created_at, + vw.allow_edit, vw.is_owner, vw.shared_by_name + from visible_workflows vw + where ( + coalesce(p_scope, 'all') = 'all' + or (p_scope = 'owned' and vw.sort_bucket = 0) + or (p_scope = 'shared' and vw.sort_bucket = 1) + ) + and ( + p_search_term is null + or p_search_term = '' + or lower(vw.title) like + '%' || replace(replace(replace(lower(p_search_term), '\', '\\'), '%', '\%'), '_', '\_') || '%' + escape '\' + ) + and (p_practice is null or vw.practice = p_practice) + and (p_language is null or vw.language = p_language) + and (p_jurisdiction is null or vw.jurisdictions @> array[p_jurisdiction]) + order by + case when p_sort_key = 'name' and p_sort_direction = 'asc' then lower(coalesce(vw.title, '')) else null end asc, + case when p_sort_key = 'name' and p_sort_direction = 'desc' then lower(coalesce(vw.title, '')) else null end desc, + case when p_sort_key = 'type' and p_sort_direction = 'asc' then vw.type else null end asc, + case when p_sort_key = 'type' and p_sort_direction = 'desc' then vw.type else null end desc, + case when p_sort_key = 'created' and p_sort_direction = 'asc' then vw.created_at else null end asc, + case when p_sort_key = 'created' and p_sort_direction = 'desc' then vw.created_at else null end desc, + vw.sort_bucket asc, + vw.created_at desc, + vw.id asc + limit greatest(coalesce(p_limit, 20), 1) + offset greatest(coalesce(p_offset, 0), 0); +$$; + +-- Lightweight companion for bulk "select all matching" actions (owned +-- workflows only — see the route/hook layer; shared workflows are excluded +-- from bulk-delete eligibility since only the owner can delete, and system +-- workflows never need this since all 37 are always already in memory). +-- Duplicates the owned predicate directly rather than delegating to +-- get_workflows_overview, same rationale as get_project_ids_overview: no +-- need for the shared-by-name join when the caller only wants ids. +create or replace function public.get_workflow_ids_overview( + p_user_id text, + p_user_email text, + p_type text, + p_scope text, + p_search_term text, + p_practice text, + p_language text, + p_jurisdiction text, + p_limit integer, + p_offset integer +) +returns table ( + id uuid, + user_id text +) +language sql +stable +as $$ + with owned as ( + select w.id, w.user_id::text as user_id, w.title, w.practice, w.language, w.jurisdictions, + w.created_at, 0 as sort_bucket + from public.workflows w + where w.user_id::text = p_user_id + and (p_type is null or w.type = p_type) + ), + shared as ( + select w.id, w.user_id::text as user_id, w.title, w.practice, w.language, w.jurisdictions, + w.created_at, 1 as sort_bucket + from public.workflow_shares ws + join public.workflows w + on w.id = ws.workflow_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 + from visible_workflows vw + where ( + coalesce(p_scope, 'all') = 'all' + or (p_scope = 'owned' and vw.sort_bucket = 0) + or (p_scope = 'shared' and vw.sort_bucket = 1) + ) + and ( + p_search_term is null + or p_search_term = '' + or lower(vw.title) like + '%' || replace(replace(replace(lower(p_search_term), '\', '\\'), '%', '\%'), '_', '\_') || '%' + escape '\' + ) + and (p_practice is null or vw.practice = p_practice) + and (p_language is null or vw.language = p_language) + and (p_jurisdiction is null or vw.jurisdictions @> array[p_jurisdiction]) + order by vw.sort_bucket asc, vw.created_at desc, vw.id asc + limit greatest(coalesce(p_limit, 1000), 1) + offset greatest(coalesce(p_offset, 0), 0); +$$; + +-- Lightweight sidebar project feed. The Projects overview RPC intentionally +-- computes file/chat/review counts for table sorting; the sidebar needs none +-- of those aggregates. +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, + p.name, + p.created_at, + p.updated_at, + p.user_id = p_user_id as is_owner + 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) + ) + 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); +$$; + +-- ID-only Library query for select-all and bulk actions. This mirrors the +-- flat Library search predicate without returning document/version payloads. +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 + 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 = 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); +$$; + -- --------------------------------------------------------------------------- -- Direct client grant hardening -- --------------------------------------------------------------------------- diff --git a/backend/src/__tests__/integration/projects.routes.test.ts b/backend/src/__tests__/integration/projects.routes.test.ts index 166a19b60..4a186a028 100644 --- a/backend/src/__tests__/integration/projects.routes.test.ts +++ b/backend/src/__tests__/integration/projects.routes.test.ts @@ -39,9 +39,25 @@ function resultForTable(table: string): QueryResult { function makeQuery(table: string) { const q: Record = {}; const chain = [ - "select", "update", "delete", "upsert", - "eq", "neq", "in", "is", "or", "not", "lt", "gt", "gte", "lte", - "filter", "order", "limit", "range", "contains", + "select", + "update", + "delete", + "upsert", + "eq", + "neq", + "in", + "is", + "or", + "not", + "lt", + "gt", + "gte", + "lte", + "filter", + "order", + "limit", + "range", + "contains", ]; for (const m of chain) q[m] = vi.fn(() => q); q.insert = vi.fn((payload: unknown) => { @@ -50,8 +66,10 @@ function makeQuery(table: string) { }); q.single = vi.fn(() => Promise.resolve(resultForTable(table))); q.maybeSingle = vi.fn(() => Promise.resolve(resultForTable(table))); - q.then = (resolve: (v: unknown) => unknown, reject?: (e: unknown) => unknown) => - Promise.resolve(resultForTable(table)).then(resolve, reject); + q.then = ( + resolve: (v: unknown) => unknown, + reject?: (e: unknown) => unknown, + ) => Promise.resolve(resultForTable(table)).then(resolve, reject); return q; } @@ -123,12 +141,16 @@ const AUTH = ["Authorization", "Bearer test"] as const; // Wraps mockSupabase()'s rpc so the next request's exact RPC call args can be // asserted on — the shared mock otherwise only lets tests control the // *response*, not inspect what was sent. -function captureRpcArgs(): { args: unknown } { - const captured: { args: unknown } = { args: undefined }; +function captureRpcArgs(): { args: unknown; name: string | undefined } { + const captured: { args: unknown; name: string | undefined } = { + args: undefined, + name: undefined, + }; vi.mocked(createServerSupabase).mockImplementationOnce(() => { const db = mockSupabase(); const originalRpc = db.rpc; db.rpc = vi.fn((name: string, args: unknown) => { + captured.name = name; captured.args = args; return originalRpc(name, args as never); }); @@ -157,7 +179,9 @@ describe("projects.routes", () => { error: null, }; - const res = await request(app).get("/projects").set(...AUTH); + const res = await request(app) + .get("/projects") + .set(...AUTH); expect(res.status).toBe(200); expect(res.body).toEqual([{ id: "p1", name: "Alpha" }]); @@ -206,22 +230,23 @@ describe("projects.routes", () => { it("returns 500 with detail when the RPC errors", async () => { supabaseState.rpc = { data: null, error: { message: "boom" } }; - const res = await request(app).get("/projects").set(...AUTH); + const res = await request(app) + .get("/projects") + .set(...AUTH); expect(res.status).toBe(500); expect(res.body.detail).toBe("boom"); }); - // Regression guard: the sidebar nav, the document-picker directory - // view, and the tabular-review project pickers all call GET /projects - // with no query params and need the full, unpaginated list back. If - // this ever silently switched to the paginated RPC shape by default, - // those callers would start seeing a truncated list with no error. + // Regression guard: legacy project pickers call GET /projects with no + // query params and need the full, unpaginated list back. it("calls the legacy 2-arg RPC shape when no pagination params are present", async () => { const captured = captureRpcArgs(); supabaseState.rpc = { data: [], error: null }; - await request(app).get("/projects").set(...AUTH); + await request(app) + .get("/projects") + .set(...AUTH); expect(captured.args).toEqual({ p_user_id: "u1", @@ -253,6 +278,47 @@ describe("projects.routes", () => { p_owner_user_id: "u2", }); }); + + it("uses the lightweight summary RPC for view=summary", async () => { + const captured = captureRpcArgs(); + supabaseState.rpc = { + data: [{ id: "p1", name: "Recently updated" }], + error: null, + }; + + const res = await request(app) + .get("/projects?view=summary&limit=11&offset=10") + .set(...AUTH); + + expect(res.status).toBe(200); + expect(res.body).toEqual([ + { id: "p1", name: "Recently updated" }, + ]); + expect(captured.name).toBe("get_project_summaries"); + expect(captured.args).toEqual({ + p_user_id: "u1", + p_user_email: "u1@test.local", + p_limit: 11, + p_offset: 10, + }); + }); + + it("uses the projects collection for directory search", async () => { + const res = await request(app) + .get("/projects?view=directory-search") + .set(...AUTH); + + expect(res.status).toBe(200); + expect(res.body).toEqual([]); + }); + + it("no longer exposes a separate project directory search route", async () => { + const res = await request(app) + .get("/projects/directory/search?search=Agreement") + .set(...AUTH); + + expect(res.status).toBe(404); + }); }); // ── GET /projects/ids (select-all-matching support) ────────────────── @@ -271,7 +337,9 @@ describe("projects.routes", () => { return db as unknown as ReturnType; }); - const res = await request(app).get("/projects/ids").set(...AUTH); + const res = await request(app) + .get("/projects/ids") + .set(...AUTH); expect(res.status).toBe(200); expect(res.body).toEqual([{ id: "p1", user_id: "u1" }]); @@ -282,13 +350,115 @@ describe("projects.routes", () => { it("returns 500 with detail when the RPC errors", async () => { supabaseState.rpc = { data: null, error: { message: "boom" } }; - const res = await request(app).get("/projects/ids").set(...AUTH); + const res = await request(app) + .get("/projects/ids") + .set(...AUTH); expect(res.status).toBe(500); expect(res.body.detail).toBe("boom"); }); }); + describe("GET /projects/filter-options", () => { + it("returns lightweight practice and owner facets", async () => { + const captured = captureRpcArgs(); + supabaseState.rpc = { + data: [ + { + practices: ["Litigation"], + owners: [{ value: "u1", label: "Me" }], + }, + ], + error: null, + }; + + const res = await request(app) + .get("/projects/filter-options") + .set(...AUTH); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + practices: ["Litigation"], + owners: [{ value: "u1", label: "Me" }], + }); + expect(captured.args).toEqual({ + p_user_id: "u1", + p_user_email: "u1@test.local", + }); + }); + }); + + describe("Library query endpoints", () => { + it("returns a flat paginated search result", async () => { + const captured = captureRpcArgs(); + supabaseState.rpc = { + data: [ + { id: "d1", filename: "Agreement.docx" }, + { id: "d2", filename: "Agreement schedule.docx" }, + ], + error: null, + }; + + const res = await request(app) + .get( + "/library/templates?view=search&limit=1&offset=2&search=Agreement" + + "&file_type=docx&sort_key=name&sort_direction=asc", + ) + .set(...AUTH); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + documents: [ + { + id: "d1", + filename: "Agreement.docx", + folder_id: null, + }, + ], + documentsHasMore: true, + }); + expect(captured.name).toBe("search_library_documents"); + expect(captured.args).toEqual({ + p_user_id: "u1", + p_library_kind: "template", + p_limit: 2, + p_offset: 2, + p_search_term: "Agreement", + p_file_type: "docx", + p_sort_key: "name", + p_sort_direction: "asc", + }); + }); + + it("no longer exposes a separate Library search route", async () => { + const res = await request(app) + .get("/library/templates/search?search=Agreement") + .set(...AUTH); + + expect(res.status).toBe(404); + }); + + it("returns only the file-type facet payload", async () => { + const captured = captureRpcArgs(); + supabaseState.rpc = { + data: [{ file_types: ["docx", "pdf"] }], + error: null, + }; + + const res = await request(app) + .get("/library/files/filter-options") + .set(...AUTH); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ fileTypes: ["docx", "pdf"] }); + expect(captured.name).toBe("get_library_filter_options"); + expect(captured.args).toEqual({ + p_user_id: "u1", + p_library_kind: "file", + }); + }); + }); + // ── POST /projects (create) ─────────────────────────────────────────── describe("POST /projects", () => { it("returns 400 when name is missing/blank", async () => { @@ -310,9 +480,7 @@ describe("projects.routes", () => { .send({ name: "Beta", shared_with: ["U1@Test.Local"] }); expect(res.status).toBe(400); - expect(res.body.detail).toBe( - "You cannot share a project with yourself.", - ); + expect(res.body.detail).toBe("You cannot share a project with yourself."); }); it("creates the project (201) and normalises shared_with", async () => { @@ -346,9 +514,7 @@ describe("projects.routes", () => { // The insert payload should be lowercased, deduped, trimmed and // the name trimmed. - const insert = supabaseState.inserts.find( - (i) => i.table === "projects", - ); + const insert = supabaseState.inserts.find((i) => i.table === "projects"); expect(insert?.payload).toMatchObject({ name: "Gamma", shared_with: ["a@x.com", "b@x.com"], @@ -393,7 +559,9 @@ describe("projects.routes", () => { it("returns 404 when the project does not exist", async () => { supabaseState.tables.projects = { data: null, error: null }; - const res = await request(app).get("/projects/p1").set(...AUTH); + const res = await request(app) + .get("/projects/p1") + .set(...AUTH); expect(res.status).toBe(404); expect(res.body.detail).toBe("Project not found"); @@ -409,7 +577,9 @@ describe("projects.routes", () => { error: null, }; - const res = await request(app).get("/projects/p1").set(...AUTH); + const res = await request(app) + .get("/projects/p1") + .set(...AUTH); expect(res.status).toBe(404); expect(res.body.detail).toBe("Project not found"); @@ -427,7 +597,9 @@ describe("projects.routes", () => { supabaseState.tables.documents = { data: [], error: null }; supabaseState.tables.project_subfolders = { data: [], error: null }; - const res = await request(app).get("/projects/p1").set(...AUTH); + const res = await request(app) + .get("/projects/p1") + .set(...AUTH); expect(res.status).toBe(200); expect(res.body).toMatchObject({ id: "p1", is_owner: false }); @@ -447,7 +619,9 @@ describe("projects.routes", () => { error: null, }; - const res = await request(app).get("/projects/p1").set(...AUTH); + const res = await request(app) + .get("/projects/p1") + .set(...AUTH); expect(res.status).toBe(200); expect(res.body).toMatchObject({ @@ -498,9 +672,7 @@ describe("projects.routes", () => { .send({ shared_with: ["u1@test.local"] }); expect(res.status).toBe(400); - expect(res.body.detail).toBe( - "You cannot share a project with yourself.", - ); + expect(res.body.detail).toBe("You cannot share a project with yourself."); }); it("returns 404 when the update matches no owned project", async () => { @@ -521,7 +693,9 @@ describe("projects.routes", () => { it("returns 404 when nothing was deleted", async () => { deleteUserProjects.mockResolvedValue(0); - const res = await request(app).delete("/projects/p1").set(...AUTH); + const res = await request(app) + .delete("/projects/p1") + .set(...AUTH); expect(res.status).toBe(404); expect(res.body.detail).toBe("Project not found"); @@ -530,21 +704,23 @@ describe("projects.routes", () => { it("returns 204 when the project is deleted", async () => { deleteUserProjects.mockResolvedValue(1); - const res = await request(app).delete("/projects/p1").set(...AUTH); + const res = await request(app) + .delete("/projects/p1") + .set(...AUTH); expect(res.status).toBe(204); // Signature is deleteUserProjects(db, userId, [projectId]). - expect(deleteUserProjects).toHaveBeenCalledWith( - expect.anything(), - "u1", - ["p1"], - ); + expect(deleteUserProjects).toHaveBeenCalledWith(expect.anything(), "u1", [ + "p1", + ]); }); it("returns 500 when deletion throws", async () => { deleteUserProjects.mockRejectedValue(new Error("cascade failed")); - const res = await request(app).delete("/projects/p1").set(...AUTH); + const res = await request(app) + .delete("/projects/p1") + .set(...AUTH); expect(res.status).toBe(500); expect(res.body.detail).toBe("cascade failed"); @@ -690,7 +866,7 @@ describe("projects.routes", () => { it("does not leak the underlying error when the manifest build fails", async () => { supabaseState.tables.projects = { data: null, - error: { message: "relation \"projects\" does not exist" }, + error: { message: 'relation "projects" does not exist' }, }; const res = await request(app) @@ -698,9 +874,7 @@ describe("projects.routes", () => { .set(...AUTH); expect(res.status).toBe(500); - expect(res.body.detail).toBe( - "Failed to build project export manifest", - ); + expect(res.body.detail).toBe("Failed to build project export manifest"); }); }); }); diff --git a/backend/src/__tests__/integration/tabular.routes.test.ts b/backend/src/__tests__/integration/tabular.routes.test.ts index ee198ee28..cf13f6bbe 100644 --- a/backend/src/__tests__/integration/tabular.routes.test.ts +++ b/backend/src/__tests__/integration/tabular.routes.test.ts @@ -50,9 +50,25 @@ function resultForTable(table: string): QueryResult { function makeQuery(table: string) { const q: Record = {}; const chain = [ - "select", "update", "delete", "upsert", - "eq", "neq", "in", "is", "or", "not", "lt", "gt", "gte", "lte", - "filter", "order", "limit", "range", "contains", + "select", + "update", + "delete", + "upsert", + "eq", + "neq", + "in", + "is", + "or", + "not", + "lt", + "gt", + "gte", + "lte", + "filter", + "order", + "limit", + "range", + "contains", ]; for (const m of chain) q[m] = vi.fn(() => q); q.insert = vi.fn((payload: unknown) => { @@ -61,8 +77,10 @@ function makeQuery(table: string) { }); q.single = vi.fn(() => Promise.resolve(resultForTable(table))); q.maybeSingle = vi.fn(() => Promise.resolve(resultForTable(table))); - q.then = (resolve: (v: unknown) => unknown, reject?: (e: unknown) => unknown) => - Promise.resolve(resultForTable(table)).then(resolve, reject); + q.then = ( + resolve: (v: unknown) => unknown, + reject?: (e: unknown) => unknown, + ) => Promise.resolve(resultForTable(table)).then(resolve, reject); return q; } @@ -151,7 +169,9 @@ describe("tabular.routes", () => { error: null, }; - const res = await request(app).get("/tabular-review").set(...AUTH); + const res = await request(app) + .get("/tabular-review") + .set(...AUTH); expect(res.status).toBe(200); expect(res.body).toEqual([{ id: "r1", title: "Alpha" }]); @@ -160,7 +180,9 @@ describe("tabular.routes", () => { it("returns 500 with detail when the RPC errors", async () => { supabaseState.rpc = { data: null, error: { message: "boom" } }; - const res = await request(app).get("/tabular-review").set(...AUTH); + const res = await request(app) + .get("/tabular-review") + .set(...AUTH); expect(res.status).toBe(500); expect(res.body.detail).toBe("boom"); @@ -242,9 +264,27 @@ describe("tabular.routes", () => { }; supabaseState.tables.documents = { data: [ - { id: "d1", filename: "A.pdf", file_type: "pdf", project_id: "p1", folder_id: "f1" }, - { id: "d2", filename: "B.pdf", file_type: "pdf", project_id: "p1", folder_id: "f1" }, - { id: "d3", filename: "Loose.pdf", file_type: "pdf", project_id: "p1", folder_id: null }, + { + id: "d1", + filename: "A.pdf", + file_type: "pdf", + project_id: "p1", + folder_id: "f1", + }, + { + id: "d2", + filename: "B.pdf", + file_type: "pdf", + project_id: "p1", + folder_id: "f1", + }, + { + id: "d3", + filename: "Loose.pdf", + file_type: "pdf", + project_id: "p1", + folder_id: null, + }, ], error: null, }; @@ -290,10 +330,14 @@ describe("tabular.routes", () => { }); expect(res.status).toBe(201); - expect(supabaseState.inserts.find((i) => i.table === "tabular_reviews")?.payload) - .toMatchObject({ document_grouping: "folder" }); - expect(supabaseState.inserts.find((i) => i.table === "tabular_review_rows")?.payload) - .toEqual([ + expect( + supabaseState.inserts.find((i) => i.table === "tabular_reviews") + ?.payload, + ).toMatchObject({ document_grouping: "folder" }); + expect( + supabaseState.inserts.find((i) => i.table === "tabular_review_rows") + ?.payload, + ).toEqual([ { review_id: "r10", label: "Contracts", @@ -313,14 +357,18 @@ describe("tabular.routes", () => { sort_index: 1, }, ]); - expect(supabaseState.inserts.find((i) => i.table === "tabular_review_row_sources")?.payload) - .toEqual([ + expect( + supabaseState.inserts.find( + (i) => i.table === "tabular_review_row_sources", + )?.payload, + ).toEqual([ { row_id: "row-folder", document_id: "d1", sort_index: 0 }, { row_id: "row-folder", document_id: "d2", sort_index: 1 }, { row_id: "row-document", document_id: "d3", sort_index: 0 }, ]); - expect(supabaseState.inserts.find((i) => i.table === "tabular_cells")?.payload) - .toEqual([ + expect( + supabaseState.inserts.find((i) => i.table === "tabular_cells")?.payload, + ).toEqual([ { review_id: "r10", row_id: "row-folder", @@ -669,9 +717,7 @@ describe("tabular.routes", () => { .send({}); expect(res.status).toBe(400); - expect(res.body.detail).toBe( - "row_id and column_index are required", - ); + expect(res.body.detail).toBe("row_id and column_index are required"); }); it("returns 404 when review access is denied", async () => { @@ -960,9 +1006,7 @@ describe("tabular.routes", () => { .set(...AUTH); expect(res.status).toBe(200); - expect(res.body).toEqual([ - { id: "chat-1", title: "T", user_id: "u1" }, - ]); + expect(res.body).toEqual([{ id: "chat-1", title: "T", user_id: "u1" }]); }); }); }); diff --git a/backend/src/__tests__/integration/workflows.routes.test.ts b/backend/src/__tests__/integration/workflows.routes.test.ts index d062bfdff..c31ca3557 100644 --- a/backend/src/__tests__/integration/workflows.routes.test.ts +++ b/backend/src/__tests__/integration/workflows.routes.test.ts @@ -37,9 +37,25 @@ function resultForTable(table: string): QueryResult { function makeQuery(table: string) { const q: Record = {}; const chain = [ - "select", "update", "delete", "upsert", - "eq", "neq", "in", "is", "or", "not", "lt", "gt", "gte", "lte", - "filter", "order", "limit", "range", "contains", + "select", + "update", + "delete", + "upsert", + "eq", + "neq", + "in", + "is", + "or", + "not", + "lt", + "gt", + "gte", + "lte", + "filter", + "order", + "limit", + "range", + "contains", ]; for (const m of chain) q[m] = vi.fn(() => q); q.insert = vi.fn((payload: unknown) => { @@ -48,8 +64,10 @@ function makeQuery(table: string) { }); q.single = vi.fn(() => Promise.resolve(resultForTable(table))); q.maybeSingle = vi.fn(() => Promise.resolve(resultForTable(table))); - q.then = (resolve: (v: unknown) => unknown, reject?: (e: unknown) => unknown) => - Promise.resolve(resultForTable(table)).then(resolve, reject); + q.then = ( + resolve: (v: unknown) => unknown, + reject?: (e: unknown) => unknown, + ) => Promise.resolve(resultForTable(table)).then(resolve, reject); return q; } @@ -168,7 +186,9 @@ describe("workflows.routes", () => { const captured = captureRpcArgs(); supabaseState.rpc = { data: [], error: null }; - await request(app).get("/workflows?type=tabular").set(...AUTH); + await request(app) + .get("/workflows?type=tabular") + .set(...AUTH); expect(captured.name).toBe("get_workflows_overview"); expect(captured.args).toEqual({ @@ -211,7 +231,9 @@ describe("workflows.routes", () => { it("returns 500 with detail when the RPC errors", async () => { supabaseState.rpc = { data: null, error: { message: "boom" } }; - const res = await request(app).get("/workflows?type=assistant").set(...AUTH); + const res = await request(app) + .get("/workflows?type=assistant") + .set(...AUTH); expect(res.status).toBe(500); expect(res.body.detail).toBe("boom"); @@ -236,9 +258,12 @@ describe("workflows.routes", () => { expect(res.status).toBe(200); expect(Array.isArray(res.body)).toBe(true); expect(res.body.length).toBeGreaterThan(0); - expect(res.body.every((w: { is_system: boolean; metadata: { type: string } }) => + expect( + res.body.every( + (w: { is_system: boolean; metadata: { type: string } }) => w.is_system && w.metadata.type === "assistant", - )).toBe(true); + ), + ).toBe(true); expect(createServerSupabase).not.toHaveBeenCalled(); }); }); @@ -259,7 +284,9 @@ describe("workflows.routes", () => { return db as unknown as ReturnType; }); - const res = await request(app).get("/workflows/ids").set(...AUTH); + const res = await request(app) + .get("/workflows/ids") + .set(...AUTH); expect(res.status).toBe(200); expect(res.body).toEqual([{ id: "w1", user_id: "u1" }]); @@ -270,10 +297,46 @@ describe("workflows.routes", () => { it("returns 500 with detail when the RPC errors", async () => { supabaseState.rpc = { data: null, error: { message: "boom" } }; - const res = await request(app).get("/workflows/ids").set(...AUTH); + const res = await request(app) + .get("/workflows/ids") + .set(...AUTH); expect(res.status).toBe(500); expect(res.body.detail).toBe("boom"); }); }); + + describe("GET /workflows/filter-options", () => { + it("passes type and scope to the facet RPC", async () => { + const captured = captureRpcArgs(); + supabaseState.rpc = { + data: [ + { + practices: ["Disputes"], + languages: ["English"], + jurisdictions: ["Singapore"], + }, + ], + error: null, + }; + + const res = await request(app) + .get("/workflows/filter-options?type=assistant&scope=shared") + .set(...AUTH); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + practices: ["Disputes"], + languages: ["English"], + jurisdictions: ["Singapore"], + }); + expect(captured.name).toBe("get_workflow_filter_options"); + expect(captured.args).toEqual({ + p_user_id: "u1", + p_user_email: "u1@test.local", + p_type: "assistant", + p_scope: "shared", + }); + }); + }); }); diff --git a/backend/src/lib/sort.ts b/backend/src/lib/sort.ts index 8e637ddac..ed162e782 100644 --- a/backend/src/lib/sort.ts +++ b/backend/src/lib/sort.ts @@ -6,9 +6,16 @@ export interface TabularReviewSort { direction: TabularReviewSortDirection; } -const SUPPORTED_KEYS: TabularReviewSortKey[] = ["name", "columns", "documents", "created"]; +const SUPPORTED_KEYS: TabularReviewSortKey[] = [ + "name", + "columns", + "documents", + "created", +]; -export function parseTabularReviewSort(value: Record): TabularReviewSort { +export function parseTabularReviewSort( + value: Record, +): TabularReviewSort { const rawKey = typeof value.sort_key === "string" ? value.sort_key : typeof value.key === "string" @@ -22,7 +29,14 @@ export function parseTabularReviewSort(value: Record): TabularR return { key, direction }; } -export type ProjectSortKey = "name" | "cm" | "files" | "chats" | "reviews" | "created"; +export type ProjectSortKey = + | "name" + | "cm" + | "files" + | "chats" + | "reviews" + | "created" + | "updated"; export type ProjectSortDirection = "asc" | "desc"; export interface ProjectSort { @@ -30,7 +44,15 @@ export interface ProjectSort { direction: ProjectSortDirection; } -const PROJECT_SUPPORTED_KEYS: ProjectSortKey[] = ["name", "cm", "files", "chats", "reviews", "created"]; +const PROJECT_SUPPORTED_KEYS: ProjectSortKey[] = [ + "name", + "cm", + "files", + "chats", + "reviews", + "created", + "updated", +]; export function parseProjectSort(value: Record): ProjectSort { const rawKey = typeof value.sort_key === "string" @@ -56,7 +78,9 @@ export interface WorkflowSort { const WORKFLOW_SUPPORTED_KEYS: WorkflowSortKey[] = ["name", "type", "created"]; -export function parseWorkflowSort(value: Record): WorkflowSort { +export function parseWorkflowSort( + value: Record, +): WorkflowSort { const rawKey = typeof value.sort_key === "string" ? value.sort_key : typeof value.key === "string" diff --git a/backend/src/routes/chat.ts b/backend/src/routes/chat.ts index 53ea17eff..6e7145e24 100644 --- a/backend/src/routes/chat.ts +++ b/backend/src/routes/chat.ts @@ -24,9 +24,7 @@ import { buildWordDocumentContextPrompt, } from "../lib/chat"; import { completeText } from "../lib/llm"; -import { - getUserModelSettings, -} from "../lib/userSettings"; +import { getUserModelSettings } from "../lib/userSettings"; import { checkProjectAccess } from "../lib/access"; import { safeErrorLog, safeErrorMessage } from "../lib/safeError"; @@ -41,7 +39,10 @@ const devLog = (...args: Parameters) => { const TITLE_FALLBACK = "Misc. Query"; function normalizeGeneratedTitle(raw: string): string { - const title = raw.trim().replace(/^["'`]+|["'`.,:;!?]+$/g, "").trim(); + const title = raw + .trim() + .replace(/^["'`]+|["'`.,:;!?]+$/g, "") + .trim(); if (!title) return TITLE_FALLBACK; return title.slice(0, 80); } @@ -105,13 +106,19 @@ chatRouter.get("/", requireAuth, async (req, res) => { const userId = res.locals.userId as string; const db = createServerSupabase(); const requestedLimit = Number.parseInt(String(req.query.limit ?? ""), 10); + const requestedOffset = Number.parseInt(String(req.query.offset ?? ""), 10); const limit = Number.isFinite(requestedLimit) ? Math.min(Math.max(requestedLimit, 1), 100) : null; + const offset = + Number.isFinite(requestedOffset) && requestedOffset > 0 + ? requestedOffset + : 0; const { data, error } = await db.rpc("get_chats_overview", { p_user_id: userId, p_limit: limit, + p_offset: offset, }); if (error) return void res.status(500).json({ detail: error.message }); res.json(data ?? []); @@ -156,8 +163,7 @@ chatRouter.get("/:chatId", requireAuth, async (req, res) => { const db = createServerSupabase(); const chat = await getAccessibleChat(chatId, userId, userEmail, db); - if (!chat) - return void res.status(404).json({ detail: "Chat not found" }); + if (!chat) return void res.status(404).json({ detail: "Chat not found" }); const { data: messages } = await db .from("chat_messages") @@ -183,8 +189,7 @@ async function hydrateEditStatuses( if (!Array.isArray(list)) return; for (const a of list as Record[]) { if (typeof a?.edit_id === "string") editIds.add(a.edit_id); - if (typeof a?.version_id === "string") - versionIds.add(a.version_id); + if (typeof a?.version_id === "string") versionIds.add(a.version_id); } }; for (const m of messages) { @@ -193,8 +198,7 @@ async function hydrateEditStatuses( for (const ev of content as Record[]) { if (ev?.type === "doc_edited") { collectFromAnnList(ev.annotations); - if (typeof ev.version_id === "string") - versionIds.add(ev.version_id); + if (typeof ev.version_id === "string") versionIds.add(ev.version_id); } } } @@ -258,8 +262,7 @@ async function hydrateEditStatuses( return messages.map((m) => { const next: Record = { ...m }; if (Array.isArray(m.content)) { - next.content = (m.content as Record[]).map( - (ev) => { + next.content = (m.content as Record[]).map((ev) => { if (ev?.type !== "doc_edited") return ev; let patched: Record = { ...ev, @@ -271,13 +274,11 @@ async function hydrateEditStatuses( ) { patched = { ...patched, - version_number: - versionNumberById.get(ev.version_id) ?? null, + version_number: versionNumberById.get(ev.version_id) ?? null, }; } return patched; - }, - ); + }); } return next; }); @@ -288,8 +289,7 @@ chatRouter.patch("/:chatId", requireAuth, async (req, res) => { const userId = res.locals.userId as string; const { chatId } = req.params; const title = (req.body.title ?? "").trim(); - if (!title) - return void res.status(400).json({ detail: "title is required" }); + if (!title) return void res.status(400).json({ detail: "title is required" }); const db = createServerSupabase(); const { data, error } = await db @@ -332,14 +332,10 @@ chatRouter.post("/:chatId/generate-title", requireAuth, async (req, res) => { const db = createServerSupabase(); const chat = await getAccessibleChat(chatId, userId, userEmail, db); - if (!chat) - return void res.status(404).json({ detail: "Chat not found" }); + if (!chat) return void res.status(404).json({ detail: "Chat not found" }); try { - const { title_model, api_keys } = await getUserModelSettings( - userId, - db, - ); + const { title_model, api_keys } = await getUserModelSettings(userId, db); const titleText = await completeText({ model: title_model, user: `Generate a concise title (3–6 words) for a chat in an AI Legal Platform that starts with this message. The title should describe the topic or document — do NOT include words like "Legal Assistant", "AI", "Chat", or any similar prefix. If there is not enough information to generate a title, return exactly "${TITLE_FALLBACK}". Return only the title, no quotes or punctuation.\n\nMessage: ${message.slice(0, 500)}`, @@ -348,10 +344,7 @@ chatRouter.post("/:chatId/generate-title", requireAuth, async (req, res) => { }); const title = normalizeGeneratedTitle(titleText); - await db - .from("chats") - .update({ title }) - .eq("id", chatId); + await db.from("chats").update({ title }).eq("id", chatId); res.json({ title }); } catch (err) { @@ -390,9 +383,7 @@ chatRouter.post("/", requireAuth, async (req, res) => { body.document_context, ); if (!parsedDocumentContext.ok) { - return void res - .status(400) - .json({ detail: parsedDocumentContext.detail }); + return void res.status(400).json({ detail: parsedDocumentContext.detail }); } const parsedAskInputsResponse = parseOptionalAskInputsResponse( body.ask_inputs_response, @@ -462,9 +453,7 @@ chatRouter.post("/", requireAuth, async (req, res) => { .single(); if (error || !newChat) { console.error("[chat/stream] failed to create chat", error); - return void res - .status(500) - .json({ detail: "Failed to create chat" }); + return void res.status(500).json({ detail: "Failed to create chat" }); } chatId = newChat.id as string; chatTitle = newChat.title; @@ -509,10 +498,8 @@ chatRouter.post("/", requireAuth, async (req, res) => { docIndex, nonce, ); - const { - api_keys: apiKeys, - legal_research_us: legalResearchUs, - } = await getUserModelSettings(userId, db); + const { api_keys: apiKeys, legal_research_us: legalResearchUs } = + await getUserModelSettings(userId, db); // Extra system context: the Word add-in's active-document body. The // document text is user-controlled and a prompt-injection vector, so // buildWordDocumentContextPrompt nonce-fences it before it enters the @@ -616,12 +603,8 @@ chatRouter.post("/", requireAuth, async (req, res) => { await db.from("chat_messages").insert({ chat_id: chatId, role: "assistant", - content: partial.events.length - ? partial.events - : null, - citations: partial.citations.length - ? partial.citations - : null, + content: partial.events.length ? partial.events : null, + citations: partial.citations.length ? partial.citations : null, }) ).error; if (askInputsResponse) { @@ -643,17 +626,14 @@ chatRouter.post("/", requireAuth, async (req, res) => { } console.error("[chat/stream] error:", safeErrorLog(err)); const message = safeErrorMessage(err, "Stream error"); - const errorEvents = err instanceof AssistantStreamError + const errorEvents = + err instanceof AssistantStreamError ? stripTransientAssistantEvents(err.events) : [{ type: "error" as const, message }]; const errorFullText = err instanceof AssistantStreamError ? err.fullText : ""; try { - const citations = extractCitations( - errorFullText, - docIndex, - errorEvents, - ); + const citations = extractCitations(errorFullText, docIndex, errorEvents); const saveError = askInputsResponse ? null : ( @@ -678,9 +658,7 @@ chatRouter.post("/", requireAuth, async (req, res) => { console.error("[chat/stream] failed to save error", saveErr); } try { - write( - `data: ${JSON.stringify({ type: "error", message })}\n\n`, - ); + write(`data: ${JSON.stringify({ type: "error", message })}\n\n`); write("data: [DONE]\n\n"); } catch { /* ignore */ diff --git a/backend/src/routes/library.ts b/backend/src/routes/library.ts index c8a817514..58ba6aea3 100644 --- a/backend/src/routes/library.ts +++ b/backend/src/routes/library.ts @@ -9,10 +9,44 @@ import { import { singleFileUpload } from "../lib/upload"; import { handleDocumentUpload } from "./documents"; import { parsePaginationQuery, type PaginationParams } from "../lib/pagination"; +import { normalizeSearchTerm } from "../lib/search"; export const libraryRouter = Router(); type LibraryKind = "file" | "template"; +type LibraryDocumentSortKey = + | "name" + | "type" + | "size" + | "version" + | "created" + | "updated"; + +const LIBRARY_DOCUMENT_SORT_KEYS: LibraryDocumentSortKey[] = [ + "name", + "type", + "size", + "version", + "created", + "updated", +]; +const LIBRARY_IDS_PAGE_SIZE = 1000; +const LIBRARY_IDS_MAX_PAGES = 50; +const LIBRARY_BULK_DELETE_BATCH_SIZE = 100; + +function parseLibraryDocumentSort(query: Record): { + key: LibraryDocumentSortKey; + direction: "asc" | "desc"; +} { + const rawKey = typeof query.sort_key === "string" ? query.sort_key : null; + return { + key: + rawKey && LIBRARY_DOCUMENT_SORT_KEYS.includes(rawKey as LibraryDocumentSortKey) + ? (rawKey as LibraryDocumentSortKey) + : "updated", + direction: query.sort_direction === "asc" ? "asc" : "desc", + }; +} function normalizeLibraryKind(value: unknown): LibraryKind | null { if (value === "file" || value === "files") return "file"; @@ -49,7 +83,9 @@ async function loadLibraryFolder( .eq("user_id", userId) .eq("library_kind", kind) .maybeSingle(); - return (data as { id: string; parent_folder_id: string | null } | null) ?? null; + return ( + (data as { id: string; parent_folder_id: string | null } | null) ?? null + ); } async function deleteLibraryDocumentsAndVersionFiles( @@ -58,12 +94,29 @@ async function deleteLibraryDocumentsAndVersionFiles( kind: LibraryKind, documentIds: string[], ) { - if (documentIds.length === 0) return null; + if (documentIds.length === 0) return { error: null, deletedIds: [] }; + let eligibleQuery = db + .from("documents") + .select("id") + .eq("user_id", userId) + .is("project_id", null); + eligibleQuery = + kind === "file" + ? eligibleQuery.or("library_kind.eq.file,library_kind.is.null") + : eligibleQuery.eq("library_kind", kind); + const { data: eligibleDocuments, error: eligibleError } = + await eligibleQuery.in("id", documentIds); + if (eligibleError) return { error: eligibleError, deletedIds: [] }; + const eligibleIds = (eligibleDocuments ?? []).map( + (document) => document.id as string, + ); + if (eligibleIds.length === 0) return { error: null, deletedIds: [] }; + const { data: versions, error: versionsError } = await db .from("document_versions") .select("storage_path, pdf_storage_path") - .in("document_id", documentIds); - if (versionsError) return versionsError; + .in("document_id", eligibleIds); + if (versionsError) return { error: versionsError, deletedIds: [] }; const paths = new Set(); for (const version of versions ?? []) { @@ -88,8 +141,8 @@ async function deleteLibraryDocumentsAndVersionFiles( kind === "file" ? deleteQuery.or("library_kind.eq.file,library_kind.is.null") : deleteQuery.eq("library_kind", kind); - const { error } = await deleteQuery.in("id", documentIds); - return error ?? null; + const { error } = await deleteQuery.in("id", eligibleIds); + return { error: error ?? null, deletedIds: error ? [] : eligibleIds }; } // Folders per level are assumed to stay small (organizational containers, @@ -132,8 +185,10 @@ async function loadLibraryLevel( ? foldersQuery.is("parent_folder_id", null) : foldersQuery.eq("parent_folder_id", parentFolderId); - const [{ data: docs, error: docsError }, { data: folders, error: foldersError }] = - await Promise.all([ + const [ + { data: docs, error: docsError }, + { data: folders, error: foldersError }, + ] = await Promise.all([ documentsQuery.order("created_at", { ascending: true }), foldersQuery.order("created_at", { ascending: true }), ]); @@ -173,6 +228,8 @@ async function loadLibraryLevel( } // GET /library/:kind +// Directory mode is the default. Pass parent_folder_id to load one folder +// level, or view=search for flat search/filter/sort results. libraryRouter.get("/:kind", requireAuth, async (req, res) => { const userId = res.locals.userId as string; const kind = normalizeLibraryKind(req.params.kind); @@ -180,7 +237,45 @@ libraryRouter.get("/:kind", requireAuth, async (req, res) => { const db = createServerSupabase(); const pagination = parsePaginationQuery(req.query as Record); - const result = await loadLibraryLevel(db, userId, kind, null, pagination); + if (req.query.view === "search") { + const searchTerm = normalizeSearchTerm(req.query.search); + const fileType = + normalizeSearchTerm(req.query.file_type)?.toLowerCase() ?? null; + const sort = parseLibraryDocumentSort( + req.query as Record, + ); + const { data, error } = await db.rpc("search_library_documents", { + p_user_id: userId, + p_library_kind: kind, + p_limit: pagination.limit + 1, + p_offset: pagination.offset, + p_search_term: searchTerm, + p_file_type: fileType, + p_sort_key: sort.key, + p_sort_direction: sort.direction, + }); + if (error) return void res.status(500).json({ detail: error.message }); + + const rows = (data ?? []) as Record[]; + return void res.json({ + documents: rows.slice(0, pagination.limit).map(mapLibraryDocument), + documentsHasMore: rows.length > pagination.limit, + }); + } + + const parentFolderId = normalizeSearchTerm(req.query.parent_folder_id); + if (parentFolderId) { + const folder = await loadLibraryFolder(db, userId, kind, parentFolderId); + if (!folder) + return void res.status(404).json({ detail: "Folder not found" }); + } + const result = await loadLibraryLevel( + db, + userId, + kind, + parentFolderId, + pagination, + ); if (result.error) return void res.status(500).json({ detail: result.error }); res.json({ documents: result.documents, @@ -189,33 +284,165 @@ libraryRouter.get("/:kind", requireAuth, async (req, res) => { }); }); -// GET /library/:kind/folders/:folderId/children -libraryRouter.get( - "/:kind/folders/:folderId/children", +// POST /library/:kind/levels +// Refresh several already-open directory levels through one bounded API call. +libraryRouter.post("/:kind/levels", requireAuth, async (req, res) => { + const userId = res.locals.userId as string; + const kind = normalizeLibraryKind(req.params.kind); + if (!kind) return void res.status(404).json({ detail: "Library not found" }); + const rawLevels: unknown[] = Array.isArray(req.body?.levels) + ? req.body.levels + : []; + const seen = new Set(); + const levels = rawLevels.flatMap((value: unknown) => { + if (!value || typeof value !== "object") return []; + const row = value as { parentId?: unknown; limit?: unknown }; + const parentId = typeof row.parentId === "string" ? row.parentId : null; + const key = parentId ?? "root"; + if (seen.has(key)) return []; + seen.add(key); + const requestedLimit = Number(row.limit); + return [ + { + parentId, + limit: Number.isFinite(requestedLimit) + ? Math.max(1, Math.min(500, Math.floor(requestedLimit))) + : 50, + }, + ]; + }); + if (levels.length === 0 || levels.length > 100) { + return void res + .status(400) + .json({ detail: "1 to 100 levels are required" }); + } + + const db = createServerSupabase(); + const results: Array<{ + parentId: string | null; + result: Awaited>; + }> = new Array(levels.length); + let nextLevelIndex = 0; + await Promise.all( + Array.from({ length: Math.min(8, levels.length) }, async () => { + while (nextLevelIndex < levels.length) { + const index = nextLevelIndex++; + const level = levels[index]; + results[index] = { + parentId: level.parentId, + result: await loadLibraryLevel(db, userId, kind, level.parentId, { + limit: level.limit, + offset: 0, + }), + }; + } + }), + ); + const failed = results.find(({ result }) => result.error); + if (failed?.result.error) { + return void res.status(500).json({ detail: failed.result.error }); + } + res.json({ + levels: results.map(({ parentId, result }) => ({ + parentId, + documents: result.documents, + folders: result.folders, + documentsHasMore: result.documentsHasMore, + })), + }); +}); + +// GET /library/:kind/filter-options +libraryRouter.get("/:kind/filter-options", requireAuth, async (req, res) => { + const userId = res.locals.userId as string; + const kind = normalizeLibraryKind(req.params.kind); + if (!kind) return void res.status(404).json({ detail: "Library not found" }); + + const db = createServerSupabase(); + const { data, error } = await db.rpc("get_library_filter_options", { + p_user_id: userId, + p_library_kind: kind, + }); + if (error) return void res.status(500).json({ detail: error.message }); + const row = (data?.[0] ?? {}) as { file_types?: unknown }; + res.json({ + fileTypes: Array.isArray(row.file_types) + ? row.file_types.filter( + (value): value is string => typeof value === "string", + ) + : [], + }); +}); + +// GET /library/:kind/ids +// Complete ID-only result set for select-all across unloaded pages/folders. +libraryRouter.get("/:kind/ids", requireAuth, async (req, res) => { + const userId = res.locals.userId as string; + const kind = normalizeLibraryKind(req.params.kind); + if (!kind) return void res.status(404).json({ detail: "Library not found" }); + + const db = createServerSupabase(); + const searchTerm = normalizeSearchTerm(req.query.search); + const fileType = normalizeSearchTerm(req.query.file_type)?.toLowerCase() ?? null; + const ids: string[] = []; + let offset = 0; + for (let page = 0; page < LIBRARY_IDS_MAX_PAGES; page++) { + const { data, error } = await db.rpc("get_library_document_ids", { + p_user_id: userId, + p_library_kind: kind, + p_search_term: searchTerm, + p_file_type: fileType, + p_limit: LIBRARY_IDS_PAGE_SIZE, + p_offset: offset, + }); + if (error) return void res.status(500).json({ detail: error.message }); + const rows = (data ?? []) as { id: string }[]; + if (rows.length === 0) break; + ids.push(...rows.map((row) => row.id)); + offset += rows.length; + } + res.json(ids); +}); + +// POST /library/:kind/documents/bulk-delete +// One bounded backend operation replaces an unbounded browser request burst. +libraryRouter.post( + "/:kind/documents/bulk-delete", requireAuth, async (req, res) => { const userId = res.locals.userId as string; const kind = normalizeLibraryKind(req.params.kind); - if (!kind) return void res.status(404).json({ detail: "Library not found" }); + if (!kind) + return void res.status(404).json({ detail: "Library not found" }); + const ids: string[] = Array.from( + new Set( + (Array.isArray(req.body?.ids) ? req.body.ids : []).filter( + (id: unknown): id is string => + typeof id === "string" && id.length > 0, + ), + ), + ); + if (ids.length === 0) return void res.json({ deletedIds: [] }); const db = createServerSupabase(); - const folder = await loadLibraryFolder(db, userId, kind, req.params.folderId); - if (!folder) return void res.status(404).json({ detail: "Folder not found" }); - - const pagination = parsePaginationQuery(req.query as Record); - const result = await loadLibraryLevel( - db, - userId, - kind, - folder.id, - pagination, - ); - if (result.error) return void res.status(500).json({ detail: result.error }); - res.json({ - documents: result.documents, - folders: result.folders, - documentsHasMore: result.documentsHasMore, - }); + const deletedIds: string[] = []; + for ( + let offset = 0; + offset < ids.length; + offset += LIBRARY_BULK_DELETE_BATCH_SIZE + ) { + const batch = ids.slice(offset, offset + LIBRARY_BULK_DELETE_BATCH_SIZE); + const result = await deleteLibraryDocumentsAndVersionFiles( + db, + userId, + kind, + batch, + ); + if (result.error) + return void res.status(500).json({ detail: result.error.message }); + deletedIds.push(...result.deletedIds); + } + res.json({ deletedIds }); }, ); @@ -227,7 +454,8 @@ libraryRouter.post( async (req, res) => { const userId = res.locals.userId as string; const kind = normalizeLibraryKind(req.params.kind); - if (!kind) return void res.status(404).json({ detail: "Library not found" }); + if (!kind) + return void res.status(404).json({ detail: "Library not found" }); const db = createServerSupabase(); await handleDocumentUpload(req, res, userId, null, db, { libraryKind: kind, @@ -270,16 +498,24 @@ libraryRouter.post("/:kind/folders", requireAuth, async (req, res) => { }); // PATCH /library/:kind/folders/:folderId -libraryRouter.patch("/:kind/folders/:folderId", requireAuth, async (req, res) => { +libraryRouter.patch( + "/:kind/folders/:folderId", + requireAuth, + async (req, res) => { const userId = res.locals.userId as string; const kind = normalizeLibraryKind(req.params.kind); - if (!kind) return void res.status(404).json({ detail: "Library not found" }); + if (!kind) + return void res.status(404).json({ detail: "Library not found" }); const { folderId } = req.params; - const body = req.body as { name?: string; parent_folder_id?: string | null }; + const body = req.body as { + name?: string; + parent_folder_id?: string | null; + }; const db = createServerSupabase(); const folder = await loadLibraryFolder(db, userId, kind, folderId); - if (!folder) return void res.status(404).json({ detail: "Folder not found" }); + if (!folder) + return void res.status(404).json({ detail: "Folder not found" }); const updates: Record = { updated_at: new Date().toISOString(), @@ -301,7 +537,9 @@ libraryRouter.patch("/:kind/folders/:folderId", requireAuth, async (req, res) => } const parent = await loadLibraryFolder(db, userId, kind, cur); if (!parent) - return void res.status(404).json({ detail: "Parent folder not found" }); + return void res + .status(404) + .json({ detail: "Parent folder not found" }); cur = parent.parent_folder_id ?? null; } } @@ -319,13 +557,18 @@ libraryRouter.patch("/:kind/folders/:folderId", requireAuth, async (req, res) => if (error || !data) return void res.status(404).json({ detail: "Folder not found" }); res.json(data); -}); + }, +); // DELETE /library/:kind/folders/:folderId -libraryRouter.delete("/:kind/folders/:folderId", requireAuth, async (req, res) => { +libraryRouter.delete( + "/:kind/folders/:folderId", + requireAuth, + async (req, res) => { const userId = res.locals.userId as string; const kind = normalizeLibraryKind(req.params.kind); - if (!kind) return void res.status(404).json({ detail: "Library not found" }); + if (!kind) + return void res.status(404).json({ detail: "Library not found" }); const { folderId } = req.params; const db = createServerSupabase(); @@ -371,17 +614,20 @@ libraryRouter.delete("/:kind/folders/:folderId", requireAuth, async (req, res) = "library_folder_id", [...folderIds], ); - if (docsError) return void res.status(500).json({ detail: docsError.message }); + if (docsError) + return void res.status(500).json({ detail: docsError.message }); const docIds = (docs ?? []).map((doc) => doc.id as string); - const deleteDocsError = await deleteLibraryDocumentsAndVersionFiles( + const deleteDocsResult = await deleteLibraryDocumentsAndVersionFiles( db, userId, kind, docIds, ); - if (deleteDocsError) - return void res.status(500).json({ detail: deleteDocsError.message }); + if (deleteDocsResult.error) + return void res + .status(500) + .json({ detail: deleteDocsResult.error.message }); const { error } = await db .from("library_folders") @@ -391,7 +637,8 @@ libraryRouter.delete("/:kind/folders/:folderId", requireAuth, async (req, res) = .eq("library_kind", kind); if (error) return void res.status(500).json({ detail: error.message }); res.status(204).send(); -}); + }, +); // PATCH /library/:kind/documents/:documentId/folder libraryRouter.patch( @@ -400,7 +647,8 @@ libraryRouter.patch( async (req, res) => { const userId = res.locals.userId as string; const kind = normalizeLibraryKind(req.params.kind); - if (!kind) return void res.status(404).json({ detail: "Library not found" }); + if (!kind) + return void res.status(404).json({ detail: "Library not found" }); const { documentId } = req.params; const { folder_id } = req.body as { folder_id: string | null }; @@ -425,9 +673,7 @@ libraryRouter.patch( kind === "file" ? moveQuery.or("library_kind.eq.file,library_kind.is.null") : moveQuery.eq("library_kind", kind); - const { data, error } = await moveQuery - .select("*") - .single(); + const { data, error } = await moveQuery.select("*").single(); if (error || !data) return void res.status(404).json({ detail: "Document not found" }); res.json(mapLibraryDocument(data)); @@ -441,7 +687,8 @@ libraryRouter.patch( async (req, res) => { const userId = res.locals.userId as string; const kind = normalizeLibraryKind(req.params.kind); - if (!kind) return void res.status(404).json({ detail: "Library not found" }); + if (!kind) + return void res.status(404).json({ detail: "Library not found" }); const { documentId } = req.params; const db = createServerSupabase(); @@ -456,7 +703,8 @@ libraryRouter.patch( ? docQuery.or("library_kind.eq.file,library_kind.is.null") : docQuery.eq("library_kind", kind); const { data: doc } = await docQuery.single(); - if (!doc) return void res.status(404).json({ detail: "Document not found" }); + if (!doc) + return void res.status(404).json({ detail: "Document not found" }); const active = doc.current_version_id ? await db @@ -484,9 +732,7 @@ libraryRouter.patch( kind === "file" ? updateQuery.or("library_kind.eq.file,library_kind.is.null") : updateQuery.eq("library_kind", kind); - const { data: updated, error } = await updateQuery - .select("*") - .single(); + const { data: updated, error } = await updateQuery.select("*").single(); if (error || !updated) return void res.status(404).json({ detail: "Document not found" }); diff --git a/backend/src/routes/projects.ts b/backend/src/routes/projects.ts index 1dfd62b7c..121875004 100644 --- a/backend/src/routes/projects.ts +++ b/backend/src/routes/projects.ts @@ -1,4 +1,4 @@ -import { Router } from "express"; +import { Router, type Request, type Response } from "express"; import { requireAuth, requireMfaIfEnrolled } from "../middleware/auth"; import { createServerSupabase } from "../lib/supabase"; import { createClient } from "@supabase/supabase-js"; @@ -75,7 +75,10 @@ async function deleteProjectDocumentsAndVersionFiles( if (typeof v.storage_path === "string" && v.storage_path.length > 0) { paths.add(v.storage_path); } - if (typeof v.pdf_storage_path === "string" && v.pdf_storage_path.length > 0) { + if ( + typeof v.pdf_storage_path === "string" && + v.pdf_storage_path.length > 0 + ) { paths.add(v.pdf_storage_path); } } @@ -105,7 +108,10 @@ async function attachDocumentOwnerLabels( .select("user_id, display_name") .in("user_id", ownerIds); if (profilesError) { - console.warn("[projects] failed to load document owner profiles", profilesError); + console.warn( + "[projects] failed to load document owner profiles", + profilesError, + ); } for (const profile of profiles ?? []) { const displayName = @@ -117,11 +123,11 @@ async function attachDocumentOwnerLabels( } } - for (const doc of docs as ({ + for (const doc of docs as { user_id?: string | null; owner_email?: string | null; owner_display_name?: string | null; - })[]) { + }[]) { if (!doc.user_id) continue; doc.owner_email = null; doc.owner_display_name = displayNameByUserId.get(doc.user_id) ?? null; @@ -144,7 +150,10 @@ async function attachChatCreatorLabels( .select("user_id, display_name") .in("user_id", creatorIds); if (profilesError) { - console.warn("[projects] failed to load chat creator profiles", profilesError); + console.warn( + "[projects] failed to load chat creator profiles", + profilesError, + ); } for (const profile of profiles ?? []) { const displayName = @@ -156,15 +165,67 @@ async function attachChatCreatorLabels( } } - for (const chat of chats as ({ + for (const chat of chats as { user_id?: string | null; creator_display_name?: string | null; - })[]) { + }[]) { if (!chat.user_id) continue; chat.creator_display_name = displayNameByUserId.get(chat.user_id) ?? null; } } +async function loadProjectDirectoryLevel( + db: ReturnType, + projectId: string, + parentFolderId: string | null, + pagination: { limit: number; offset: number }, +) { + let documentsQuery = db + .from("documents") + .select("*") + .eq("project_id", projectId); + let foldersQuery = db + .from("project_subfolders") + .select("*") + .eq("project_id", projectId); + documentsQuery = parentFolderId + ? documentsQuery.eq("folder_id", parentFolderId) + : documentsQuery.is("folder_id", null); + foldersQuery = parentFolderId + ? foldersQuery.eq("parent_folder_id", parentFolderId) + : foldersQuery.is("parent_folder_id", null); + + const [ + { data: documents, error: documentsError }, + { data: folders, error: foldersError }, + ] = await Promise.all([ + documentsQuery + .order("created_at", { ascending: true }) + .range(pagination.offset, pagination.offset + pagination.limit), + foldersQuery.order("created_at", { ascending: true }), + ]); + if (documentsError) + return { error: documentsError, documents: [], folders: [] }; + if (foldersError) return { error: foldersError, documents: [], folders: [] }; + + const rows = documents ?? []; + const documentsHasMore = rows.length > pagination.limit; + const page = (documentsHasMore ? rows.slice(0, pagination.limit) : rows) as { + id: string; + user_id?: string | null; + current_version_id?: string | null; + }[]; + await attachLatestVersionNumbers(db, page); + await attachActiveVersionPaths(db, page); + await attachDocumentOwnerLabels(db, page); + return { + error: null, + documents: page, + folders: folders ?? [], + documentsHasMore, + }; +} + // GET /projects // Pass ?include=documents to also receive each project's documents in the // same response. The directory pickers (useDirectoryData) previously fanned @@ -174,11 +235,10 @@ async function attachChatCreatorLabels( // and a fixed number of queries regardless of project count. // // Pagination is opt-in via query params (limit/offset/search/sort_key or -// key/scope) — only ProjectsOverview.tsx sends them. Every other caller -// (sidebar nav, document-picker directory view, tabular-review project -// pickers) calls this with no query params at all and must keep getting the -// full, unpaginated list back, so the branch below must never default to -// paginating a request that didn't ask for it. +// key/scope). ProjectsOverview.tsx sends them. Legacy tabular-review project +// pickers call this with no query params and must keep getting the full, +// unpaginated list, so the branch below must never default +// to paginating a request that didn't ask for it. const PROJECT_PAGINATION_QUERY_KEYS = [ "limit", "offset", @@ -196,7 +256,25 @@ projectsRouter.get("/", requireAuth, async (req, res) => { const userId = res.locals.userId as string; const userEmail = res.locals.userEmail as string | undefined; const includeDocuments = req.query.include === "documents"; + + if (req.query.view === "directory-search") { + return handleProjectDirectorySearch(req, res); + } + const db = createServerSupabase(); + if (req.query.view === "summary") { + const pagination = parsePaginationQuery( + req.query as Record, + ); + const { data, error } = await db.rpc("get_project_summaries", { + p_user_id: userId, + p_user_email: userEmail ?? null, + p_limit: pagination.limit, + p_offset: pagination.offset, + }); + if (error) return void res.status(500).json({ detail: error.message }); + return void res.json(data ?? []); + } const hasPaginationParams = PROJECT_PAGINATION_QUERY_KEYS.some( (key) => req.query[key] !== undefined, @@ -207,7 +285,9 @@ projectsRouter.get("/", requireAuth, async (req, res) => { userId, userEmail, scope: parseProjectScope(req.query.scope), - pagination: parsePaginationQuery(req.query as Record), + pagination: parsePaginationQuery( + req.query as Record, + ), searchTerm: normalizeSearchTerm(req.query.search), sort: parseProjectSort(req.query as Record), practice: normalizeSearchTerm(req.query.practice), @@ -330,6 +410,163 @@ projectsRouter.post("/", requireAuth, async (req, res) => { res.status(201).json({ ...data, documents: [] }); }); +// GET /projects?view=directory-search +// Flat filename/project matches for the document picker. Search results do +// not pretend that a partially loaded project tree is a complete result set. +async function handleProjectDirectorySearch(req: Request, res: Response) { + const userId = res.locals.userId as string; + const userEmail = res.locals.userEmail as string | undefined; + const searchTerm = normalizeSearchTerm(req.query.search); + if (!searchTerm) return void res.json([]); + const pagination = parsePaginationQuery( + req.query as Record, + ); + const db = createServerSupabase(); + + const projectQueries = [ + db.from("projects").select("*").eq("user_id", userId), + ]; + if (userEmail) { + projectQueries.push( + db.from("projects").select("*").contains("shared_with", [userEmail]), + ); + } + const projectResults = await Promise.all(projectQueries); + const projectError = projectResults.find((result) => result.error)?.error; + if (projectError) + return void res.status(500).json({ detail: projectError.message }); + const projectsById = new Map>(); + for (const result of projectResults) { + for (const project of result.data ?? []) { + projectsById.set(project.id as string, project); + } + } + const accessibleProjectIds = [...projectsById.keys()]; + if (accessibleProjectIds.length === 0) return void res.json([]); + + const escaped = searchTerm.replace(/[%_]/g, (value) => `\\${value}`); + const { data: versions, error: versionsError } = await db + .from("document_versions") + .select("id") + .ilike("filename", `%${escaped}%`) + .is("deleted_at", null); + if (versionsError) + return void res.status(500).json({ detail: versionsError.message }); + + const versionIds = (versions ?? []).map((version) => version.id as string); + let matchedDocuments: Record[] = []; + if (versionIds.length > 0) { + const { data, error } = await db + .from("documents") + .select("*") + .in("project_id", accessibleProjectIds) + .in("current_version_id", versionIds); + if (error) return void res.status(500).json({ detail: error.message }); + matchedDocuments = (data ?? []) as Record[]; + await attachLatestVersionNumbers( + db, + matchedDocuments as { id: string; current_version_id?: string | null }[], + ); + await attachActiveVersionPaths( + db, + matchedDocuments as { id: string; current_version_id?: string | null }[], + ); + await attachDocumentOwnerLabels( + db, + matchedDocuments as { user_id?: string | null }[], + ); + } + + const normalized = searchTerm.toLowerCase(); + const documentProjectIds = new Set( + matchedDocuments.map((document) => document.project_id as string), + ); + const matches = [...projectsById.values()] + .filter((project) => { + const name = String(project.name ?? "").toLowerCase(); + const cmNumber = String(project.cm_number ?? "").toLowerCase(); + return ( + name.includes(normalized) || + cmNumber.includes(normalized) || + documentProjectIds.has(project.id as string) + ); + }) + .sort((a, b) => + String(b.updated_at ?? "").localeCompare(String(a.updated_at ?? "")), + ) + .slice(pagination.offset, pagination.offset + pagination.limit + 1) + .map((project) => ({ + ...project, + is_owner: project.user_id === userId, + documents: matchedDocuments.filter( + (document) => document.project_id === project.id, + ), + folders: [], + })); + res.json(matches); +} + +// GET /projects/:projectId/directory +// Returns one folder level so file pickers can expand projects without +// downloading every document and subfolder for every project up front. +projectsRouter.get("/:projectId/directory", requireAuth, async (req, res) => { + const userId = res.locals.userId as string; + const userEmail = res.locals.userEmail as string | undefined; + const { projectId } = req.params; + const db = createServerSupabase(); + const access = await checkProjectAccess(projectId, userId, userEmail, db); + if (!access.ok) + return void res.status(404).json({ detail: "Project not found" }); + + const pagination = parsePaginationQuery(req.query as Record); + const result = await loadProjectDirectoryLevel( + db, + projectId, + normalizeOptionalString(req.query.parent_folder_id), + pagination, + ); + if (result.error) + return void res.status(500).json({ detail: result.error.message }); + res.json({ + documents: result.documents, + folders: result.folders, + documentsHasMore: result.documentsHasMore, + }); +}); + +// GET /projects/filter-options (must come before /:projectId routes) +projectsRouter.get("/filter-options", requireAuth, async (req, res) => { + const userId = res.locals.userId as string; + const userEmail = res.locals.userEmail as string | undefined; + const db = createServerSupabase(); + const { data, error } = await db.rpc("get_project_filter_options", { + p_user_id: userId, + p_user_email: userEmail ?? null, + }); + if (error) return void res.status(500).json({ detail: error.message }); + + const row = (data?.[0] ?? {}) as { + practices?: unknown; + owners?: unknown; + }; + const practices = Array.isArray(row.practices) + ? row.practices.filter( + (value): value is string => typeof value === "string", + ) + : []; + const owners = Array.isArray(row.owners) + ? row.owners.flatMap((value) => { + if (!value || typeof value !== "object") return []; + const option = value as { value?: unknown; label?: unknown }; + return typeof option.value === "string" && + typeof option.label === "string" + ? [{ value: option.value, label: option.label }] + : []; + }) + : []; + res.json({ practices, owners }); +}); + // GET /projects/ids (must come before /:projectId routes) // Lightweight id + owner list for every project matching the current // filters — backs "select all matching" bulk actions so the client doesn't @@ -371,9 +608,6 @@ projectsRouter.get("/ids", requireAuth, async (req, res) => { const rows = (data ?? []) as { id: string; user_id: string }[]; if (rows.length === 0) break; ids.push(...rows); - // Advance by what actually came back, not the requested page size — if - // PostgREST's cap is lower than PROJECT_IDS_PAGE_SIZE this still - // converges correctly instead of skipping rows. offset += rows.length; } @@ -404,8 +638,16 @@ projectsRouter.get("/:projectId", requireAuth, async (req, res) => { return void res.status(404).json({ detail: "Project not found" }); const [{ data: docs }, { data: folderData }] = await Promise.all([ - db.from("documents").select("*").eq("project_id", projectId).order("created_at", { ascending: true }), - db.from("project_subfolders").select("*").eq("project_id", projectId).order("created_at", { ascending: true }), + db + .from("documents") + .select("*") + .eq("project_id", projectId) + .order("created_at", { ascending: true }), + db + .from("project_subfolders") + .select("*") + .eq("project_id", projectId) + .order("created_at", { ascending: true }), ]); const docsTyped = (docs ?? []) as unknown as { id: string; @@ -442,12 +684,10 @@ projectsRouter.get("/:projectId/people", requireAuth, async (req, res) => { return void res.status(404).json({ detail: "Project not found" }); const isOwner = project.user_id === userId; - const sharedWith = (Array.isArray(project.shared_with) - ? (project.shared_with as string[]) - : [] + const sharedWith = ( + Array.isArray(project.shared_with) ? (project.shared_with as string[]) : [] ).map((e) => e.toLowerCase()); - const isShared = - !!userEmail && sharedWith.includes(userEmail.toLowerCase()); + const isShared = !!userEmail && sharedWith.includes(userEmail.toLowerCase()); if (!isOwner && !isShared) return void res.status(404).json({ detail: "Project not found" }); @@ -524,8 +764,16 @@ projectsRouter.patch("/:projectId", requireAuth, async (req, res) => { return void res.status(404).json({ detail: "Project not found" }); const [{ data: docs }, { data: folderData }] = await Promise.all([ - db.from("documents").select("*").eq("project_id", projectId).order("created_at", { ascending: true }), - db.from("project_subfolders").select("*").eq("project_id", projectId).order("created_at", { ascending: true }), + db + .from("documents") + .select("*") + .eq("project_id", projectId) + .order("created_at", { ascending: true }), + db + .from("project_subfolders") + .select("*") + .eq("project_id", projectId) + .order("created_at", { ascending: true }), ]); const docsTyped = (docs ?? []) as unknown as { id: string; @@ -642,10 +890,9 @@ projectsRouter.post( .single(); if (!doc) return void res.status(404).json({ detail: "Document not found" }); - await attachActiveVersionPaths( - db, - [doc as { id: string; current_version_id?: string | null }], - ); + await attachActiveVersionPaths(db, [ + doc as { id: string; current_version_id?: string | null }, + ]); // Already in this project — idempotent if (doc.project_id === projectId) return void res.json(doc); @@ -663,11 +910,12 @@ projectsRouter.post( .select("*") .single(); if (error || !updated) - return void res.status(500).json({ detail: "Failed to update document" }); - await attachActiveVersionPaths( - db, - [updated as { id: string; current_version_id?: string | null }], - ); + return void res + .status(500) + .json({ detail: "Failed to update document" }); + await attachActiveVersionPaths(db, [ + updated as { id: string; current_version_id?: string | null }, + ]); return void res.json(updated); } else { // Belongs to another project → duplicate record AND copy the @@ -782,10 +1030,9 @@ projectsRouter.post( ); } - await attachActiveVersionPaths( - db, - [updatedCopy as { id: string; current_version_id?: string | null }], - ); + await attachActiveVersionPaths(db, [ + updatedCopy as { id: string; current_version_id?: string | null }, + ]); return void res.status(201).json(updatedCopy); } catch (err) { console.error("[projects/documents/copy] failed", err); @@ -803,7 +1050,10 @@ projectsRouter.post( ); // PATCH /projects/:projectId/documents/:documentId — rename a project document -projectsRouter.patch("/:projectId/documents/:documentId", requireAuth, async (req, res) => { +projectsRouter.patch( + "/:projectId/documents/:documentId", + requireAuth, + async (req, res) => { const userId = res.locals.userId as string; const userEmail = res.locals.userEmail as string | undefined; const { projectId, documentId } = req.params; @@ -831,8 +1081,7 @@ projectsRouter.patch("/:projectId/documents/:documentId", requireAuth, async (re .single() : null; const currentName = - typeof active?.data?.filename === "string" && - active.data.filename.trim() + typeof active?.data?.filename === "string" && active.data.filename.trim() ? active.data.filename.trim() : "Untitled document"; const filename = normalizeDocumentFilename(req.body?.filename, currentName); @@ -861,7 +1110,8 @@ projectsRouter.patch("/:projectId/documents/:documentId", requireAuth, async (re ...updated, filename, }); -}); + }, +); // POST /projects/:projectId/documents projectsRouter.post( @@ -915,77 +1165,124 @@ projectsRouter.post("/:projectId/folders", requireAuth, async (req, res) => { const userId = res.locals.userId as string; const userEmail = res.locals.userEmail as string | undefined; const { projectId } = req.params; - const { name, parent_folder_id } = req.body as { name: string; parent_folder_id?: string | null }; - if (!name?.trim()) return void res.status(400).json({ detail: "name is required" }); + const { name, parent_folder_id } = req.body as { + name: string; + parent_folder_id?: string | null; + }; + if (!name?.trim()) + return void res.status(400).json({ detail: "name is required" }); const db = createServerSupabase(); const access = await checkProjectAccess(projectId, userId, userEmail, db); - if (!access.ok) return void res.status(404).json({ detail: "Project not found" }); + if (!access.ok) + return void res.status(404).json({ detail: "Project not found" }); // Verify parent folder belongs to this project if (parent_folder_id) { - const { data: parent } = await db.from("project_subfolders").select("id").eq("id", parent_folder_id).eq("project_id", projectId).single(); - if (!parent) return void res.status(404).json({ detail: "Parent folder not found" }); + const { data: parent } = await db + .from("project_subfolders") + .select("id") + .eq("id", parent_folder_id) + .eq("project_id", projectId) + .single(); + if (!parent) + return void res.status(404).json({ detail: "Parent folder not found" }); } - const { data, error } = await db.from("project_subfolders").insert({ + const { data, error } = await db + .from("project_subfolders") + .insert({ project_id: projectId, user_id: userId, name: name.trim(), parent_folder_id: parent_folder_id ?? null, - }).select("*").single(); + }) + .select("*") + .single(); if (error) return void res.status(500).json({ detail: error.message }); res.status(201).json(data); }); // PATCH /projects/:projectId/folders/:folderId -projectsRouter.patch("/:projectId/folders/:folderId", requireAuth, async (req, res) => { +projectsRouter.patch( + "/:projectId/folders/:folderId", + requireAuth, + async (req, res) => { const userId = res.locals.userId as string; const userEmail = res.locals.userEmail as string | undefined; const { projectId, folderId } = req.params; - const body = req.body as { name?: string; parent_folder_id?: string | null }; + const body = req.body as { + name?: string; + parent_folder_id?: string | null; + }; const db = createServerSupabase(); const access = await checkProjectAccess(projectId, userId, userEmail, db); - if (!access.ok) return void res.status(404).json({ detail: "Project not found" }); + if (!access.ok) + return void res.status(404).json({ detail: "Project not found" }); - const updates: Record = { updated_at: new Date().toISOString() }; + const updates: Record = { + updated_at: new Date().toISOString(), + }; if (body.name != null) updates.name = body.name.trim(); if ("parent_folder_id" in body) { // Cycle check: walk up the tree from the proposed parent to ensure folderId is not an ancestor if (body.parent_folder_id) { - const parent = await loadProjectFolder(db, projectId, body.parent_folder_id); - if (!parent) return void res.status(404).json({ detail: "Parent folder not found" }); + const parent = await loadProjectFolder( + db, + projectId, + body.parent_folder_id, + ); + if (!parent) + return void res + .status(404) + .json({ detail: "Parent folder not found" }); let cur: string | null = body.parent_folder_id; while (cur) { - if (cur === folderId) return void res.status(400).json({ detail: "Cannot move a folder into itself or a descendant" }); + if (cur === folderId) + return void res.status(400).json({ + detail: "Cannot move a folder into itself or a descendant", + }); const p = await loadProjectFolder(db, projectId, cur); - if (!p) return void res.status(404).json({ detail: "Parent folder not found" }); + if (!p) + return void res + .status(404) + .json({ detail: "Parent folder not found" }); cur = p?.parent_folder_id ?? null; } } updates.parent_folder_id = body.parent_folder_id ?? null; } - const { data, error } = await db.from("project_subfolders") + const { data, error } = await db + .from("project_subfolders") .update(updates) - .eq("id", folderId).eq("project_id", projectId) - .select("*").single(); - if (error || !data) return void res.status(404).json({ detail: "Folder not found" }); + .eq("id", folderId) + .eq("project_id", projectId) + .select("*") + .single(); + if (error || !data) + return void res.status(404).json({ detail: "Folder not found" }); res.json(data); -}); + }, +); // DELETE /projects/:projectId/folders/:folderId -projectsRouter.delete("/:projectId/folders/:folderId", requireAuth, async (req, res) => { +projectsRouter.delete( + "/:projectId/folders/:folderId", + requireAuth, + async (req, res) => { const userId = res.locals.userId as string; const userEmail = res.locals.userEmail as string | undefined; const { projectId, folderId } = req.params; const db = createServerSupabase(); const access = await checkProjectAccess(projectId, userId, userEmail, db); - if (!access.ok) return void res.status(404).json({ detail: "Project not found" }); - if (!access.isOwner) return void res.status(404).json({ detail: "Project not found" }); + if (!access.ok) + return void res.status(404).json({ detail: "Project not found" }); + if (!access.isOwner) + return void res.status(404).json({ detail: "Project not found" }); const { data: allFolders, error: foldersError } = await db .from("project_subfolders") @@ -1019,7 +1316,8 @@ projectsRouter.delete("/:projectId/folders/:folderId", requireAuth, async (req, .select("id") .eq("project_id", projectId) .in("folder_id", [...folderIds]); - if (docsError) return void res.status(500).json({ detail: docsError.message }); + if (docsError) + return void res.status(500).json({ detail: docsError.message }); const docIds = (docs ?? []).map((d) => d.id as string); const deleteDocsError = await deleteProjectDocumentsAndVersionFiles( @@ -1030,14 +1328,21 @@ projectsRouter.delete("/:projectId/folders/:folderId", requireAuth, async (req, if (deleteDocsError) return void res.status(500).json({ detail: deleteDocsError.message }); - const { error } = await db.from("project_subfolders") - .delete().eq("id", folderId).eq("project_id", projectId); + const { error } = await db + .from("project_subfolders") + .delete() + .eq("id", folderId) + .eq("project_id", projectId); if (error) return void res.status(500).json({ detail: error.message }); res.status(204).send(); -}); + }, +); // PATCH /projects/:projectId/documents/:documentId/folder — move doc to a folder -projectsRouter.patch("/:projectId/documents/:documentId/folder", requireAuth, async (req, res) => { +projectsRouter.patch( + "/:projectId/documents/:documentId/folder", + requireAuth, + async (req, res) => { const userId = res.locals.userId as string; const userEmail = res.locals.userEmail as string | undefined; const { projectId, documentId } = req.params; @@ -1045,20 +1350,30 @@ projectsRouter.patch("/:projectId/documents/:documentId/folder", requireAuth, as const db = createServerSupabase(); const access = await checkProjectAccess(projectId, userId, userEmail, db); - if (!access.ok) return void res.status(404).json({ detail: "Project not found" }); + if (!access.ok) + return void res.status(404).json({ detail: "Project not found" }); if (folder_id) { const folder = await loadProjectFolder(db, projectId, folder_id); - if (!folder) return void res.status(404).json({ detail: "Folder not found" }); + if (!folder) + return void res.status(404).json({ detail: "Folder not found" }); } - const { data, error } = await db.from("documents") - .update({ folder_id: folder_id ?? null, updated_at: new Date().toISOString() }) - .eq("id", documentId).eq("project_id", projectId) - .select("*").single(); - if (error || !data) return void res.status(404).json({ detail: "Document not found" }); + const { data, error } = await db + .from("documents") + .update({ + folder_id: folder_id ?? null, + updated_at: new Date().toISOString(), + }) + .eq("id", documentId) + .eq("project_id", projectId) + .select("*") + .single(); + if (error || !data) + return void res.status(404).json({ detail: "Document not found" }); res.json(data); -}); + }, +); async function loadProjectFolder( db: ReturnType, @@ -1071,7 +1386,9 @@ async function loadProjectFolder( .eq("id", folderId) .eq("project_id", projectId) .maybeSingle(); - return (data as { id: string; parent_folder_id: string | null } | null) ?? null; + return ( + (data as { id: string; parent_folder_id: string | null } | null) ?? null + ); } export async function handleDocumentUpload( @@ -1089,9 +1406,7 @@ export async function handleDocumentUpload( ? filename.split(".").pop()!.toLowerCase() : ""; if (!ALLOWED_DOCUMENT_TYPES.has(suffix)) - return void res - .status(400) - .json({ + return void res.status(400).json({ detail: `Unsupported file type: ${suffix}. Allowed: ${ALLOWED_DOCUMENT_TYPES_LABEL}`, }); diff --git a/backend/src/routes/tabular.ts b/backend/src/routes/tabular.ts index 642edbbd9..5b8685779 100644 --- a/backend/src/routes/tabular.ts +++ b/backend/src/routes/tabular.ts @@ -39,14 +39,14 @@ import { findMissingUserEmails, loadProfileUsersByEmail, } from "../lib/userLookup"; -import { parsePaginationQuery } from "../lib/pagination"; -import { normalizeSearchTerm } from "../lib/search"; -import { parseTabularReviewSort } from "../lib/sort"; import { buildTabularReviewIdsOverviewRpcArgs, buildTabularReviewsOverviewRpcArgs, parseTabularReviewScope, } from "../lib/tabularReviewsOverview"; +import { parsePaginationQuery } from "../lib/pagination"; +import { normalizeSearchTerm } from "../lib/search"; +import { parseTabularReviewSort } from "../lib/sort"; function formatPromptSuffix(format?: string, tags?: string[]): string { switch (format) { @@ -109,9 +109,7 @@ async function fetchSourceDocuments( if (documentIds.length === 0) return []; const { data, error } = await db .from("documents") - .select( - "id, current_version_id, project_id, folder_id, library_folder_id", - ) + .select("id, current_version_id, project_id, folder_id, library_folder_id") .in("id", documentIds); if (error) throw new Error(error.message); const docs = (data ?? []) as (Omit< @@ -166,9 +164,7 @@ async function getFolderPathMaps( }> { const projectIds = [ ...new Set( - docs - .map((doc) => doc.project_id) - .filter((id): id is string => !!id), + docs.map((doc) => doc.project_id).filter((id): id is string => !!id), ), ]; const [projectResult, libraryResult] = await Promise.all([ @@ -319,9 +315,7 @@ async function createRowsForReview( })), ); if (cells.length) { - const { error: cellError } = await db - .from("tabular_cells") - .insert(cells); + const { error: cellError } = await db.from("tabular_cells").insert(cells); if (cellError) throw new Error(cellError.message); } } @@ -414,7 +408,10 @@ async function loadReviewRows( const { data: sources, error: sourceError } = await db .from("tabular_review_row_sources") .select("row_id, document_id") - .in("row_id", rows.map((row) => row.id)) + .in( + "row_id", + rows.map((row) => row.id), + ) .order("sort_index", { ascending: true }); if (sourceError) throw new Error(sourceError.message); const byRow = new Map(); @@ -447,10 +444,7 @@ async function loadRowDocumentText( const buf = await downloadFile(storagePath); if (buf) { try { - markdown = await extractDocumentMarkdown( - buf, - doc.file_type, - ); + markdown = await extractDocumentMarkdown(buf, doc.file_type); } catch (error) { console.error( `[tabular] extraction error doc=${doc.id}`, @@ -493,19 +487,15 @@ tabularRouter.get("/", requireAuth, async (req, res) => { typeof req.query.project_id === "string" && req.query.project_id ? (req.query.project_id as string) : null; - const pagination = parsePaginationQuery(req.query as Record); - const searchTerm = normalizeSearchTerm(req.query.search); - const sort = parseTabularReviewSort(req.query as Record); - const scope = parseTabularReviewScope(req.query.scope); const rpcArgs = buildTabularReviewsOverviewRpcArgs({ userId, userEmail, projectIdFilter, - scope, - pagination, - searchTerm, - sort, + scope: parseTabularReviewScope(req.query.scope), + pagination: parsePaginationQuery(req.query as Record), + searchTerm: normalizeSearchTerm(req.query.search), + sort: parseTabularReviewSort(req.query as Record), }); const { data, error } = await db.rpc("get_tabular_reviews_overview", rpcArgs); @@ -560,9 +550,6 @@ tabularRouter.get("/ids", requireAuth, async (req, res) => { const rows = (data ?? []) as { id: string; user_id: string }[]; if (rows.length === 0) break; ids.push(...rows); - // Advance by what actually came back, not the requested page size — - // if PostgREST's cap is lower than TABULAR_REVIEW_IDS_PAGE_SIZE this - // still converges correctly instead of skipping rows. offset += rows.length; } @@ -591,12 +578,7 @@ tabularRouter.post("/", requireAuth, async (req, res) => { const db = createServerSupabase(); if (project_id) { - const access = await checkProjectAccess( - project_id, - userId, - userEmail, - db, - ); + const access = await checkProjectAccess(project_id, userId, userEmail, db); if (!access.ok) return void res.status(404).json({ detail: "Project not found" }); } @@ -635,9 +617,7 @@ tabularRouter.post("/", requireAuth, async (req, res) => { await db.from("tabular_reviews").delete().eq("id", review.id); return void res.status(500).json({ detail: - error instanceof Error - ? error.message - : "Failed to create review rows", + error instanceof Error ? error.message : "Failed to create review rows", }); } @@ -647,10 +627,8 @@ tabularRouter.post("/", requireAuth, async (req, res) => { // POST /tabular-review/prompt (must come before /:reviewId routes) tabularRouter.post("/prompt", requireAuth, async (req, res) => { const userId = res.locals.userId as string; - const title = - typeof req.body.title === "string" ? req.body.title.trim() : ""; - if (!title) - return void res.status(400).json({ detail: "title is required" }); + const title = typeof req.body.title === "string" ? req.body.title.trim() : ""; + if (!title) return void res.status(400).json({ detail: "title is required" }); const format: string = typeof req.body.format === "string" ? req.body.format : "text"; @@ -780,16 +758,13 @@ tabularRouter.get("/:reviewId/people", requireAuth, async (req, res) => { .select("id, user_id, project_id, shared_with") .eq("id", reviewId) .single(); - if (!review) - return void res.status(404).json({ detail: "Review not found" }); + if (!review) return void res.status(404).json({ detail: "Review not found" }); const access = await ensureReviewAccess(review, userId, userEmail, db); if (!access.ok) return void res.status(404).json({ detail: "Review not found" }); const sharedWith: string[] = ( - Array.isArray(review.shared_with) - ? (review.shared_with as string[]) - : [] + Array.isArray(review.shared_with) ? (review.shared_with as string[]) : [] ).map((e) => (e ?? "").toLowerCase()); // Use the mirrored profile email so sharing checks do not scan auth.users. @@ -821,8 +796,7 @@ tabularRouter.patch("/:reviewId", requireAuth, async (req, res) => { const projectIdUpdate = req.body.project_id === null ? null - : typeof req.body.project_id === "string" && - req.body.project_id.trim() + : typeof req.body.project_id === "string" && req.body.project_id.trim() ? req.body.project_id.trim() : undefined; if (projectIdUpdateProvided && projectIdUpdate === undefined) { @@ -1077,9 +1051,7 @@ tabularRouter.post( const rows = await loadReviewRows(db, reviewId); const row = rows.find((candidate) => candidate.id === row_id); if (!row) - return void res - .status(404) - .json({ detail: "Review row not found" }); + return void res.status(404).json({ detail: "Review row not found" }); const sourceIds = row.source_document_ids ?? []; const allowedSourceIds = await filterAccessibleDocumentIds( sourceIds, @@ -1088,14 +1060,9 @@ tabularRouter.post( db, ); if (allowedSourceIds.length !== sourceIds.length) - return void res - .status(404) - .json({ detail: "Review row not found" }); + return void res.status(404).json({ detail: "Review row not found" }); - const { tabular_model, api_keys } = await getUserModelSettings( - userId, - db, - ); + const { tabular_model, api_keys } = await getUserModelSettings(userId, db); const missingKey = missingModelApiKey(tabular_model, api_keys); if (missingKey) { return void res.status(422).json({ @@ -1214,10 +1181,7 @@ tabularRouter.post("/:reviewId/generate", requireAuth, async (req, res) => { try { await Promise.all( rows.map(async (row) => { - const markdown = await loadRowDocumentText( - db, - row, - ); + const markdown = await loadRowDocumentText(db, row); // Filter to only columns that need processing const columnsToProcess = columns.filter((col) => { @@ -1456,11 +1420,9 @@ function extractTabularAnnotations( ref: c.ref, col_index: c.col_index, row_index: c.row_index, - col_name: - tabularStore.columns[c.col_index]?.name ?? `Col ${c.col_index}`, + col_name: tabularStore.columns[c.col_index]?.name ?? `Col ${c.col_index}`, doc_name: - tabularStore.documents[c.row_index]?.filename ?? - `Row ${c.row_index}`, + tabularStore.documents[c.row_index]?.filename ?? `Row ${c.row_index}`, quote: c.quote, })); } @@ -1557,12 +1519,7 @@ tabularRouter.post("/:reviewId/chat", requireAuth, async (req, res) => { .single(); if (error || !review) return void res.status(404).json({ detail: "Review not found" }); - const reviewAccess = await ensureReviewAccess( - review, - userId, - userEmail, - db, - ); + const reviewAccess = await ensureReviewAccess(review, userId, userEmail, db); if (!reviewAccess.ok) return void res.status(404).json({ detail: "Review not found" }); @@ -1675,8 +1632,7 @@ tabularRouter.post("/:reviewId/chat", requireAuth, async (req, res) => { extraTools: TABULAR_TOOLS, includeResearchTools: false, tabularStore, - buildCitations: (text) => - extractTabularAnnotations(text, tabularStore), + buildCitations: (text) => extractTabularAnnotations(text, tabularStore), model: tabular_model, apiKeys: api_keys, signal: streamAbort.signal, @@ -1737,9 +1693,7 @@ tabularRouter.post("/:reviewId/chat", requireAuth, async (req, res) => { chat_id: chatId, role: "assistant", content: partial.events.length ? partial.events : null, - annotations: annotations.length - ? annotations - : null, + annotations: annotations.length ? annotations : null, }); if (saveError) { console.error( @@ -1756,7 +1710,8 @@ tabularRouter.post("/:reviewId/chat", requireAuth, async (req, res) => { } console.error("[tabular/chat] error", safeErrorLog(err)); const message = safeErrorMessage(err, "Stream error"); - const errorEvents = err instanceof AssistantStreamError + const errorEvents = + err instanceof AssistantStreamError ? stripTransientAssistantEvents(err.events) : [{ type: "error" as const, message }]; const errorFullText = @@ -1782,9 +1737,7 @@ tabularRouter.post("/:reviewId/chat", requireAuth, async (req, res) => { } } try { - write( - `data: ${JSON.stringify({ type: "error", message })}\n\n`, - ); + write(`data: ${JSON.stringify({ type: "error", message })}\n\n`); write("data: [DONE]\n\n"); } catch { /* ignore */ @@ -1885,8 +1838,7 @@ The "summary" field must contain only the extracted value with inline citations }; return { summary: - String(parsed.summary ?? parsed.value ?? "").trim() || - "Not addressed", + String(parsed.summary ?? parsed.value ?? "").trim() || "Not addressed", flag: (["green", "grey", "yellow", "red"] as const).includes( parsed.flag as "green", ) @@ -2020,10 +1972,7 @@ Rules: contentBuffer += delta; let newlineIdx: number; while ((newlineIdx = contentBuffer.indexOf("\n")) !== -1) { - const completedLine = contentBuffer.slice( - 0, - newlineIdx, - ); + const completedLine = contentBuffer.slice(0, newlineIdx); contentBuffer = contentBuffer.slice(newlineIdx + 1); pending.push(processLine(completedLine)); } @@ -2068,9 +2017,7 @@ async function extractDocumentMarkdown( async function extractPdfMarkdown(buf: ArrayBuffer): Promise { try { - const pdfjsLib = await import( - "pdfjs-dist/legacy/build/pdf.mjs" as string - ); + const pdfjsLib = await import("pdfjs-dist/legacy/build/pdf.mjs" as string); const pdf = await ( pdfjsLib as unknown as { getDocument: (opts: unknown) => { diff --git a/backend/src/routes/workflows.ts b/backend/src/routes/workflows.ts index cc6d38bf4..11132c007 100644 --- a/backend/src/routes/workflows.ts +++ b/backend/src/routes/workflows.ts @@ -1,4 +1,9 @@ -import { Router, type NextFunction, type Request, type Response } from "express"; +import { + Router, + type NextFunction, + type Request, + type Response, +} from "express"; import { requireAuth } from "../middleware/auth"; import { createServerSupabase } from "../lib/supabase"; import { @@ -97,13 +102,11 @@ const DEFAULT_WORKFLOW_JURISDICTIONS = ["General"]; const WORKFLOW_CONTRIBUTIONS_ENABLED = process.env.WORKFLOW_CONTRIBUTIONS_ENABLED === "true"; -type WorkflowAccess = - | { +type WorkflowAccess = { workflow: WorkflowRecord; allowEdit: boolean; isOwner: boolean; - } - | null; +} | null; type AsyncRoute = (req: Request, res: Response) => Promise; @@ -115,7 +118,11 @@ function asyncRoute(handler: AsyncRoute) { function withWorkflowAccess( workflow: T, - access: { allowEdit: boolean; isOwner: boolean; sharedByName?: string | null }, + access: { + allowEdit: boolean; + isOwner: boolean; + sharedByName?: string | null; + }, ) { return { ...workflow, @@ -146,15 +153,16 @@ function workflowTypeFrom(value: unknown): WorkflowType { return value === "tabular" ? "tabular" : "assistant"; } -function metadataFromWorkflowRecord(workflow: WorkflowRecord): WorkflowMetadata { +function metadataFromWorkflowRecord( + workflow: WorkflowRecord, +): WorkflowMetadata { const type = workflowTypeFrom(workflow.type); return { name: workflowNameFromSkillMd(workflow.prompt_md), title: workflow.title ?? "", description: null, type, - contributors: - normalizeContributors(workflow.contributors) ?? [ + contributors: normalizeContributors(workflow.contributors) ?? [ DEFAULT_WORKFLOW_CONTRIBUTOR, ], language: workflow.language ?? DEFAULT_WORKFLOW_LANGUAGE, @@ -184,6 +192,16 @@ function withDatabaseWorkflow(workflow: WorkflowRecord) { }; } +function withDatabaseWorkflowSummary(workflow: WorkflowRecord) { + return { + ...withDatabaseWorkflow(workflow), + // List pages render metadata only. Full instructions/columns are loaded + // from GET /workflows/:id when a workflow is opened. + skill_md: null, + columns_config: null, + }; +} + function normalizeOptionalString(value: unknown): string | null { if (typeof value !== "string") return null; const trimmed = value.trim(); @@ -252,7 +270,11 @@ async function resolveWorkflowAccess( .maybeSingle(); if (!share) return null; - return { workflow: workflowRecord, allowEdit: !!share.allow_edit, isOwner: false }; + return { + workflow: workflowRecord, + allowEdit: !!share.allow_edit, + isOwner: false, + }; } function toOpenSourceSubmissionSummary( @@ -281,7 +303,9 @@ async function getLatestOpenSourceSubmission( .limit(1) .maybeSingle(); if (error) throw error; - return data ? toOpenSourceSubmissionSummary(data as OpenSourceSubmissionRow) : null; + return data + ? toOpenSourceSubmissionSummary(data as OpenSourceSubmissionRow) + : null; } function buildOpenSourceSnapshot( @@ -309,22 +333,14 @@ function validateOpenSourceWorkflow(workflow: WorkflowRecord): string | null { : "Assistant workflows need instructions before they can be opened source."; } if (workflow.type === "tabular") { - return Array.isArray(workflow.columns_config) && workflow.columns_config.length > 0 + return Array.isArray(workflow.columns_config) && + workflow.columns_config.length > 0 ? null : "Tabular workflows need at least one column before they can be opened source."; } return "Workflow type must be 'assistant' or 'tabular'."; } -// GET /workflows -// Pagination is opt-in via query params (limit/offset/search/sort_key or -// key/scope/practice/language/jurisdiction) — only WorkflowList.tsx sends -// them. Every other caller (the workflow picker modal, the chat slash-menu -// picker, UseWorkflowModal's own independent fetch) calls this with no -// query params at all and must keep getting the exact legacy response shape -// (system workflows prepended, full unpaginated owned+shared set) back, so -// the branch below must never default to paginating a request that didn't -// ask for it. const WORKFLOW_PAGINATION_QUERY_KEYS = [ "limit", "offset", @@ -342,20 +358,14 @@ const WORKFLOW_PAGINATION_QUERY_KEYS = [ workflowsRouter.get("/", requireAuth, asyncRoute(async (req, res) => { const userId = res.locals.userId as string; const userEmail = res.locals.userEmail as string | undefined; - const { type } = req.query as { type?: string }; const db = createServerSupabase(); + const { type } = req.query as { type?: string }; const workflowType = typeof type === "string" && type ? type : null; - const hasPaginationParams = WORKFLOW_PAGINATION_QUERY_KEYS.some( (key) => req.query[key] !== undefined, ); if (hasPaginationParams) { - // Paginated path: DB-backed owned+shared workflows only. System - // workflows are deliberately NOT prepended here — the hybrid design - // keeps them fetched separately (GET /workflows/system) and merged - // client-side, since there are only 37 of them and they never grow - // from user data. const rpcArgs = buildWorkflowsOverviewRpcArgs({ userId, userEmail, @@ -372,7 +382,7 @@ workflowsRouter.get("/", requireAuth, asyncRoute(async (req, res) => { if (error) return void res.status(500).json({ detail: error.message }); const databaseWorkflows = ((data ?? []) as WorkflowRecord[]) .filter((workflow) => !SYSTEM_WORKFLOW_IDS.has(workflow.id)) - .map(withDatabaseWorkflow); + .map(withDatabaseWorkflowSummary); return void res.json(databaseWorkflows); } @@ -388,9 +398,9 @@ workflowsRouter.get("/", requireAuth, asyncRoute(async (req, res) => { const systemWorkflows = SYSTEM_WORKFLOWS.filter( (workflow) => !workflowType || workflow.metadata.type === workflowType, ).map(withSystemWorkflowAccess); - const databaseWorkflows = ((data ?? []) as WorkflowRecord[]).filter( - (workflow) => !SYSTEM_WORKFLOW_IDS.has(workflow.id), - ).map(withDatabaseWorkflow); + const databaseWorkflows = ((data ?? []) as WorkflowRecord[]) + .filter((workflow) => !SYSTEM_WORKFLOW_IDS.has(workflow.id)) + .map(withDatabaseWorkflow); res.json([...systemWorkflows, ...databaseWorkflows]); })); @@ -400,14 +410,52 @@ workflowsRouter.get("/", requireAuth, asyncRoute(async (req, res) => { // hybrid pagination design keeps this bucket always fully loaded client-side // (only 37 entries, code-generated, zero user-data growth) rather than // trying to fold it into the paginated RPC above. -workflowsRouter.get("/system", requireAuth, asyncRoute(async (req, res) => { +workflowsRouter.get( + "/system", + requireAuth, + asyncRoute(async (req, res) => { const { type } = req.query as { type?: string }; const workflowType = typeof type === "string" && type ? type : null; const systemWorkflows = SYSTEM_WORKFLOWS.filter( (workflow) => !workflowType || workflow.metadata.type === workflowType, ).map(withSystemWorkflowAccess); res.json(systemWorkflows); -})); + }), +); + +// GET /workflows/filter-options (must come before /:workflowId routes) +workflowsRouter.get( + "/filter-options", + requireAuth, + asyncRoute(async (req, res) => { + const userId = res.locals.userId as string; + const userEmail = res.locals.userEmail as string | undefined; + const type = + req.query.type === "assistant" || req.query.type === "tabular" + ? req.query.type + : null; + const scope = parseWorkflowScope(req.query.scope); + const db = createServerSupabase(); + const { data, error } = await db.rpc("get_workflow_filter_options", { + p_user_id: userId, + p_user_email: userEmail ?? null, + p_type: type, + p_scope: scope, + }); + if (error) return void res.status(500).json({ detail: error.message }); + + const row = (data?.[0] ?? {}) as Record; + const strings = (value: unknown) => + Array.isArray(value) + ? value.filter((item): item is string => typeof item === "string") + : []; + res.json({ + practices: strings(row.practices), + languages: strings(row.languages), + jurisdictions: strings(row.jurisdictions), + }); + }), +); // GET /workflows/ids (must come before /:workflowId routes) // Lightweight id + owner list for every owned/shared workflow matching the @@ -423,7 +471,10 @@ workflowsRouter.get("/system", requireAuth, asyncRoute(async (req, res) => { const WORKFLOW_IDS_PAGE_SIZE = 1000; const WORKFLOW_IDS_MAX_PAGES = 200; // guards a runaway loop, not a product limit -workflowsRouter.get("/ids", requireAuth, asyncRoute(async (req, res) => { +workflowsRouter.get( + "/ids", + requireAuth, + asyncRoute(async (req, res) => { const userId = res.locals.userId as string; const userEmail = res.locals.userEmail as string | undefined; const db = createServerSupabase(); @@ -456,23 +507,20 @@ workflowsRouter.get("/ids", requireAuth, asyncRoute(async (req, res) => { const rows = (data ?? []) as { id: string; user_id: string }[]; if (rows.length === 0) break; ids.push(...rows); - // Advance by what actually came back, not the requested page size — if - // PostgREST's cap is lower than WORKFLOW_IDS_PAGE_SIZE this still - // converges correctly instead of skipping rows. offset += rows.length; } res.json(ids); -})); + }), +); // POST /workflows -workflowsRouter.post("/", requireAuth, asyncRoute(async (req, res) => { +workflowsRouter.post( + "/", + requireAuth, + asyncRoute(async (req, res) => { const userId = res.locals.userId as string; - const { - metadata, - skill_md, - columns_config, - } = req.body as { + const { metadata, skill_md, columns_config } = req.body as { metadata?: Partial; skill_md?: string; columns_config?: unknown; @@ -480,7 +528,9 @@ workflowsRouter.post("/", requireAuth, asyncRoute(async (req, res) => { const title = metadata?.title; const type = metadata?.type; if (!title?.trim()) - return void res.status(400).json({ detail: "metadata.title is required" }); + return void res + .status(400) + .json({ detail: "metadata.title is required" }); if (type !== "assistant" && type !== "tabular") return void res .status(400) @@ -494,7 +544,8 @@ workflowsRouter.post("/", requireAuth, asyncRoute(async (req, res) => { hasSkill: typeof skill_md === "string" && skill_md.length > 0, columnCount: Array.isArray(columns_config) ? columns_config.length : null, language: - normalizeOptionalString(metadata?.language) ?? DEFAULT_WORKFLOW_LANGUAGE, + normalizeOptionalString(metadata?.language) ?? + DEFAULT_WORKFLOW_LANGUAGE, practice: metadata?.practice ?? null, jurisdictions: normalizeJurisdictions(metadata?.jurisdictions) ?? @@ -509,9 +560,11 @@ workflowsRouter.post("/", requireAuth, asyncRoute(async (req, res) => { prompt_md: skill_md ?? null, columns_config: columns_config ?? null, language: - normalizeOptionalString(metadata?.language) ?? DEFAULT_WORKFLOW_LANGUAGE, + normalizeOptionalString(metadata?.language) ?? + DEFAULT_WORKFLOW_LANGUAGE, practice: - normalizeOptionalString(metadata?.practice) ?? DEFAULT_WORKFLOW_PRACTICE, + normalizeOptionalString(metadata?.practice) ?? + DEFAULT_WORKFLOW_PRACTICE, jurisdictions: normalizeJurisdictions(metadata?.jurisdictions) ?? DEFAULT_WORKFLOW_JURISDICTIONS, @@ -537,7 +590,8 @@ workflowsRouter.post("/", requireAuth, asyncRoute(async (req, res) => { type: data?.type, }); res.status(201).json(withDatabaseWorkflow(data as WorkflowRecord)); -})); + }), +); async function handleWorkflowUpdate(req: Request, res: Response) { const userId = res.locals.userId as string; @@ -582,13 +636,24 @@ async function handleWorkflowUpdate(req: Request, res: Response) { } // PUT /workflows/:workflowId -workflowsRouter.put("/:workflowId", requireAuth, asyncRoute(handleWorkflowUpdate)); +workflowsRouter.put( + "/:workflowId", + requireAuth, + asyncRoute(handleWorkflowUpdate), +); // PATCH /workflows/:workflowId -workflowsRouter.patch("/:workflowId", requireAuth, asyncRoute(handleWorkflowUpdate)); +workflowsRouter.patch( + "/:workflowId", + requireAuth, + asyncRoute(handleWorkflowUpdate), +); // DELETE /workflows/:workflowId -workflowsRouter.delete("/:workflowId", requireAuth, asyncRoute(async (req, res) => { +workflowsRouter.delete( + "/:workflowId", + requireAuth, + asyncRoute(async (req, res) => { const userId = res.locals.userId as string; const { workflowId } = req.params; const systemWorkflow = SYSTEM_WORKFLOWS.find( @@ -606,10 +671,14 @@ workflowsRouter.delete("/:workflowId", requireAuth, asyncRoute(async (req, res) .eq("user_id", userId); if (error) return void res.status(500).json({ detail: error.message }); res.status(204).send(); -})); + }), +); // GET /workflows/hidden -workflowsRouter.get("/hidden", requireAuth, asyncRoute(async (req, res) => { +workflowsRouter.get( + "/hidden", + requireAuth, + asyncRoute(async (req, res) => { const userId = res.locals.userId as string; const db = createServerSupabase(); const { data, error } = await db @@ -618,10 +687,14 @@ workflowsRouter.get("/hidden", requireAuth, asyncRoute(async (req, res) => { .eq("user_id", userId); if (error) return void res.status(500).json({ detail: error.message }); res.json((data ?? []).map((r) => r.workflow_id)); -})); + }), +); // POST /workflows/hidden -workflowsRouter.post("/hidden", requireAuth, asyncRoute(async (req, res) => { +workflowsRouter.post( + "/hidden", + requireAuth, + asyncRoute(async (req, res) => { const userId = res.locals.userId as string; const { workflow_id } = req.body as { workflow_id: string }; if (!workflow_id?.trim()) @@ -629,13 +702,20 @@ workflowsRouter.post("/hidden", requireAuth, asyncRoute(async (req, res) => { const db = createServerSupabase(); const { error } = await db .from("hidden_workflows") - .upsert({ user_id: userId, workflow_id }, { onConflict: "user_id,workflow_id" }); + .upsert( + { user_id: userId, workflow_id }, + { onConflict: "user_id,workflow_id" }, + ); if (error) return void res.status(500).json({ detail: error.message }); res.status(204).send(); -})); + }), +); // DELETE /workflows/hidden/:workflowId -workflowsRouter.delete("/hidden/:workflowId", requireAuth, asyncRoute(async (req, res) => { +workflowsRouter.delete( + "/hidden/:workflowId", + requireAuth, + asyncRoute(async (req, res) => { const userId = res.locals.userId as string; const { workflowId } = req.params; const db = createServerSupabase(); @@ -646,12 +726,18 @@ workflowsRouter.delete("/hidden/:workflowId", requireAuth, asyncRoute(async (req .eq("workflow_id", workflowId); if (error) return void res.status(500).json({ detail: error.message }); res.status(204).send(); -})); + }), +); // POST /workflows/:workflowId/open-source -workflowsRouter.post("/:workflowId/open-source", requireAuth, asyncRoute(async (req, res) => { +workflowsRouter.post( + "/:workflowId/open-source", + requireAuth, + asyncRoute(async (req, res) => { if (!WORKFLOW_CONTRIBUTIONS_ENABLED) { - return void res.status(404).json({ detail: "Workflow contributions are disabled" }); + return void res + .status(404) + .json({ detail: "Workflow contributions are disabled" }); } const userId = res.locals.userId as string; @@ -662,9 +748,7 @@ workflowsRouter.post("/:workflowId/open-source", requireAuth, asyncRoute(async ( contributor?: unknown; }; const requestedContributorMode = - openSourceBody.contributor_mode === "named" - ? "named" - : "anonymous"; + openSourceBody.contributor_mode === "named" ? "named" : "anonymous"; const db = createServerSupabase(); const { data: workflow, error: workflowError } = await db @@ -773,10 +857,14 @@ workflowsRouter.post("/:workflowId/open-source", requireAuth, asyncRoute(async ( ...toOpenSourceSubmissionSummary(created as OpenSourceSubmissionRow), mode: "created", }); -})); + }), +); // GET /workflows/:workflowId -workflowsRouter.get("/:workflowId", requireAuth, asyncRoute(async (req, res) => { +workflowsRouter.get( + "/:workflowId", + requireAuth, + asyncRoute(async (req, res) => { const userId = res.locals.userId as string; const userEmail = res.locals.userEmail as string | undefined; const { workflowId } = req.params; @@ -788,7 +876,12 @@ workflowsRouter.get("/:workflowId", requireAuth, asyncRoute(async (req, res) => } const db = createServerSupabase(); - const access = await resolveWorkflowAccess(workflowId, userId, userEmail, db); + const access = await resolveWorkflowAccess( + workflowId, + userId, + userEmail, + db, + ); if (!access) return void res.status(404).json({ detail: "Workflow not found" }); const openSourceSubmission = access.isOwner @@ -803,10 +896,14 @@ workflowsRouter.get("/:workflowId", requireAuth, asyncRoute(async (req, res) => openSourceSubmission, ), ); -})); + }), +); // GET /workflows/:workflowId/shares -workflowsRouter.get("/:workflowId/shares", requireAuth, asyncRoute(async (req, res) => { +workflowsRouter.get( + "/:workflowId/shares", + requireAuth, + asyncRoute(async (req, res) => { const userId = res.locals.userId as string; const { workflowId } = req.params; const db = createServerSupabase(); @@ -817,7 +914,10 @@ workflowsRouter.get("/:workflowId/shares", requireAuth, asyncRoute(async (req, r .eq("id", workflowId) .eq("user_id", userId) .single(); - if (!wf) return void res.status(404).json({ detail: "Workflow not found or not editable" }); + if (!wf) + return void res + .status(404) + .json({ detail: "Workflow not found or not editable" }); const { data: shares, error } = await db .from("workflow_shares") @@ -827,10 +927,14 @@ workflowsRouter.get("/:workflowId/shares", requireAuth, asyncRoute(async (req, r if (error) return void res.status(500).json({ detail: error.message }); res.json(shares ?? []); -})); + }), +); // DELETE /workflows/:workflowId/shares/:shareId -workflowsRouter.delete("/:workflowId/shares/:shareId", requireAuth, asyncRoute(async (req, res) => { +workflowsRouter.delete( + "/:workflowId/shares/:shareId", + requireAuth, + asyncRoute(async (req, res) => { const userId = res.locals.userId as string; const { workflowId, shareId } = req.params; const db = createServerSupabase(); @@ -843,23 +947,33 @@ workflowsRouter.delete("/:workflowId/shares/:shareId", requireAuth, asyncRoute(a .single(); if (!wf) return void res.status(404).json({ detail: "Workflow not found" }); - await db.from("workflow_shares").delete().eq("id", shareId).eq("workflow_id", workflowId); + await db + .from("workflow_shares") + .delete() + .eq("id", shareId) + .eq("workflow_id", workflowId); res.status(204).send(); -})); + }), +); // POST /workflows/:workflowId/share -workflowsRouter.post("/:workflowId/share", requireAuth, asyncRoute(async (req, res) => { +workflowsRouter.post( + "/:workflowId/share", + requireAuth, + asyncRoute(async (req, res) => { const userId = res.locals.userId as string; const userEmail = res.locals.userEmail as string | undefined; const { workflowId } = req.params; - const { emails, allow_edit } = req.body as { emails: string[]; allow_edit: boolean }; + const { emails, allow_edit } = req.body as { + emails: string[]; + allow_edit: boolean; + }; - if (!emails?.length) return void res.status(400).json({ detail: "emails is required" }); + if (!emails?.length) + return void res.status(400).json({ detail: "emails is required" }); const normalizedEmails = [ ...new Set( - emails - .map((email) => email.trim().toLowerCase()) - .filter(Boolean), + emails.map((email) => email.trim().toLowerCase()).filter(Boolean), ), ]; if (normalizedEmails.length === 0) { @@ -873,7 +987,10 @@ workflowsRouter.post("/:workflowId/share", requireAuth, asyncRoute(async (req, r } const db = createServerSupabase(); - const missingSharedUsers = await findMissingUserEmails(db, normalizedEmails); + const missingSharedUsers = await findMissingUserEmails( + db, + normalizedEmails, + ); if (missingSharedUsers.length > 0) { return void res.status(400).json({ detail: `${missingSharedUsers[0]} does not belong to a Mike user.`, @@ -887,7 +1004,10 @@ workflowsRouter.post("/:workflowId/share", requireAuth, asyncRoute(async (req, r .eq("id", workflowId) .eq("user_id", userId) .single(); - if (!wf) return void res.status(404).json({ detail: "Workflow not found or not editable" }); + if (!wf) + return void res + .status(404) + .json({ detail: "Workflow not found or not editable" }); const rows = normalizedEmails.map((email: string) => ({ workflow_id: workflowId, @@ -903,7 +1023,8 @@ workflowsRouter.post("/:workflowId/share", requireAuth, asyncRoute(async (req, r if (error) return void res.status(500).json({ detail: error.message }); res.status(204).send(); -})); + }), +); workflowsRouter.use( (err: unknown, _req: Request, res: Response, next: NextFunction) => { diff --git a/frontend/src/app/(pages)/tabular-reviews/page.tsx b/frontend/src/app/(pages)/tabular-reviews/page.tsx index a0e160910..9b0585d77 100644 --- a/frontend/src/app/(pages)/tabular-reviews/page.tsx +++ b/frontend/src/app/(pages)/tabular-reviews/page.tsx @@ -21,6 +21,7 @@ import { NewTRModal } from "@/app/components/tabular/NewTRModal"; import { TabularReviewDetailsModal } from "@/app/components/tabular/TabularReviewDetailsModal"; import { OwnerOnlyPopup } from "@/app/components/popups/OwnerOnlyPopup"; import { WarningPopup } from "@/app/components/popups/WarningPopup"; +import { ConfirmPopup } from "@/app/components/popups/ConfirmPopup"; import { useAuth } from "@/app/contexts/AuthContext"; import { PageHeader } from "@/app/components/shared/PageHeader"; import { @@ -109,6 +110,9 @@ export default function TabularReviewsPage() { }); const [actionsOpen, setActionsOpen] = useState(false); const [ownerOnlyAction, setOwnerOnlyAction] = useState(null); + const [selectionCameFromSelectAll, setSelectionCameFromSelectAll] = + useState(false); + const [confirmDeleteAllOpen, setConfirmDeleteAllOpen] = useState(false); const [bulkDeleteNotice, setBulkDeleteNotice] = useState( null, ); @@ -178,8 +182,13 @@ export default function TabularReviewsPage() { !allSelected && filtered.some((r) => selectedIds.includes(r.id)); function toggleAll() { - if (allSelected) setSelectedIds([]); - else void selectAllMatching(); + if (allSelected) { + setSelectedIds([]); + setSelectionCameFromSelectAll(false); + } else { + setSelectionCameFromSelectAll(true); + void selectAllMatching(); + } } function toggleOne(id: string) { @@ -190,6 +199,8 @@ export default function TabularReviewsPage() { function clearSelection() { setSelectedIds([]); + setSelectionCameFromSelectAll(false); + setConfirmDeleteAllOpen(false); setActionsOpen(false); } @@ -265,9 +276,20 @@ export default function TabularReviewsPage() { ); } + function requestDeleteSelected() { + setActionsOpen(false); + if (selectionCameFromSelectAll) { + setConfirmDeleteAllOpen(true); + return; + } + void handleDeleteSelected(); + } + async function handleDeleteSelected() { const ids = [...selectedIds]; setActionsOpen(false); + setConfirmDeleteAllOpen(false); + setSelectionCameFromSelectAll(false); setBulkDeleteNotice(null); const owned = ids.filter((id) => { const ownerId = getReviewOwnerId(id); @@ -396,7 +418,7 @@ export default function TabularReviewsPage() { {actionsOpen && (
); } diff --git a/frontend/src/app/components/documents/DocTable.tsx b/frontend/src/app/components/documents/DocTable.tsx index e37dfe809..a1a497b87 100644 --- a/frontend/src/app/components/documents/DocTable.tsx +++ b/frontend/src/app/components/documents/DocTable.tsx @@ -5,6 +5,7 @@ import { type DragEvent, type ReactNode, type SetStateAction, + type UIEvent, useCallback, useEffect, useMemo, @@ -12,12 +13,7 @@ import { useState, } from "react"; import { createPortal } from "react-dom"; -import { - Loader2, - AlertCircle, - ChevronDown, - ChevronRight, -} from "lucide-react"; +import { Loader2, AlertCircle, ChevronDown, ChevronRight } from "lucide-react"; import { deleteDocument, getDocumentUrl, @@ -41,9 +37,7 @@ import { RowActions, type RowActionMenuSurfaceProps, } from "@/app/components/shared/RowActions"; -import { - SubfolderSvgIcon, -} from "@/app/components/shared/FolderSvgIcon"; +import { SubfolderSvgIcon } from "@/app/components/shared/FolderSvgIcon"; import { useAuth } from "@/app/contexts/AuthContext"; import { WarningPopup } from "@/app/components/popups/WarningPopup"; import { UploadOverlay } from "@/app/components/assistant/UploadOverlay"; @@ -90,7 +84,16 @@ export interface DocTableSelectionActions { onDelete: () => Promise; } -type DocumentSortKey = "name" | "size" | "version" | "created" | "updated"; +export type DocumentSortKey = "name" | "size" | "version" | "created" | "updated"; + +export interface DocTableQuery { + search: string; + fileType: string | null; + sort: { + key: DocumentSortKey; + direction: TableSortDirection; + } | null; +} const SORT_OPTIONS: TableFilterOption[] = [ { value: "asc", label: "Ascending" }, @@ -100,24 +103,13 @@ const SORT_OPTIONS: TableFilterOption[] = [ interface DocTableOperations { uploadDocument: (file: File) => Promise; refreshCollection: () => Promise; - createFolder: ( - name: string, - parentFolderId?: string | null, - ) => Promise; - renameFolder: ( - folderId: string, - name: string, - ) => Promise; + createFolder: (name: string, parentFolderId?: string | null) => Promise; + renameFolder: (folderId: string, name: string) => Promise; deleteFolder: (folderId: string) => Promise; - moveFolder: ( - folderId: string, - parentFolderId: string | null, - ) => Promise; - moveDocument: ( - documentId: string, - folderId: string | null, - ) => Promise; + moveFolder: (folderId: string, parentFolderId: string | null) => Promise; + moveDocument: (documentId: string, folderId: string | null) => Promise; renameDocument: (documentId: string, filename: string) => Promise; + bulkDeleteDocuments?: (documentIds: string[]) => Promise<{ deletedIds: string[] }>; } interface DocTableProps { @@ -150,6 +142,17 @@ interface DocTableProps { documentsHasMoreByLevel?: Record; loadingMoreDocumentsByLevel?: Record; onLoadMoreDocuments?: (parentId: string | null) => void; + // When non-null, these are already filtered/sorted server results and are + // rendered as a flat list instead of the folder tree. + serverDocuments?: Document[] | null; + serverQueryLoading?: boolean; + serverQueryHasMore?: boolean; + serverQueryLoadingMore?: boolean; + onLoadMoreServerDocuments?: () => void; + onServerQueryChange?: (query: DocTableQuery) => void; + onSelectAllMatching?: (query: DocTableQuery) => Promise; + documentTypeOptions?: TableFilterOption[]; + autoLoadOnScroll?: boolean; } function apiErrorDetail(error: unknown): string | null { @@ -190,11 +193,7 @@ function documentVersionNumber(doc: Document): number | null { return doc.active_version_number ?? doc.latest_version_number ?? null; } -function ProjectTableLoadingHeader({ - stickyCellBg, -}: { - stickyCellBg: string; -}) { +function ProjectTableLoadingHeader({ stickyCellBg }: { stickyCellBg: string }) { return ( {[1, 2, 3, 4, 5].map((i) => ( -
-
+
+
@@ -287,6 +281,15 @@ export function DocTable({ documentsHasMoreByLevel, loadingMoreDocumentsByLevel, onLoadMoreDocuments, + serverDocuments = null, + serverQueryLoading = false, + serverQueryHasMore = false, + serverQueryLoadingMore = false, + onLoadMoreServerDocuments, + onServerQueryChange, + onSelectAllMatching, + documentTypeOptions, + autoLoadOnScroll = false, }: DocTableProps) { const [addDocsOpen, setAddDocsOpen] = useState(false); const { user } = useAuth(); @@ -297,18 +300,20 @@ export function DocTable({ label: string; } | null>(null); const [selectedDocIds, setSelectedDocIds] = useState([]); + const [selectionCameFromSelectAll, setSelectionCameFromSelectAll] = useState(false); + const [selectingAllDocuments, setSelectingAllDocuments] = useState(false); + const [confirmDeleteAllOpen, setConfirmDeleteAllOpen] = useState(false); const [typeFilter, setTypeFilter] = useState(null); const [sort, setSort] = useState<{ key: DocumentSortKey; direction: TableSortDirection; } | null>(null); + const serverQueryActive = serverDocuments !== null; const documentUploadInputRef = useRef(null); + const autoLoadTriggeredRef = useRef(false); const loadingRef = useRef(loading); const renderAddDocumentsModalRef = useRef(renderAddDocumentsModal); - const setOwnerOnlyAction = useMemo( - () => onOwnerOnlyAction ?? (() => {}), - [onOwnerOnlyAction], - ); + const setOwnerOnlyAction = useMemo(() => onOwnerOnlyAction ?? (() => {}), [onOwnerOnlyAction]); useEffect(() => { loadingRef.current = loading; @@ -333,23 +338,13 @@ export function DocTable({ // versions so toggling closed + open again doesn't refetch. loadingIds // drives the inline spinner in the version cell while a fetch is in // flight. - const [expandedVersionDocIds, setExpandedVersionDocIds] = useState< - Set - >(() => new Set()); + const [expandedVersionDocIds, setExpandedVersionDocIds] = useState>(() => new Set()); const [versionsByDocId, setVersionsByDocId] = useState< - Map< - string, - { currentVersionId: string | null; versions: DocumentVersion[] } - > + Map >(() => new Map()); - const [loadingVersionDocIds, setLoadingVersionDocIds] = useState< - Set - >(() => new Set()); - - const loadDocumentVersions = async ( - docId: string, - options: { expand?: boolean; force?: boolean } = {}, - ) => { + const [loadingVersionDocIds, setLoadingVersionDocIds] = useState>(() => new Set()); + + const loadDocumentVersions = async (docId: string, options: { expand?: boolean; force?: boolean } = {}) => { if (options.expand) { setExpandedVersionDocIds((prev) => new Set([...prev, docId])); } @@ -390,11 +385,7 @@ export function DocTable({ await loadDocumentVersions(docId, { expand: true }); }; - async function downloadDocVersion( - docId: string, - versionId: string, - filename: string, - ) { + async function downloadDocVersion(docId: string, versionId: string, filename: string) { try { const resolved = await getDocumentUrl(docId, versionId); const a = document.createElement("a"); @@ -414,9 +405,7 @@ export function DocTable({ window.setTimeout(() => versionUploadInputRef.current?.click(), 0); } - async function handleVersionUploadInputChange( - e: React.ChangeEvent, - ) { + async function handleVersionUploadInputChange(e: React.ChangeEvent) { const file = e.target.files?.[0] ?? null; e.target.value = ""; const doc = versionUploadTargetDoc; @@ -425,11 +414,7 @@ export function DocTable({ await handleDropDocumentVersions(doc, [file]); } - async function submitNewVersion( - doc: Document, - file: File, - filename: string, - ) { + async function submitNewVersion(doc: Document, file: File, filename: string) { try { await uploadDocumentVersion(doc.id, file, filename); await refreshDocumentVersionState(doc.id); @@ -438,17 +423,10 @@ export function DocTable({ } } - async function replaceVersionFile( - docId: string, - versionId: string, - file: File, - filename: string, - ) { + async function replaceVersionFile(docId: string, versionId: string, file: File, filename: string) { await replaceDocumentVersionFile(docId, versionId, file, filename); const res = await refreshDocumentVersionState(docId); - const replaced = res.versions.find( - (version) => version.id === versionId, - ); + const replaced = res.versions.find((version) => version.id === versionId); if (replaced) { setViewingDocVersion({ id: replaced.id, @@ -477,39 +455,25 @@ export function DocTable({ /** * Patch a version filename and update the local cache in place. */ - async function handleRenameVersion( - docId: string, - versionId: string, - filename: string | null, - ) { + async function handleRenameVersion(docId: string, versionId: string, filename: string | null) { const previousFilename = versionsByDocId .get(docId) ?.versions.find((version) => version.id === versionId) ?.filename?.trim(); - if ( - previousFilename && - (filename == null || - hasFilenameExtensionChange(previousFilename, filename)) - ) { + if (previousFilename && (filename == null || hasFilenameExtensionChange(previousFilename, filename))) { setDocumentRenameWarning(extensionChangeWarning(previousFilename)); return; } try { - const updated = await renameDocumentVersion( - docId, - versionId, - filename, - ); + const updated = await renameDocumentVersion(docId, versionId, filename); setVersionsByDocId((prev) => { const cached = prev.get(docId); if (!cached) return prev; const next = new Map(prev); next.set(docId, { ...cached, - versions: cached.versions.map((v) => - v.id === versionId ? updated : v, - ), + versions: cached.versions.map((v) => (v.id === versionId ? updated : v)), }); return next; }); @@ -522,13 +486,9 @@ export function DocTable({ try { await deleteDocumentVersion(docId, versionId); const res = await refreshDocumentVersionState(docId); - const activeVersions = res.versions.filter( - (version) => version.deleted_at == null, - ); + const activeVersions = res.versions.filter((version) => version.deleted_at == null); const nextVersion = - activeVersions.find( - (version) => version.id === res.current_version_id, - ) ?? + activeVersions.find((version) => version.id === res.current_version_id) ?? activeVersions[activeVersions.length - 1] ?? null; setViewingDocVersion( @@ -545,83 +505,47 @@ export function DocTable({ } } - const [renamingDocumentId, setRenamingDocumentId] = useState( - null, - ); + const [renamingDocumentId, setRenamingDocumentId] = useState(null); const [renameDocumentValue, setRenameDocumentValue] = useState(""); // Folder state - const [expandedFolderIds, setExpandedFolderIds] = useState>( - new Set(), - ); - const [loadingChildFolderIds, setLoadingChildFolderIds] = useState< - Set - >(() => new Set()); + const [expandedFolderIds, setExpandedFolderIds] = useState>(new Set()); + const [loadingChildFolderIds, setLoadingChildFolderIds] = useState>(() => new Set()); // undefined = not creating; null = creating at root; string = creating inside that folder id - const [creatingFolderIn, setCreatingFolderIn] = useState< - string | null | undefined - >(undefined); + const [creatingFolderIn, setCreatingFolderIn] = useState(undefined); const [newFolderName, setNewFolderName] = useState(""); - const [renamingFolderId, setRenamingFolderId] = useState( - null, - ); + const [renamingFolderId, setRenamingFolderId] = useState(null); const [renameFolderValue, setRenameFolderValue] = useState(""); - const [contextMenu, setContextMenu] = useState( - null, - ); + const [contextMenu, setContextMenu] = useState(null); const contextMenuRef = useRef(null); const newFolderInputRef = useRef(null); const versionUploadInputRef = useRef(null); - const [dragOverFolderId, setDragOverFolderId] = useState( - null, - ); + const [dragOverFolderId, setDragOverFolderId] = useState(null); const [dragOverRoot, setDragOverRoot] = useState(false); const [dragOverFileRoot, setDragOverFileRoot] = useState(false); - const [isDraggingCollectionFiles, setIsDraggingCollectionFiles] = - useState(false); + const [isDraggingCollectionFiles, setIsDraggingCollectionFiles] = useState(false); const collectionDragDepthRef = useRef(0); - const [dragOverVersionDocId, setDragOverVersionDocId] = useState< - string | null - >(null); - const [uploadingVersionDocIds, setUploadingVersionDocIds] = useState< - Set - >(() => new Set()); - const [versionUploadTargetDoc, setVersionUploadTargetDoc] = - useState(null); - const [uploadingDroppedFilenames, setUploadingDroppedFilenames] = useState< - string[] - >([]); - const [deletingDocIds, setDeletingDocIds] = useState>( - () => new Set(), - ); - const [documentUploadWarning, setDocumentUploadWarning] = useState< - string | null - >(null); - const [documentRenameWarning, setDocumentRenameWarning] = useState< - string | null - >(null); - const [collectionActionWarning, setCollectionActionWarning] = useState< - string | null - >(null); + const [dragOverVersionDocId, setDragOverVersionDocId] = useState(null); + const [uploadingVersionDocIds, setUploadingVersionDocIds] = useState>(() => new Set()); + const [versionUploadTargetDoc, setVersionUploadTargetDoc] = useState(null); + const [uploadingDroppedFilenames, setUploadingDroppedFilenames] = useState([]); + const [deletingDocIds, setDeletingDocIds] = useState>(() => new Set()); + const [documentUploadWarning, setDocumentUploadWarning] = useState(null); + const [documentRenameWarning, setDocumentRenameWarning] = useState(null); + const [collectionActionWarning, setCollectionActionWarning] = useState(null); const [pendingVersionDrop, setPendingVersionDrop] = useState<{ targetDoc: Document; sourceDoc: Document; } | null>(null); - const [pendingDeleteDoc, setPendingDeleteDoc] = useState( - null, - ); - const [pendingDeleteStatus, setPendingDeleteStatus] = useState< - "idle" | "deleting" | "deleted" - >("idle"); + const [pendingDeleteDoc, setPendingDeleteDoc] = useState(null); + const [pendingDeleteStatus, setPendingDeleteStatus] = useState<"idle" | "deleting" | "deleted">("idle"); const [pendingDeleteFolder, setPendingDeleteFolder] = useState<{ folder: DocTableFolder; folderIds: string[]; documentIds: string[]; documentCount: number; } | null>(null); - const [pendingDeleteFolderStatus, setPendingDeleteFolderStatus] = useState< - "idle" | "deleting" | "deleted" - >("idle"); + const [pendingDeleteFolderStatus, setPendingDeleteFolderStatus] = useState<"idle" | "deleting" | "deleted">("idle"); const openCreateFolder = useCallback(() => { if (loadingRef.current) return; @@ -643,6 +567,8 @@ export function DocTable({ useEffect(() => { setSelectedDocIds([]); + setSelectionCameFromSelectAll(false); + setConfirmDeleteAllOpen(false); setContextMenu(null); setTypeFilter(null); setSort(null); @@ -652,11 +578,7 @@ export function DocTable({ useEffect(() => { if (!contextMenu) return; function handle(e: MouseEvent) { - if ( - contextMenuRef.current && - !contextMenuRef.current.contains(e.target as Node) - ) - setContextMenu(null); + if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) setContextMenu(null); } document.addEventListener("mousedown", handle); return () => document.removeEventListener("mousedown", handle); @@ -756,9 +678,7 @@ export function DocTable({ const name = renameFolderValue.trim(); setRenamingFolderId(null); if (!name) return; - setFolders((prev) => - prev.map((f) => (f.id === folderId ? { ...f, name } : f)), - ); + setFolders((prev) => prev.map((f) => (f.id === folderId ? { ...f, name } : f))); await operations.renameFolder(folderId, name); } @@ -766,8 +686,7 @@ export function DocTable({ const childrenByParent = new Map(); for (const folder of folders) { if (!folder.parent_folder_id) continue; - const children = - childrenByParent.get(folder.parent_folder_id) ?? []; + const children = childrenByParent.get(folder.parent_folder_id) ?? []; children.push(folder.id); childrenByParent.set(folder.parent_folder_id, children); } @@ -782,9 +701,7 @@ export function DocTable({ } const folderIds = [...toDelete]; - const documentIds = documents - .filter((d) => d.folder_id && toDelete.has(d.folder_id)) - .map((d) => d.id); + const documentIds = documents.filter((d) => d.folder_id && toDelete.has(d.folder_id)).map((d) => d.id); return { folderIds, documentIds, documentCount: documentIds.length }; } @@ -811,9 +728,7 @@ export function DocTable({ const toDelete = new Set(pending.folderIds); setFolders((prev) => prev.filter((f) => !toDelete.has(f.id))); - setDocuments((prev) => - prev.filter((d) => !d.folder_id || !toDelete.has(d.folder_id)), - ); + setDocuments((prev) => prev.filter((d) => !d.folder_id || !toDelete.has(d.folder_id))); setExpandedFolderIds((prev) => { const next = new Set(prev); for (const id of toDelete) next.delete(id); @@ -826,9 +741,7 @@ export function DocTable({ setContextMenu(null); } const deletedDocIds = new Set(pending.documentIds); - setSelectedDocIds((prev) => - prev.filter((id) => !deletedDocIds.has(id)), - ); + setSelectedDocIds((prev) => prev.filter((id) => !deletedDocIds.has(id))); setExpandedVersionDocIds((prev) => { const next = new Set(prev); for (const id of pending.documentIds) next.delete(id); @@ -847,21 +760,14 @@ export function DocTable({ } catch (err) { console.error("delete folder failed", err); setPendingDeleteFolderStatus("idle"); - setCollectionActionWarning( - "Folder could not be deleted. Please try again.", - ); + setCollectionActionWarning("Folder could not be deleted. Please try again."); } } // ── Doc/chat/review handlers ────────────────────────────────────────────── function handleDocsSelected(newDocs: Document[]) { - setDocuments((prev) => - [ - ...prev, - ...newDocs.filter((d) => !prev.some((e) => e.id === d.id)), - ], - ); + setDocuments((prev) => [...prev, ...newDocs.filter((d) => !prev.some((e) => e.id === d.id))]); } function removeDocumentFromLocalState(docId: string) { @@ -909,17 +815,11 @@ export function DocTable({ setDocuments((prev) => { if (prev.some((d) => d.id === doc.id)) return prev; const nextDocs = [...prev]; - nextDocs.splice( - Math.max(0, Math.min(snapshot.index, nextDocs.length)), - 0, - doc, - ); + nextDocs.splice(Math.max(0, Math.min(snapshot.index, nextDocs.length)), 0, doc); return nextDocs; }); if (snapshot.selected) { - setSelectedDocIds((prev) => - prev.includes(doc.id) ? prev : [...prev, doc.id], - ); + setSelectedDocIds((prev) => (prev.includes(doc.id) ? prev : [...prev, doc.id])); } if (snapshot.versionsOpen) { setExpandedVersionDocIds((prev) => new Set([...prev, doc.id])); @@ -948,11 +848,7 @@ export function DocTable({ } async function handleRemoveDocFromFolder(docId: string) { - setDocuments((prev) => - prev.map((d) => - d.id === docId ? { ...d, folder_id: null } : d, - ), - ); + setDocuments((prev) => prev.map((d) => (d.id === docId ? { ...d, folder_id: null } : d))); await operations.moveDocument(docId, null); } @@ -987,16 +883,10 @@ export function DocTable({ ); try { const updated = await operations.renameDocument(docId, trimmed); - setDocuments((prev) => - prev.map((d) => (d.id === docId ? { ...d, ...updated } : d)), - ); + setDocuments((prev) => prev.map((d) => (d.id === docId ? { ...d, ...updated } : d))); } catch (e) { console.error("renameDocument failed", e); - setDocuments((prev) => - previous - ? prev.map((d) => (d.id === docId ? previous : d)) - : prev, - ); + setDocuments((prev) => (previous ? prev.map((d) => (d.id === docId ? previous : d)) : prev)); } } @@ -1026,10 +916,7 @@ export function DocTable({ setOwnerOnlyAction("delete this document"); return; } - const versionCount = - versionsByDocId.get(doc.id)?.versions.length ?? - currentVersionNumber(doc) ?? - 1; + const versionCount = versionsByDocId.get(doc.id)?.versions.length ?? currentVersionNumber(doc) ?? 1; if (versionCount <= 1) { void handleRemoveDoc(doc.id); return; @@ -1059,9 +946,7 @@ export function DocTable({ function wouldCreateCycle(movingId: string, targetId: string): boolean { // Returns true if targetId is movingId or a descendant of it - let cur: DocTableFolder | undefined = folders.find( - (f) => f.id === targetId, - ); + let cur: DocTableFolder | undefined = folders.find((f) => f.id === targetId); while (cur) { if (cur.id === movingId) return true; if (!cur.parent_folder_id) break; @@ -1072,9 +957,7 @@ export function DocTable({ function hasMovePayload(dt: DataTransfer): boolean { return Array.from(dt.types).some( - (type) => - type === "application/mike-doc" || - type === "application/mike-folder", + (type) => type === "application/mike-doc" || type === "application/mike-folder", ); } @@ -1096,15 +979,12 @@ export function DocTable({ async function handleDropCollectionFiles(files: File[]) { if (files.length === 0) return; - const { supported, unsupported } = - partitionSupportedDocumentFiles(files); + const { supported, unsupported } = partitionSupportedDocumentFiles(files); setDocumentUploadWarning(formatUnsupportedDocumentWarning(unsupported)); if (supported.length === 0) return; setUploadingDroppedFilenames(supported.map((file) => file.name)); try { - const uploaded = await Promise.all( - supported.map((file) => operations.uploadDocument(file)), - ); + const uploaded = await Promise.all(supported.map((file) => operations.uploadDocument(file))); handleDocsSelected(uploaded); } catch (err) { console.error("Document drop upload failed", err); @@ -1132,10 +1012,7 @@ export function DocTable({ function handleDragLeave(event: globalThis.DragEvent) { if (!hasFiles(event.dataTransfer)) return; - collectionDragDepthRef.current = Math.max( - 0, - collectionDragDepthRef.current - 1, - ); + collectionDragDepthRef.current = Math.max(0, collectionDragDepthRef.current - 1); if (collectionDragDepthRef.current === 0) { setIsDraggingCollectionFiles(false); } @@ -1148,9 +1025,7 @@ export function DocTable({ collectionDragDepthRef.current = 0; setIsDraggingCollectionFiles(false); setDragOverFileRoot(false); - void handleDropCollectionFiles( - Array.from(event.dataTransfer?.files ?? []), - ); + void handleDropCollectionFiles(Array.from(event.dataTransfer?.files ?? [])); } window.addEventListener("dragenter", handleDragEnter); @@ -1167,8 +1042,7 @@ export function DocTable({ async function handleDropDocumentVersions(doc: Document, files: File[]) { if (files.length === 0) return; - const { supported, unsupported } = - partitionSupportedDocumentFiles(files); + const { supported, unsupported } = partitionSupportedDocumentFiles(files); setDocumentUploadWarning(formatUnsupportedDocumentWarning(unsupported)); if (supported.length === 0) return; @@ -1189,42 +1063,29 @@ export function DocTable({ } } - async function saveExistingDocumentAsNewVersion( - targetDoc: Document, - sourceDoc: Document, - ) { - const sourceIndex = - documents.findIndex((doc) => doc.id === sourceDoc.id); + async function saveExistingDocumentAsNewVersion(targetDoc: Document, sourceDoc: Document) { + const sourceIndex = documents.findIndex((doc) => doc.id === sourceDoc.id); const sourceSnapshot = { index: sourceIndex >= 0 ? sourceIndex : 0, selected: selectedDocIds.includes(sourceDoc.id), versionsOpen: expandedVersionDocIds.has(sourceDoc.id), versions: versionsByDocId.get(sourceDoc.id)?.versions, - currentVersionId: versionsByDocId.get(sourceDoc.id) - ?.currentVersionId, + currentVersionId: versionsByDocId.get(sourceDoc.id)?.currentVersionId, loadingVersions: loadingVersionDocIds.has(sourceDoc.id), uploadingVersion: uploadingVersionDocIds.has(sourceDoc.id), viewing: viewingDoc?.id === sourceDoc.id, - viewingVersion: - viewingDoc?.id === sourceDoc.id ? viewingDocVersion : null, + viewingVersion: viewingDoc?.id === sourceDoc.id ? viewingDocVersion : null, }; setUploadingVersionDocIds((prev) => new Set([...prev, targetDoc.id])); removeDocumentFromLocalState(sourceDoc.id); try { - await copyDocumentVersionFromDocument( - targetDoc.id, - sourceDoc.id, - sourceDoc.filename, - ); + await copyDocumentVersionFromDocument(targetDoc.id, sourceDoc.id, sourceDoc.filename); await refreshDocumentVersionState(targetDoc.id); } catch (err) { console.error("Existing document version drop failed", err); restoreDocumentToLocalState(sourceDoc, sourceSnapshot); - setCollectionActionWarning( - apiErrorDetail(err) ?? - "Could not save this document as a new version.", - ); + setCollectionActionWarning(apiErrorDetail(err) ?? "Could not save this document as a new version."); } finally { setUploadingVersionDocIds((prev) => { const next = new Set(prev); @@ -1234,24 +1095,15 @@ export function DocTable({ } } - function handleDropExistingDocumentVersion( - targetDoc: Document, - sourceDocId: string, - ) { + function handleDropExistingDocumentVersion(targetDoc: Document, sourceDocId: string) { if (!sourceDocId || sourceDocId === targetDoc.id) return; const sourceDoc = documents.find((doc) => doc.id === sourceDocId); if (!sourceDoc) return; setPendingVersionDrop({ targetDoc, sourceDoc }); } - function handleDocumentVersionDragOver( - e: DragEvent, - docId: string, - ) { - if ( - !hasFilePayload(e.dataTransfer) && - !hasDocumentPayload(e.dataTransfer) - ) { + function handleDocumentVersionDragOver(e: DragEvent, docId: string) { + if (!hasFilePayload(e.dataTransfer) && !hasDocumentPayload(e.dataTransfer)) { return; } e.preventDefault(); @@ -1268,14 +1120,8 @@ export function DocTable({ } } - function handleDocumentVersionDrop( - e: DragEvent, - doc: Document, - ) { - if ( - !hasFilePayload(e.dataTransfer) && - !hasDocumentPayload(e.dataTransfer) - ) { + function handleDocumentVersionDrop(e: DragEvent, doc: Document) { + if (!hasFilePayload(e.dataTransfer) && !hasDocumentPayload(e.dataTransfer)) { return; } e.preventDefault(); @@ -1287,49 +1133,27 @@ export function DocTable({ setDragOverRoot(false); setDragOverFolderId(null); if (hasFilePayload(e.dataTransfer)) { - void handleDropDocumentVersions( - doc, - Array.from(e.dataTransfer.files), - ); + void handleDropDocumentVersions(doc, Array.from(e.dataTransfer.files)); return; } - void handleDropExistingDocumentVersion( - doc, - e.dataTransfer.getData("application/mike-doc"), - ); + void handleDropExistingDocumentVersion(doc, e.dataTransfer.getData("application/mike-doc")); } - async function handleDropOnFolder( - targetFolderId: string | null, - dt: DataTransfer, - ) { + async function handleDropOnFolder(targetFolderId: string | null, dt: DataTransfer) { if (!hasMovePayload(dt)) return; const docId = dt.getData("application/mike-doc"); const subFolderId = dt.getData("application/mike-folder"); if (docId) { const doc = documents.find((d) => d.id === docId); if (!doc || (doc.folder_id ?? null) === targetFolderId) return; - setDocuments((prev) => - prev.map((d) => - d.id === docId ? { ...d, folder_id: targetFolderId } : d, - ), - ); + setDocuments((prev) => prev.map((d) => (d.id === docId ? { ...d, folder_id: targetFolderId } : d))); await operations.moveDocument(docId, targetFolderId); } else if (subFolderId && subFolderId !== targetFolderId) { - if ( - targetFolderId !== null && - wouldCreateCycle(subFolderId, targetFolderId) - ) - return; + if (targetFolderId !== null && wouldCreateCycle(subFolderId, targetFolderId)) return; const folder = folders.find((f) => f.id === subFolderId); - if (!folder || (folder.parent_folder_id ?? null) === targetFolderId) - return; + if (!folder || (folder.parent_folder_id ?? null) === targetFolderId) return; setFolders((prev) => - prev.map((f) => - f.id === subFolderId - ? { ...f, parent_folder_id: targetFolderId } - : f, - ), + prev.map((f) => (f.id === subFolderId ? { ...f, parent_folder_id: targetFolderId } : f)), ); await operations.moveFolder(subFolderId, targetFolderId); } @@ -1361,8 +1185,7 @@ export function DocTable({ value={newFolderName} onChange={(e) => setNewFolderName(e.target.value)} onKeyDown={(e) => { - if (e.key === "Enter") - void handleCreateFolder(parentId); + if (e.key === "Enter") void handleCreateFolder(parentId); if (e.key === "Escape") { setCreatingFolderIn(undefined); setNewFolderName(""); @@ -1396,10 +1219,7 @@ export function DocTable({ statusLabel: string; }) { return ( -
+
- - - - {filename} + + {filename}
- {fileType ?? - (filename.includes(".") - ? filename.split(".").pop() - : "file")} -
-
- {statusLabel} + {fileType ?? (filename.includes(".") ? filename.split(".").pop() : "file")}
+
{statusLabel}
@@ -1447,18 +1257,11 @@ export function DocTable({ } function renderLevel(parentId: string | null, depth: number) { - const nameMultiplier = - enableHeaderFilters && - sort?.key === "name" && - sort.direction === "desc" - ? -1 - : 1; + const nameMultiplier = enableHeaderFilters && sort?.key === "name" && sort.direction === "desc" ? -1 : 1; const childFolders = folders .filter((f) => f.parent_folder_id === parentId) .sort((a, b) => a.name.localeCompare(b.name) * nameMultiplier); - const childDocs = filteredDocs.filter( - (d) => (d.folder_id ?? null) === parentId, - ); + const childDocs = filteredDocs.filter((d) => (d.folder_id ?? null) === parentId); return ( <> @@ -1466,17 +1269,13 @@ export function DocTable({ {/* Files first */} {childDocs.map((doc) => { const docName = doc.filename; - const isProcessing = - doc.status === "pending" || doc.status === "processing"; + const isProcessing = doc.status === "pending" || doc.status === "processing"; const isError = doc.status === "error"; const isVersionsOpen = expandedVersionDocIds.has(doc.id); const versionNumber = currentVersionNumber(doc); - const hasVersions = - typeof versionNumber === "number" && versionNumber > 1; + const hasVersions = typeof versionNumber === "number" && versionNumber > 1; const isVersionDragOver = dragOverVersionDocId === doc.id; - const isUploadingVersion = uploadingVersionDocIds.has( - doc.id, - ); + const isUploadingVersion = uploadingVersionDocIds.has(doc.id); const isSelected = selectedDocIds.includes(doc.id); const isDeletingDoc = deletingDocIds.has(doc.id); if (isDeletingDoc) { @@ -1498,10 +1297,7 @@ export function DocTable({ e.preventDefault(); return; } - e.dataTransfer.setData( - "application/mike-doc", - doc.id, - ); + e.dataTransfer.setData("application/mike-doc", doc.id); e.dataTransfer.effectAllowed = "copyMove"; }} onDragEnd={() => { @@ -1509,13 +1305,9 @@ export function DocTable({ setDragOverFolderId(null); setDragOverVersionDocId(null); }} - onDragOver={(e) => - handleDocumentVersionDragOver(e, doc.id) - } + onDragOver={(e) => handleDocumentVersionDragOver(e, doc.id)} onDragLeave={handleDocumentVersionDragLeave} - onDrop={(e) => - handleDocumentVersionDrop(e, doc) - } + onDrop={(e) => handleDocumentVersionDrop(e, doc)} onClick={() => { setViewingDocVersion(null); setViewingDoc(doc); @@ -1547,37 +1339,20 @@ export function DocTable({ style={treeNameCellStyle(depth)} >
- {isProcessing || - isUploadingVersion ? ( + {isProcessing || isUploadingVersion ? ( ) : ( - setSelectedDocIds( - (prev) => - prev.includes( - doc.id, - ) - ? prev.filter( - ( - x, - ) => - x !== - doc.id, - ) - : [ - ...prev, - doc.id, - ], - ) - } - onClick={(e) => - e.stopPropagation() - } + checked={selectedDocIds.includes(doc.id)} + onChange={() => { + setSelectedDocIds((prev) => + prev.includes(doc.id) + ? prev.filter((x) => x !== doc.id) + : [...prev, doc.id], + ); + }} + onClick={(e) => e.stopPropagation()} className="mr-4 h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black" /> )} @@ -1585,61 +1360,29 @@ export function DocTable({ {isError ? ( ) : ( - + )} - {renamingDocumentId === - doc.id ? ( + {renamingDocumentId === doc.id ? ( - e.stopPropagation() - } - onDragStart={( - e, - ) => { + value={renameDocumentValue} + onClick={(e) => e.stopPropagation()} + onDragStart={(e) => { e.preventDefault(); e.stopPropagation(); }} - onChange={(e) => - setRenameDocumentValue( - e.target - .value, - ) - } + onChange={(e) => setRenameDocumentValue(e.target.value)} onKeyDown={(e) => { - if ( - e.key === - "Enter" - ) - void submitDocumentRename( - doc.id, - ); - if ( - e.key === - "Escape" - ) { - setRenamingDocumentId( - null, - ); - setRenameDocumentValue( - "", - ); + if (e.key === "Enter") + void submitDocumentRename(doc.id); + if (e.key === "Escape") { + setRenamingDocumentId(null); + setRenameDocumentValue(""); } }} - onBlur={() => - void submitDocumentRename( - doc.id, - ) - } + onBlur={() => void submitDocumentRename(doc.id)} /> ) : ( @@ -1649,39 +1392,25 @@ export function DocTable({
- {doc.file_type ?? ( - - — - - )} + {doc.file_type ?? }
{doc.size_bytes != null ? ( formatBytes(doc.size_bytes) ) : ( - - — - + )}
- e.stopPropagation() - } + onClick={(e) => e.stopPropagation()} > {hasVersions ? ( ) : ( - - — - + )}
{doc.created_at ? ( formatDate(doc.created_at) ) : ( - - — - + )}
{doc.updated_at ? ( formatDate(doc.updated_at) ) : ( - - — - + )}
{!isProcessing && ( { - setRenameDocumentValue( - docName, - ); - setRenamingDocumentId( - doc.id, - ); + setRenameDocumentValue(docName); + setRenamingDocumentId(doc.id); }} renameLabel="Rename document" - onDownload={() => - downloadDoc(doc.id) - } + onDownload={() => downloadDoc(doc.id)} onShowAllVersions={ - hasVersions && - !isVersionsOpen - ? () => - void toggleVersions( - doc.id, - ) + hasVersions && !isVersionsOpen + ? () => void toggleVersions(doc.id) : undefined } - onUploadNewVersion={() => - void handleUploadNewVersion( - doc, - ) - } + onUploadNewVersion={() => void handleUploadNewVersion(doc)} onRemoveFromFolder={ doc.folder_id - ? () => - handleRemoveDocFromFolder( - doc.id, - ) + ? () => handleRemoveDocFromFolder(doc.id) : undefined } - onDelete={() => - requestRemoveDoc( - doc, - ) - } - deleteDisabled={isSharedDocument( - doc, - )} + onDelete={() => requestRemoveDoc(doc)} + deleteDisabled={isSharedDocument(doc)} /> )}
@@ -1770,14 +1470,8 @@ export function DocTable({ filename={docName} activeVersionNumber={versionNumber} loading={loadingVersionDocIds.has(doc.id)} - versions={ - versionsByDocId.get(doc.id)?.versions ?? - [] - } - currentVersionId={ - versionsByDocId.get(doc.id) - ?.currentVersionId ?? null - } + versions={versionsByDocId.get(doc.id)?.versions ?? []} + currentVersionId={versionsByDocId.get(doc.id)?.currentVersionId ?? null} depth={depth} onDownloadVersion={downloadDocVersion} onOpenVersion={(versionId, label) => { @@ -1788,16 +1482,10 @@ export function DocTable({ setViewingDoc(doc); }} onRenameVersion={(versionId, filename) => - handleRenameVersion( - doc.id, - versionId, - filename, - ) + handleRenameVersion(doc.id, versionId, filename) } onExtensionChangeBlocked={(filename) => - setDocumentRenameWarning( - extensionChangeWarning(filename), - ) + setDocumentRenameWarning(extensionChangeWarning(filename)) } /> )} @@ -1809,21 +1497,11 @@ export function DocTable({
- onLoadMoreDocuments(parentId) - } + onLoadMore={() => onLoadMoreDocuments(parentId)} />
)} @@ -1832,9 +1510,7 @@ export function DocTable({ {childFolders.map((folder) => { const isExpanded = expandedFolderIds.has(folder.id); const isRenaming = renamingFolderId === folder.id; - const isLoadingChildren = loadingChildFolderIds.has( - folder.id, - ); + const isLoadingChildren = loadingChildFolderIds.has(folder.id); return (
toggleFolder(folder.id)} onContextMenu={(e) => { @@ -1902,10 +1572,7 @@ export function DocTable({ )} - + {isRenaming ? ( - setRenameFolderValue( - e.target.value, - ) - } + onChange={(e) => setRenameFolderValue(e.target.value)} onKeyDown={(e) => { - if (e.key === "Enter") - void handleRenameFolder( - folder.id, - ); - if (e.key === "Escape") - setRenamingFolderId( - null, - ); + if (e.key === "Enter") void handleRenameFolder(folder.id); + if (e.key === "Escape") setRenamingFolderId(null); }} - onBlur={() => - void handleRenameFolder( - folder.id, - ) - } - onClick={(e) => - e.stopPropagation() - } + onBlur={() => void handleRenameFolder(folder.id)} + onClick={(e) => e.stopPropagation()} /> ) : ( - - {folder.name} - + {folder.name} )}
-
- — -
-
- — -
-
- — -
-
- — -
-
- — -
-
e.stopPropagation()} - > +
+
+
+
+
+
e.stopPropagation()}> { setRenameFolderValue(folder.name); setRenamingFolderId(folder.id); }} - onDelete={() => - requestDeleteFolder(folder.id) - } + onDelete={() => requestDeleteFolder(folder.id)} />
@@ -1989,7 +1623,7 @@ export function DocTable({ // ── Loading skeleton ────────────────────────────────────────────────────── - const docs = documents; + const docs = serverDocuments ?? documents; const downloadDoc = useCallback(async (docId: string) => { const { url, filename } = await getDocumentUrl(docId); const a = document.createElement("a"); @@ -2013,18 +1647,10 @@ export function DocTable({ }, [downloadDoc, selectedDocIds]); const handleRemoveSelectedFromFolder = useCallback(async () => { - const ids = selectedDocIds.filter( - (id) => docs.find((d) => d.id === id)?.folder_id != null, - ); + const ids = selectedDocIds.filter((id) => docs.find((d) => d.id === id)?.folder_id != null); if (ids.length === 0) return; - setDocuments((prev) => - prev.map((d) => - ids.includes(d.id) ? { ...d, folder_id: null } : d, - ), - ); - await Promise.all( - ids.map((id) => operations.moveDocument(id, null).catch(() => {})), - ); + setDocuments((prev) => prev.map((d) => (ids.includes(d.id) ? { ...d, folder_id: null } : d))); + await Promise.all(ids.map((id) => operations.moveDocument(id, null).catch(() => {}))); }, [docs, operations, selectedDocIds, setDocuments]); const handleDeleteSelectedDocs = useCallback(async () => { @@ -2034,17 +1660,39 @@ export function DocTable({ return !doc || !doc.user_id || !user?.id || doc.user_id === user.id; }); const blocked = ids.length - owned.length; + setConfirmDeleteAllOpen(false); + setSelectionCameFromSelectAll(false); setSelectedDocIds([]); - const results = await Promise.allSettled( - owned.map((id) => deleteDocument(id)), - ); - const deletedIds = owned.filter( - (_, index) => results[index].status === "fulfilled", - ); + let deletedIds: string[] = []; + if (operations.bulkDeleteDocuments) { + try { + const result = await operations.bulkDeleteDocuments(owned); + deletedIds = result.deletedIds; + } catch { + deletedIds = []; + } + } else { + // Keep destructive requests bounded for project tables, which do + // not yet expose a collection-specific bulk endpoint. + const pending = [...owned]; + const workers = Array.from({ length: Math.min(8, pending.length) }, async () => { + const workerDeleted: string[] = []; + while (pending.length > 0) { + const id = pending.shift(); + if (!id) break; + try { + await deleteDocument(id); + workerDeleted.push(id); + } catch { + // Report the aggregate failure below. + } + } + return workerDeleted; + }); + deletedIds = (await Promise.all(workers)).flat(); + } const failedCount = owned.length - deletedIds.length; - setDocuments((prev) => - prev.filter((doc) => !deletedIds.includes(doc.id)), - ); + setDocuments((prev) => prev.filter((doc) => !deletedIds.includes(doc.id))); if (deletedIds.length > 0) { setExpandedVersionDocIds((prev) => { const next = new Set(prev); @@ -2067,20 +1715,23 @@ export function DocTable({ `delete ${blocked} of the selected documents — only the document creator can delete a document`, ); } - }, [ - documents, - selectedDocIds, - setDocuments, - setOwnerOnlyAction, - user?.id, - ]); - - const sidePanelDoc = viewingDoc - ? (docs.find((doc) => doc.id === viewingDoc.id) ?? viewingDoc) - : null; + if (deletedIds.length > 0 && operations.bulkDeleteDocuments) { + await operations.refreshCollection(); + } + }, [documents, operations, selectedDocIds, setDocuments, setOwnerOnlyAction, user?.id]); + + const requestDeleteSelectedDocs = useCallback(async () => { + if (selectionCameFromSelectAll) { + setConfirmDeleteAllOpen(true); + return; + } + await handleDeleteSelectedDocs(); + }, [handleDeleteSelectedDocs, selectionCameFromSelectAll]); + + const sidePanelDoc = viewingDoc ? (docs.find((doc) => doc.id === viewingDoc.id) ?? viewingDoc) : null; const versionUploadAccept = ".pdf,.docx,.doc,.xlsx,.xlsm,.xls,.pptx,.ppt"; - const q = search.toLowerCase(); - const typeOptions = useMemo( + const q = serverQueryActive ? "__server_results__" : search.toLowerCase(); + const derivedTypeOptions = useMemo( () => Array.from(new Set(docs.map(documentTypeValue))) .sort((a, b) => a.localeCompare(b)) @@ -2090,9 +1741,51 @@ export function DocTable({ })), [docs], ); + const typeOptions = documentTypeOptions ?? derivedTypeOptions; + + useEffect(() => { + onServerQueryChange?.({ search, fileType: typeFilter, sort }); + }, [onServerQueryChange, search, sort, typeFilter]); + + const activePageLoadingMore = serverQueryActive ? serverQueryLoadingMore : !!loadingMoreDocumentsByLevel?.root; + + useEffect(() => { + if (!activePageLoadingMore) autoLoadTriggeredRef.current = false; + }, [activePageLoadingMore]); + + function handleTableScroll(event: UIEvent) { + if (!autoLoadOnScroll || autoLoadTriggeredRef.current) return; + const viewport = event.currentTarget; + const distanceFromBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight; + if (distanceFromBottom > 80) return; + + if ( + serverQueryActive && + serverQueryHasMore && + !serverQueryLoading && + !serverQueryLoadingMore && + onLoadMoreServerDocuments + ) { + autoLoadTriggeredRef.current = true; + onLoadMoreServerDocuments(); + return; + } + + if ( + !serverQueryActive && + documentsHasMoreByLevel?.root && + !loadingMoreDocumentsByLevel?.root && + onLoadMoreDocuments + ) { + autoLoadTriggeredRef.current = true; + onLoadMoreDocuments(null); + } + } function clearDocumentSelection() { setSelectedDocIds([]); + setSelectionCameFromSelectAll(false); + setConfirmDeleteAllOpen(false); } function handleTypeFilterChange(value: string | null) { @@ -2100,27 +1793,17 @@ export function DocTable({ clearDocumentSelection(); } - function handleSortChange( - key: DocumentSortKey, - direction: TableSortDirection | null, - ) { + function handleSortChange(key: DocumentSortKey, direction: TableSortDirection | null) { setSort(direction ? { key, direction } : null); clearDocumentSelection(); } const filteredDocs = useMemo(() => { + if (serverQueryActive) return docs; + const rows = docs - .filter( - (doc) => - !q || - doc.filename.toLowerCase().includes(q), - ) - .filter( - (doc) => - !enableHeaderFilters || - !typeFilter || - documentTypeValue(doc) === typeFilter, - ); + .filter((doc) => !q || doc.filename.toLowerCase().includes(q)) + .filter((doc) => !enableHeaderFilters || !typeFilter || documentTypeValue(doc) === typeFilter); if (!enableHeaderFilters || !sort) return rows; @@ -2132,41 +1815,26 @@ export function DocTable({ } if (sort.key === "version") { - return ( - ((documentVersionNumber(a) ?? 0) - - (documentVersionNumber(b) ?? 0)) * - multiplier - ); + return ((documentVersionNumber(a) ?? 0) - (documentVersionNumber(b) ?? 0)) * multiplier; } if (sort.key === "created") { - return ( - (dateTimeValue(a.created_at) - - dateTimeValue(b.created_at)) * - multiplier - ); + return (dateTimeValue(a.created_at) - dateTimeValue(b.created_at)) * multiplier; } if (sort.key === "updated") { - return ( - (dateTimeValue(a.updated_at) - - dateTimeValue(b.updated_at)) * - multiplier - ); + return (dateTimeValue(a.updated_at) - dateTimeValue(b.updated_at)) * multiplier; } return a.filename.localeCompare(b.filename) * multiplier; }); - }, [docs, enableHeaderFilters, q, sort, typeFilter]); + }, [docs, enableHeaderFilters, q, serverQueryActive, sort, typeFilter]); const nameSortDirection = sort?.key === "name" ? sort.direction : null; const sizeSortDirection = sort?.key === "size" ? sort.direction : null; - const versionSortDirection = - sort?.key === "version" ? sort.direction : null; - const createdSortDirection = - sort?.key === "created" ? sort.direction : null; - const updatedSortDirection = - sort?.key === "updated" ? sort.direction : null; + const versionSortDirection = sort?.key === "version" ? sort.direction : null; + const createdSortDirection = sort?.key === "created" ? sort.direction : null; + const updatedSortDirection = sort?.key === "updated" ? sort.direction : null; const nameFilterButton = enableHeaderFilters ? ( ) : null; - const allDocsSelected = - filteredDocs.length > 0 && - filteredDocs.every((d) => selectedDocIds.includes(d.id)); - const someDocsSelected = - !allDocsSelected && - filteredDocs.some((d) => selectedDocIds.includes(d.id)); + const allDocsSelected = filteredDocs.length > 0 && filteredDocs.every((d) => selectedDocIds.includes(d.id)); + const someDocsSelected = !allDocsSelected && filteredDocs.some((d) => selectedDocIds.includes(d.id)); + + const handleToggleAllDocuments = useCallback(async () => { + if (allDocsSelected || selectionCameFromSelectAll) { + setSelectedDocIds([]); + setSelectionCameFromSelectAll(false); + return; + } + + if (!onSelectAllMatching) { + setSelectedDocIds(filteredDocs.map((document) => document.id)); + setSelectionCameFromSelectAll(true); + return; + } + + setSelectingAllDocuments(true); + try { + const ids = await onSelectAllMatching({ + search, + fileType: typeFilter, + sort, + }); + setSelectedDocIds(ids); + setSelectionCameFromSelectAll(true); + } catch (error) { + setCollectionActionWarning( + apiErrorDetail(error) ?? "All matching files could not be selected. Please try again.", + ); + } finally { + setSelectingAllDocuments(false); + } + }, [allDocsSelected, filteredDocs, onSelectAllMatching, search, selectionCameFromSelectAll, sort, typeFilter]); const selectionActions = useMemo(() => { if (selectedDocIds.length === 0) return null; return { selectedCount: selectedDocIds.length, - hasDocumentsInFolders: selectedDocIds.some( - (id) => docs.find((d) => d.id === id)?.folder_id != null, - ), + hasDocumentsInFolders: selectedDocIds.some((id) => docs.find((d) => d.id === id)?.folder_id != null), onDownload: handleDownloadSelectedDocs, onRemoveFromFolder: handleRemoveSelectedFromFolder, - onDelete: handleDeleteSelectedDocs, + onDelete: requestDeleteSelectedDocs, }; - }, [ - docs, - handleDeleteSelectedDocs, - handleDownloadSelectedDocs, - handleRemoveSelectedFromFolder, - selectedDocIds, - ]); + }, [docs, handleDownloadSelectedDocs, handleRemoveSelectedFromFolder, requestDeleteSelectedDocs, selectedDocIds]); useEffect(() => { onSelectionActionsChange?.(selectionActions); @@ -2267,20 +1954,12 @@ export function DocTable({

You are about to save{" "} - - {pendingVersionDrop.sourceDoc.filename} - {" "} - as a new version of{" "} - - {pendingVersionDrop.targetDoc.filename} - - . + {pendingVersionDrop.sourceDoc.filename} as a new + version of {pendingVersionDrop.targetDoc.filename}.

- - {pendingVersionDrop.sourceDoc.filename} - {" "} - will no longer exist as a separate document + {pendingVersionDrop.sourceDoc.filename} will no + longer exist as a separate document {(currentVersionNumber(pendingVersionDrop.sourceDoc) ?? 1) > 1 ? " and its older versions will be deleted" : ""} @@ -2289,19 +1968,14 @@ export function DocTable({

) : undefined; const pendingDeleteDocVersionCount = pendingDeleteDoc - ? (versionsByDocId.get(pendingDeleteDoc.id)?.versions.length ?? - currentVersionNumber(pendingDeleteDoc) ?? - 1) + ? (versionsByDocId.get(pendingDeleteDoc.id)?.versions.length ?? currentVersionNumber(pendingDeleteDoc) ?? 1) : 0; const pendingDeleteDocMessage = pendingDeleteDoc ? (

- - {pendingDeleteDoc.filename} - {" "} - has {pendingDeleteDocVersionCount}{" "} - {pendingDeleteDocVersionCount === 1 ? "version" : "versions"}. - Deleting this document will delete all of its versions. + {pendingDeleteDoc.filename} has{" "} + {pendingDeleteDocVersionCount} {pendingDeleteDocVersionCount === 1 ? "version" : "versions"}. Deleting + this document will delete all of its versions.

) : undefined; @@ -2311,30 +1985,17 @@ export function DocTable({ This will permanently delete{" "} {pendingDeleteFolder.folderIds.length}{" "} - {pendingDeleteFolder.folderIds.length === 1 - ? "folder" - : "folders"} + {pendingDeleteFolder.folderIds.length === 1 ? "folder" : "folders"} - , including{" "} - - {pendingDeleteFolder.folder.name} - - {pendingDeleteFolder.folderIds.length > 1 - ? " and its nested subfolders" - : ""} - . + , including {pendingDeleteFolder.folder.name} + {pendingDeleteFolder.folderIds.length > 1 ? " and its nested subfolders" : ""}.

{pendingDeleteFolder.documentCount > 0 && (

{pendingDeleteFolder.documentCount}{" "} - {pendingDeleteFolder.documentCount === 1 - ? "document" - : "documents"}{" "} - in the deleted{" "} - {pendingDeleteFolder.folderIds.length === 1 - ? "folder" - : "folders"}{" "} - will also be permanently deleted. + {pendingDeleteFolder.documentCount === 1 ? "document" : "documents"} in the deleted{" "} + {pendingDeleteFolder.folderIds.length === 1 ? "folder" : "folders"} will also be permanently + deleted.

)}
@@ -2377,6 +2038,15 @@ export function DocTable({ onClose={() => setCollectionActionWarning(null)} message={collectionActionWarning} /> + 0} + title="Delete all selected files?" + message={`This will permanently delete every selected file you own, including selected files not currently shown in the table. Files owned by others will be skipped. ${selectedDocIds.length} files are selected.`} + confirmLabel="Delete" + cancelLabel="Cancel" + onCancel={() => setConfirmDeleteAllOpen(false)} + onConfirm={() => void handleDeleteSelectedDocs()} + /> {/* Table content */} + loading || (serverQueryActive && serverQueryLoading) ? ( + ) : ( - - + + { - if (el) - el.indeterminate = - someDocsSelected; - }} - onChange={() => { - if (allDocsSelected) - setSelectedDocIds([]); - else - setSelectedDocIds( - filteredDocs.map((d) => d.id), - ); + if (el) el.indeterminate = someDocsSelected; }} + onChange={() => void handleToggleAllDocuments()} className={TABLE_CHECKBOX_CLASS} /> Name @@ -2496,750 +2148,453 @@ export function DocTable({ ) } > - {loading ? ( - - ) : ( -
- {/* Blue ring wraps everything below the header when root-dropping */} -
{ - if (!hasFilePayload(e.dataTransfer)) return; - e.preventDefault(); - e.dataTransfer.dropEffect = "copy"; - setDragOverFileRoot(true); - setDragOverVersionDocId(null); - }} - onDragLeave={(e) => { - if ( - !e.currentTarget.contains( - e.relatedTarget as Node, - ) - ) { - setDragOverFileRoot(false); - } - }} - onDrop={(e) => { - if (!hasFilePayload(e.dataTransfer)) return; - e.preventDefault(); - e.stopPropagation(); + {loading || (serverQueryActive && serverQueryLoading) ? ( + + ) : ( +
+ {/* Blue ring wraps everything below the header when root-dropping */} +
{ + if (!hasFilePayload(e.dataTransfer)) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + setDragOverFileRoot(true); + setDragOverVersionDocId(null); + }} + onDragLeave={(e) => { + if (!e.currentTarget.contains(e.relatedTarget as Node)) { setDragOverFileRoot(false); - collectionDragDepthRef.current = 0; - setIsDraggingCollectionFiles(false); - setDragOverRoot(false); - setDragOverFolderId(null); - setDragOverVersionDocId(null); - void handleDropCollectionFiles( - Array.from(e.dataTransfer.files), - ); - }} - > - {dragOverRoot && dragOverFolderId === null && ( -
- )} - {dragOverFileRoot && ( -
- )} - - {/* Empty state */} - {docs.length === 0 && - folders.length === 0 && - uploadingDroppedFilenames.length === 0 ? ( + } + }} + onDrop={(e) => { + if (!hasFilePayload(e.dataTransfer)) return; + e.preventDefault(); + e.stopPropagation(); + setDragOverFileRoot(false); + collectionDragDepthRef.current = 0; + setIsDraggingCollectionFiles(false); + setDragOverRoot(false); + setDragOverFolderId(null); + setDragOverVersionDocId(null); + void handleDropCollectionFiles(Array.from(e.dataTransfer.files)); + }} + > + {dragOverRoot && dragOverFolderId === null && ( +
+ )} + {dragOverFileRoot && ( +
+ )} + + {/* Empty state */} + {docs.length === 0 && + (serverQueryActive || folders.length === 0) && + uploadingDroppedFilenames.length === 0 ? ( + serverQueryActive ? ( +
+

No matches found

+
+ ) : (
-

- {emptyDropLabel} -

+

{emptyDropLabel}

- ) : ( -
{ - e.preventDefault(); - closeRowActionMenus(); - setContextMenu({ - x: e.clientX, - y: e.clientY, - folderId: null, - showFolderActions: false, - }); - }} - onClick={() => setContextMenu(null)} - onDragOver={(e) => { - if (!hasMovePayload(e.dataTransfer)) - return; - e.preventDefault(); - setDragOverRoot(true); - setDragOverVersionDocId(null); - }} - onDragLeave={(e) => { - if ( - !e.currentTarget.contains( - e.relatedTarget as Node, - ) - ) { - setDragOverRoot(false); - } - }} - onDrop={async (e) => { - if (!hasMovePayload(e.dataTransfer)) - return; - e.preventDefault(); + ) + ) : ( +
{ + e.preventDefault(); + closeRowActionMenus(); + setContextMenu({ + x: e.clientX, + y: e.clientY, + folderId: null, + showFolderActions: false, + }); + }} + onClick={() => setContextMenu(null)} + onDragOver={(e) => { + if (!hasMovePayload(e.dataTransfer)) return; + e.preventDefault(); + setDragOverRoot(true); + setDragOverVersionDocId(null); + }} + onDragLeave={(e) => { + if (!e.currentTarget.contains(e.relatedTarget as Node)) { setDragOverRoot(false); - setDragOverFolderId(null); - setDragOverVersionDocId(null); - await handleDropOnFolder( - null, - e.dataTransfer, - ); - }} - > - {/* Search: flat list; no search: folder tree */} - {q ? ( - <> - {renderUploadingDocumentRows(0)} - {filteredDocs.map((doc) => { - const docName = - doc.filename; - const isProcessing = - doc.status === - "pending" || - doc.status === - "processing"; - const isError = - doc.status === "error"; - const isVersionsOpen = - expandedVersionDocIds.has( - doc.id, - ); - const versionNumber = - currentVersionNumber( - doc, - ); - const hasVersions = - typeof versionNumber === - "number" && - versionNumber > 1; - const isVersionDragOver = - dragOverVersionDocId === - doc.id; - const isUploadingVersion = - uploadingVersionDocIds.has( - doc.id, - ); - const isSelected = - selectedDocIds.includes( - doc.id, - ); - const isDeletingDoc = - deletingDocIds.has( - doc.id, - ); - if (isDeletingDoc) { - return renderDocumentActivityRow( - { - key: `deleting-doc-${doc.id}`, - filename: - doc.filename, - fileType: - doc.file_type, - depth: 0, - statusLabel: - "Deleting...", - }, - ); - } - return ( -
-
{ - if ( - renamingDocumentId === - doc.id - ) { - e.preventDefault(); - return; - } - e.dataTransfer.setData( - "application/mike-doc", - doc.id, - ); - e.dataTransfer.effectAllowed = - "copyMove"; - }} - onDragEnd={() => { - setDragOverRoot( - false, - ); - setDragOverFolderId( - null, - ); - setDragOverVersionDocId( - null, - ); - }} - onDragOver={( - e, - ) => - handleDocumentVersionDragOver( - e, - doc.id, - ) - } - onDragLeave={ - handleDocumentVersionDragLeave - } - onDrop={(e) => - handleDocumentVersionDrop( - e, - doc, - ) - } - onClick={() => { - setViewingDocVersion( - null, - ); - setViewingDoc( - doc, - ); - }} - onContextMenu={( - e, - ) => { + } + }} + onDrop={async (e) => { + if (!hasMovePayload(e.dataTransfer)) return; + e.preventDefault(); + setDragOverRoot(false); + setDragOverFolderId(null); + setDragOverVersionDocId(null); + await handleDropOnFolder(null, e.dataTransfer); + }} + > + {/* Search: flat list; no search: folder tree */} + {q ? ( + <> + {renderUploadingDocumentRows(0)} + {filteredDocs.map((doc) => { + const docName = doc.filename; + const isProcessing = + doc.status === "pending" || doc.status === "processing"; + const isError = doc.status === "error"; + const isVersionsOpen = expandedVersionDocIds.has(doc.id); + const versionNumber = currentVersionNumber(doc); + const hasVersions = + typeof versionNumber === "number" && versionNumber > 1; + const isVersionDragOver = dragOverVersionDocId === doc.id; + const isUploadingVersion = uploadingVersionDocIds.has(doc.id); + const isSelected = selectedDocIds.includes(doc.id); + const isDeletingDoc = deletingDocIds.has(doc.id); + if (isDeletingDoc) { + return renderDocumentActivityRow({ + key: `deleting-doc-${doc.id}`, + filename: doc.filename, + fileType: doc.file_type, + depth: 0, + statusLabel: "Deleting...", + }); + } + return ( +
+
{ + if (renamingDocumentId === doc.id) { e.preventDefault(); - e.stopPropagation(); - closeRowActionMenus(); - setContextMenu( - { - x: e.clientX, - y: e.clientY, - docId: doc.id, - folderId: - null, - showFolderActions: false, - }, - ); - }} - className={`group flex h-10 min-w-max items-center pr-8 cursor-pointer transition-colors ${isVersionDragOver ? "bg-blue-50 ring-1 ring-inset ring-blue-200" : isSelected ? APP_SURFACE_ACTIVE_CLASS : APP_SURFACE_HOVER_CLASS}`} + return; + } + e.dataTransfer.setData("application/mike-doc", doc.id); + e.dataTransfer.effectAllowed = "copyMove"; + }} + onDragEnd={() => { + setDragOverRoot(false); + setDragOverFolderId(null); + setDragOverVersionDocId(null); + }} + onDragOver={(e) => handleDocumentVersionDragOver(e, doc.id)} + onDragLeave={handleDocumentVersionDragLeave} + onDrop={(e) => handleDocumentVersionDrop(e, doc)} + onClick={() => { + setViewingDocVersion(null); + setViewingDoc(doc); + }} + onContextMenu={(e) => { + e.preventDefault(); + e.stopPropagation(); + closeRowActionMenus(); + setContextMenu({ + x: e.clientX, + y: e.clientY, + docId: doc.id, + folderId: null, + showFolderActions: false, + }); + }} + className={`group flex h-10 min-w-max items-center pr-8 cursor-pointer transition-colors ${isVersionDragOver ? "bg-blue-50 ring-1 ring-inset ring-blue-200" : isSelected ? APP_SURFACE_ACTIVE_CLASS : APP_SURFACE_HOVER_CLASS}`} + > +
-
-
- {isProcessing || - isUploadingVersion ? ( - - ) : ( - - setSelectedDocIds( - ( - prev, - ) => - prev.includes( - doc.id, - ) - ? prev.filter( - ( - x, - ) => - x !== - doc.id, - ) - : [ - ...prev, - doc.id, - ], - ) - } - onClick={( - e, - ) => - e.stopPropagation() - } - className="mr-4 h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black" - /> - )} - - {isError ? ( - - ) : ( - - )} - - {renamingDocumentId === - doc.id ? ( - - e.stopPropagation() - } - onDragStart={( - e, - ) => { - e.preventDefault(); - e.stopPropagation(); - }} - onChange={( - e, - ) => - setRenameDocumentValue( - e - .target - .value, - ) - } - onKeyDown={( - e, - ) => { - if ( - e.key === - "Enter" - ) - void submitDocumentRename( - doc.id, - ); - if ( - e.key === - "Escape" - ) { - setRenamingDocumentId( - null, - ); - setRenameDocumentValue( - "", - ); - } - }} - onBlur={() => - void submitDocumentRename( - doc.id, - ) - } - /> - ) : ( - - { - docName - } - - )} -
-
-
- {doc.file_type ?? ( - - — - - )} -
-
- {doc.size_bytes != - null ? ( - formatBytes( - doc.size_bytes, - ) +
+ {isProcessing || isUploadingVersion ? ( + ) : ( - - — - + { + setSelectedDocIds((prev) => + prev.includes(doc.id) + ? prev.filter( + (x) => x !== doc.id, + ) + : [...prev, doc.id], + ); + }} + onClick={(e) => e.stopPropagation()} + className="mr-4 h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black" + /> )} -
-
- e.stopPropagation() - } - > - {hasVersions ? ( - - ) : ( - - — - - )} -
-
- {doc.created_at ? ( - formatDate( - doc.created_at, - ) - ) : ( - - — - - )} -
-
- {doc.updated_at ? ( - formatDate( - doc.updated_at, - ) - ) : ( - - — - - )} -
-
- {!isProcessing && ( - { - setRenameDocumentValue( - docName, - ); - setRenamingDocumentId( - doc.id, - ); }} - renameLabel="Rename document" - onDownload={() => - downloadDoc( - doc.id, - ) - } - onShowAllVersions={ - hasVersions && - !isVersionsOpen - ? () => - void toggleVersions( - doc.id, - ) - : undefined + onBlur={() => + void submitDocumentRename(doc.id) } - onUploadNewVersion={() => - void handleUploadNewVersion( - doc, - ) - } - onDelete={() => - requestRemoveDoc( - doc, - ) - } - deleteDisabled={isSharedDocument( - doc, - )} /> + ) : ( + + {docName} + )}
- {isVersionsOpen && ( - { - setViewingDocVersion( - { - id: versionId, - label, - }, - ); - setViewingDoc( - doc, - ); - }} - onRenameVersion={( - versionId, - filename, - ) => - handleRenameVersion( - doc.id, - versionId, - filename, - ) - } - onExtensionChangeBlocked={( - filename, - ) => - setDocumentRenameWarning( - extensionChangeWarning( - filename, - ), - ) - } - /> - )} +
+ {doc.file_type ?? ( + + )} +
+
+ {doc.size_bytes != null ? ( + formatBytes(doc.size_bytes) + ) : ( + + )} +
+
e.stopPropagation()} + > + {hasVersions ? ( + + ) : ( + + )} +
+
+ {doc.created_at ? ( + formatDate(doc.created_at) + ) : ( + + )} +
+
+ {doc.updated_at ? ( + formatDate(doc.updated_at) + ) : ( + + )} +
+
+ {!isProcessing && ( + { + setRenameDocumentValue(docName); + setRenamingDocumentId(doc.id); + }} + renameLabel="Rename document" + onDownload={() => downloadDoc(doc.id)} + onShowAllVersions={ + hasVersions && !isVersionsOpen + ? () => void toggleVersions(doc.id) + : undefined + } + onUploadNewVersion={() => + void handleUploadNewVersion(doc) + } + onDelete={() => requestRemoveDoc(doc)} + deleteDisabled={isSharedDocument(doc)} + /> + )} +
- ); - })} - - ) : ( - renderLevel(null, 0) - )} - {/* Spacer — fills remaining height and extends the root drop zone */} -
-
- )} - - {/* Context menu */} - {contextMenu && - (() => { - const menuDoc = contextMenu.docId - ? docs.find( - (doc) => - doc.id === - contextMenu.docId, - ) - : null; - const menuDocVersionNumber = menuDoc - ? currentVersionNumber(menuDoc) - : null; - const menuDocHasVersions = - typeof menuDocVersionNumber === - "number" && - menuDocVersionNumber > 1; - const menuDocVersionsOpen = menuDoc - ? expandedVersionDocIds.has( - menuDoc.id, - ) - : false; - const surfaceProps: RowActionMenuSurfaceProps = - { - className: "fixed z-[120]", - style: { - top: contextMenu.y, - left: contextMenu.x, - }, - onClick: (e) => - e.stopPropagation(), - }; - - return createPortal( - menuDoc ? ( - - setContextMenu(null) - } - onRename={() => { - setRenameDocumentValue( - menuDoc.filename, - ); - setRenamingDocumentId( - menuDoc.id, - ); - }} - renameLabel="Rename document" - onDownload={() => - downloadDoc(menuDoc.id) - } - onShowAllVersions={ - menuDocHasVersions && - !menuDocVersionsOpen - ? () => - void toggleVersions( - menuDoc.id, - ) - : undefined - } - onUploadNewVersion={() => - void handleUploadNewVersion( - menuDoc, - ) - } - onRemoveFromFolder={ - menuDoc.folder_id - ? () => - void handleRemoveDocFromFolder( - menuDoc.id, - ) - : undefined - } - onDelete={() => - requestRemoveDoc(menuDoc) - } - deleteDisabled={isSharedDocument( - menuDoc, - )} + {isVersionsOpen && ( + { + setViewingDocVersion({ + id: versionId, + label, + }); + setViewingDoc(doc); + }} + onRenameVersion={(versionId, filename) => + handleRenameVersion(doc.id, versionId, filename) + } + onExtensionChangeBlocked={(filename) => + setDocumentRenameWarning( + extensionChangeWarning(filename), + ) + } + /> + )} +
+ ); + })} + {serverQueryActive && onLoadMoreServerDocuments && ( + - ) : ( - - setContextMenu(null) - } - onNewSubfolder={() => { - setCreatingFolderIn( - contextMenu.folderId, + )} + + ) : ( + renderLevel(null, 0) + )} +
+ )} + + {/* Context menu */} + {contextMenu && + (() => { + const menuDoc = contextMenu.docId + ? docs.find((doc) => doc.id === contextMenu.docId) + : null; + const menuDocVersionNumber = menuDoc ? currentVersionNumber(menuDoc) : null; + const menuDocHasVersions = + typeof menuDocVersionNumber === "number" && menuDocVersionNumber > 1; + const menuDocVersionsOpen = menuDoc ? expandedVersionDocIds.has(menuDoc.id) : false; + const surfaceProps: RowActionMenuSurfaceProps = { + className: "fixed z-[120]", + style: { + top: contextMenu.y, + left: contextMenu.x, + }, + onClick: (e) => e.stopPropagation(), + }; + + return createPortal( + menuDoc ? ( + setContextMenu(null)} + onRename={() => { + setRenameDocumentValue(menuDoc.filename); + setRenamingDocumentId(menuDoc.id); + }} + renameLabel="Rename document" + onDownload={() => downloadDoc(menuDoc.id)} + onShowAllVersions={ + menuDocHasVersions && !menuDocVersionsOpen + ? () => void toggleVersions(menuDoc.id) + : undefined + } + onUploadNewVersion={() => void handleUploadNewVersion(menuDoc)} + onRemoveFromFolder={ + menuDoc.folder_id + ? () => void handleRemoveDocFromFolder(menuDoc.id) + : undefined + } + onDelete={() => requestRemoveDoc(menuDoc)} + deleteDisabled={isSharedDocument(menuDoc)} + /> + ) : ( + setContextMenu(null)} + onNewSubfolder={() => { + setCreatingFolderIn(contextMenu.folderId); + setNewFolderName(""); + if (contextMenu.folderId) { + const wasExpanded = expandedFolderIds.has(contextMenu.folderId); + if (!wasExpanded) + void expandFolderChildren(contextMenu.folderId); + setExpandedFolderIds( + (prev) => new Set([...prev, contextMenu.folderId!]), ); - setNewFolderName(""); - if ( - contextMenu.folderId - ) { - const wasExpanded = - expandedFolderIds.has( - contextMenu.folderId, - ); - if (!wasExpanded) - void expandFolderChildren( - contextMenu.folderId, - ); - setExpandedFolderIds( - (prev) => - new Set([ - ...prev, - contextMenu.folderId!, - ]), - ); - } - }} - newSubfolderLabel={ - contextMenu.showFolderActions - ? "New subfolder inside" - : "New subfolder" - } - onRename={ - contextMenu.showFolderActions && - contextMenu.folderId - ? () => { - const f = - folders.find( - (x) => - x.id === - contextMenu.folderId, - ); - setRenameFolderValue( - f?.name ?? - "", - ); - setRenamingFolderId( - contextMenu.folderId!, - ); - } - : undefined } - renameLabel="Rename folder" - onDelete={ - contextMenu.showFolderActions && - contextMenu.folderId - ? () => - requestDeleteFolder( - contextMenu.folderId!, - ) - : undefined - } - deleteLabel="Delete folder" - /> - ), - document.body, - ); - })()} -
- {/* end blue ring wrapper */} + }} + newSubfolderLabel={ + contextMenu.showFolderActions + ? "New subfolder inside" + : "New subfolder" + } + onRename={ + contextMenu.showFolderActions && contextMenu.folderId + ? () => { + const f = folders.find( + (x) => x.id === contextMenu.folderId, + ); + setRenameFolderValue(f?.name ?? ""); + setRenamingFolderId(contextMenu.folderId!); + } + : undefined + } + renameLabel="Rename folder" + onDelete={ + contextMenu.showFolderActions && contextMenu.folderId + ? () => requestDeleteFolder(contextMenu.folderId!) + : undefined + } + deleteLabel="Delete folder" + /> + ), + document.body, + ); + })()}
- )} + {/* end blue ring wrapper */} +
+ )} - {renderAddDocumentsModal?.( - addDocsOpen, - () => setAddDocsOpen(false), - handleDocsSelected, - )} + {renderAddDocumentsModal?.(addDocsOpen, () => setAddDocsOpen(false), handleDocsSelected)} { setViewingDoc(null); setViewingDocVersion(null); }} onLoadVersions={(docId) => loadDocumentVersions(docId)} - onSelectVersion={(versionId, label) => - setViewingDocVersion({ id: versionId, label }) - } + onSelectVersion={(versionId, label) => setViewingDocVersion({ id: versionId, label })} onDownloadDocument={downloadDoc} onDownloadVersion={downloadDocVersion} onRenameVersion={handleRenameVersion} @@ -3252,7 +2607,6 @@ export function DocTable({ await handleRemoveDoc(doc.id); }} /> -
); } diff --git a/frontend/src/app/components/library/LibraryWorkspace.tsx b/frontend/src/app/components/library/LibraryWorkspace.tsx index 4a7396ac1..82851b421 100644 --- a/frontend/src/app/components/library/LibraryWorkspace.tsx +++ b/frontend/src/app/components/library/LibraryWorkspace.tsx @@ -15,23 +15,32 @@ import { import { useRouter } from "next/navigation"; import { Plus, Upload } from "lucide-react"; import { DocTable } from "@/app/components/documents/DocTable"; -import type { DocTableFolder } from "@/app/components/documents/DocTable"; +import type { + DocTableFolder, + DocTableQuery, +} from "@/app/components/documents/DocTable"; import { PageHeader } from "@/app/components/shared/PageHeader"; import { TableToolbar } from "@/app/components/shared/TableToolbar"; import { TabPillButton } from "@/app/components/ui/tab-pill-button"; import { + bulkDeleteLibraryDocuments, createLibraryFolder, deleteLibraryFolder, getLibrary, + getLibraryFilterOptions, getLibraryFolderChildren, + getLibraryLevels, + listLibraryDocumentIds, moveLibraryDocument, moveLibraryFolder, renameLibraryDocument, renameLibraryFolder, + searchLibraryDocuments, uploadLibraryDocument, type LibraryKind, } from "@/app/lib/mikeApi"; import type { Document } from "@/app/components/shared/types"; +import { useDebouncedValue } from "@/app/hooks/useDebouncedValue"; type LibraryViewCollection = { documents: Document[]; @@ -115,12 +124,12 @@ export function LibraryWorkspaceProvider({ files: false, templates: false, }); - const [searchByKind, setSearchByKind] = useState< - Record - >({ + const [searchByKind, setSearchByKind] = useState>( + { files: "", templates: "", - }); + }, + ); const [loadedFolderIdsByKind, setLoadedFolderIdsByKind] = useState< Record> >({ @@ -132,12 +141,19 @@ export function LibraryWorkspaceProvider({ // has more beyond that, and whether a "load more" fetch is in flight. const [documentLimitByKind, setDocumentLimitByKind] = useState< Record> - >({ files: {}, templates: {} }); + >({ + files: {}, + templates: {}, + }); const [documentsHasMoreByKind, setDocumentsHasMoreByKind] = useState< Record> - >({ files: {}, templates: {} }); - const [loadingMoreDocumentsByKind, setLoadingMoreDocumentsByKind] = - useState>>({ + >({ + files: {}, + templates: {}, + }); + const [loadingMoreDocumentsByKind, setLoadingMoreDocumentsByKind] = useState< + Record> + >({ files: {}, templates: {}, }); @@ -161,18 +177,18 @@ export function LibraryWorkspaceProvider({ try { const loadedFolderIds = [...loadedFolderIdsByKind[kind]]; const limits = documentLimitByKind[kind]; - const [root, childResults] = await Promise.all([ - getLibrary(kind, { + const response = await getLibraryLevels(kind, [ + { + parentId: null, limit: limits[ROOT_LEVEL_KEY] ?? DOCUMENT_PAGE_SIZE, - }), - Promise.allSettled( - loadedFolderIds.map((folderId) => - getLibraryFolderChildren(kind, folderId, { + }, + ...loadedFolderIds.map((folderId) => ({ + parentId: folderId, limit: limits[folderId] ?? DOCUMENT_PAGE_SIZE, - }), - ), - ), + })), ]); + const root = response.levels.find((level) => level.parentId === null); + if (!root) throw new Error("Library root was not returned"); const documents = [...root.documents]; const folders = [...root.folders]; @@ -183,17 +199,17 @@ export function LibraryWorkspaceProvider({ [ROOT_LEVEL_KEY]: root.documentsHasMore, }; - childResults.forEach((settled, index) => { - if (settled.status !== "fulfilled") return; - const folderId = loadedFolderIds[index]; + response.levels.forEach((level) => { + const folderId = level.parentId; + if (!folderId) return; stillLoaded.add(folderId); - nextHasMore[folderId] = settled.value.documentsHasMore; - for (const doc of settled.value.documents) { + nextHasMore[folderId] = level.documentsHasMore; + for (const doc of level.documents) { if (seenDocIds.has(doc.id)) continue; seenDocIds.add(doc.id); documents.push(doc); } - for (const folder of settled.value.folders) { + for (const folder of level.folders) { if (seenFolderIds.has(folder.id)) continue; seenFolderIds.add(folder.id); folders.push(folder); @@ -212,6 +228,20 @@ export function LibraryWorkspaceProvider({ ...prev, [kind]: nextHasMore, })); + setDocumentLimitByKind((prev) => ({ + ...prev, + [kind]: { + [ROOT_LEVEL_KEY]: root.documents.length, + ...Object.fromEntries( + loadedFolderIds.map((folderId) => [ + folderId, + documents.filter( + (document) => (document.folder_id ?? null) === folderId, + ).length, + ]), + ), + }, + })); } catch (error) { console.error("[library] failed to load", error); setCollections((prev) => ({ @@ -242,27 +272,19 @@ export function LibraryWorkspaceProvider({ const request = (async () => { try { - const children = await getLibraryFolderChildren( - kind, - folderId, - { limit: DOCUMENT_PAGE_SIZE }, - ); + const children = await getLibraryFolderChildren(kind, folderId, { + limit: DOCUMENT_PAGE_SIZE, + }); setCollections((prev) => { const current = prev[kind] ?? EMPTY_COLLECTION; - const existingDocIds = new Set( - current.documents.map((d) => d.id), - ); - const existingFolderIds = new Set( - current.folders.map((f) => f.id), - ); + const existingDocIds = new Set(current.documents.map((d) => d.id)); + const existingFolderIds = new Set(current.folders.map((f) => f.id)); return { ...prev, [kind]: { documents: [ ...current.documents, - ...children.documents.filter( - (d) => !existingDocIds.has(d.id), - ), + ...children.documents.filter((d) => !existingDocIds.has(d.id)), ], folders: [ ...current.folders, @@ -280,7 +302,10 @@ export function LibraryWorkspaceProvider({ }); setDocumentLimitByKind((prev) => ({ ...prev, - [kind]: { ...prev[kind], [folderId]: DOCUMENT_PAGE_SIZE }, + [kind]: { + ...prev[kind], + [folderId]: DOCUMENT_PAGE_SIZE, + }, })); setDocumentsHasMoreByKind((prev) => ({ ...prev, @@ -290,10 +315,7 @@ export function LibraryWorkspaceProvider({ }, })); } catch (error) { - console.error( - "[library] failed to load folder children", - error, - ); + console.error("[library] failed to load folder children", error); } finally { folderChildrenRequestsRef.current.delete(key); } @@ -304,9 +326,9 @@ export function LibraryWorkspaceProvider({ [loadedFolderIdsByKind], ); - // Fetches the next page of documents for a single level (root or one - // folder), replacing just that level's documents/folders in place — - // everything belonging to other levels is left untouched. + // Fetches a fixed-size next page for one level. The old implementation + // repeatedly fetched 100, then 150, then 200 rows from offset zero, which + // made scrolling transfer the same rows over and over. const loadMoreDocuments = useCallback( async (kind: LibraryKind, parentId: string | null) => { const levelKey = libraryLevelKey(parentId); @@ -314,9 +336,7 @@ export function LibraryWorkspaceProvider({ const inFlight = loadMoreDocumentsRequestsRef.current.get(requestKey); if (inFlight) return inFlight; - const nextLimit = - (documentLimitByKind[kind][levelKey] ?? DOCUMENT_PAGE_SIZE) + - DOCUMENT_PAGE_SIZE; + const offset = documentLimitByKind[kind][levelKey] ?? 0; setLoadingMoreDocumentsByKind((prev) => ({ ...prev, [kind]: { ...prev[kind], [levelKey]: true }, @@ -326,31 +346,43 @@ export function LibraryWorkspaceProvider({ try { const page = parentId === null - ? await getLibrary(kind, { limit: nextLimit }) + ? await getLibrary(kind, { + limit: DOCUMENT_PAGE_SIZE, + offset, + }) : await getLibraryFolderChildren(kind, parentId, { - limit: nextLimit, + limit: DOCUMENT_PAGE_SIZE, + offset, }); setCollections((prev) => { const current = prev[kind] ?? EMPTY_COLLECTION; + const existingDocumentIds = new Set( + current.documents.map((document) => document.id), + ); + const existingFolderIds = new Set( + current.folders.map((folder) => folder.id), + ); const documents = [ - ...current.documents.filter( - (d) => (d.folder_id ?? null) !== parentId, + ...current.documents, + ...page.documents.filter( + (document) => !existingDocumentIds.has(document.id), ), - ...page.documents, ]; const folders = [ - ...current.folders.filter( - (f) => - (f.parent_folder_id ?? null) !== parentId, + ...current.folders, + ...page.folders.filter( + (folder) => !existingFolderIds.has(folder.id), ), - ...page.folders, ]; return { ...prev, [kind]: { documents, folders } }; }); setDocumentLimitByKind((prev) => ({ ...prev, - [kind]: { ...prev[kind], [levelKey]: nextLimit }, + [kind]: { + ...prev[kind], + [levelKey]: offset + page.documents.length, + }, })); setDocumentsHasMoreByKind((prev) => ({ ...prev, @@ -360,10 +392,7 @@ export function LibraryWorkspaceProvider({ }, })); } catch (error) { - console.error( - "[library] failed to load more documents", - error, - ); + console.error("[library] failed to load more documents", error); } finally { setLoadingMoreDocumentsByKind((prev) => ({ ...prev, @@ -387,9 +416,7 @@ export function LibraryWorkspaceProvider({ setCollections((prev) => { const current = prev[kind] ?? EMPTY_COLLECTION; const nextDocuments = - typeof update === "function" - ? update(current.documents) - : update; + typeof update === "function" ? update(current.documents) : update; return { ...prev, [kind]: { @@ -407,9 +434,7 @@ export function LibraryWorkspaceProvider({ setCollections((prev) => { const current = prev[kind] ?? EMPTY_COLLECTION; const nextFolders = - typeof update === "function" - ? update(current.folders) - : update; + typeof update === "function" ? update(current.folders) : update; return { ...prev, [kind]: { @@ -481,7 +506,22 @@ export function LibraryCollectionPage({ kind }: { kind: LibraryKind }) { } = useLibraryWorkspace(); const collection = collections[kind]; const search = searchByKind[kind]; + const debouncedSearch = useDebouncedValue(search, 250); const title = kind === "files" ? "Files" : "Templates"; + const [documentTypeOptions, setDocumentTypeOptions] = useState([]); + const [tableQuery, setTableQuery] = useState({ + search: "", + fileType: null, + sort: null, + }); + const [serverDocuments, setServerDocuments] = useState( + null, + ); + const [serverQueryLoading, setServerQueryLoading] = useState(false); + const [serverQueryLoadingMore, setServerQueryLoadingMore] = useState(false); + const [serverQueryHasMore, setServerQueryHasMore] = useState(false); + const [serverQueryRefreshVersion, setServerQueryRefreshVersion] = useState(0); + const serverQueryRequestRef = useRef(0); useEffect(() => { if (collection) return; @@ -489,7 +529,13 @@ export function LibraryCollectionPage({ kind }: { kind: LibraryKind }) { }, [collection, kind, loadLibrary]); const setDocuments: Dispatch> = useCallback( - (update) => setDocumentsForKind(kind, update), + (update) => { + setDocumentsForKind(kind, update); + setServerDocuments((current) => { + if (current === null) return null; + return typeof update === "function" ? update(current) : update; + }); + }, [kind, setDocumentsForKind], ); const setFolders: Dispatch> = useCallback( @@ -529,22 +575,165 @@ export function LibraryCollectionPage({ kind }: { kind: LibraryKind }) { [kind, loadMoreDocuments], ); + const handleServerQueryChange = useCallback((query: DocTableQuery) => { + setTableQuery(query); + }, []); + + const handleSelectAllMatching = useCallback( + (query: DocTableQuery) => + listLibraryDocumentIds(kind, { + search: query.search.trim() || undefined, + fileType: query.fileType ?? undefined, + }), + [kind], + ); + + useEffect(() => { + let cancelled = false; + void getLibraryFilterOptions(kind) + .then((options) => { + if (!cancelled) setDocumentTypeOptions(options.fileTypes); + }) + .catch(() => { + if (!cancelled) setDocumentTypeOptions([]); + }); + return () => { + cancelled = true; + }; + }, [kind]); + + const serverQueryActive = + debouncedSearch.trim().length > 0 || + !!tableQuery.fileType || + !!tableQuery.sort; + + useEffect(() => { + const requestVersion = ++serverQueryRequestRef.current; + if (!serverQueryActive) { + setServerDocuments(null); + setServerQueryLoading(false); + setServerQueryLoadingMore(false); + setServerQueryHasMore(false); + return; + } + + const controller = new AbortController(); + setServerDocuments([]); + setServerQueryLoading(true); + setServerQueryLoadingMore(false); + void searchLibraryDocuments(kind, { + limit: DOCUMENT_PAGE_SIZE, + search: debouncedSearch.trim() || undefined, + fileType: tableQuery.fileType ?? undefined, + sortKey: tableQuery.sort?.key, + sortDirection: tableQuery.sort?.direction, + signal: controller.signal, + }) + .then((result) => { + if (requestVersion !== serverQueryRequestRef.current) return; + setServerDocuments(result.documents); + setServerQueryHasMore(result.documentsHasMore); + }) + .catch((error) => { + if ( + !controller.signal.aborted && + requestVersion === serverQueryRequestRef.current + ) { + console.error("[library] failed to search", error); + setServerDocuments([]); + setServerQueryHasMore(false); + } + }) + .finally(() => { + if ( + !controller.signal.aborted && + requestVersion === serverQueryRequestRef.current + ) { + setServerQueryLoading(false); + } + }); + return () => controller.abort(); + }, [ + debouncedSearch, + kind, + serverQueryActive, + serverQueryRefreshVersion, + tableQuery.fileType, + tableQuery.sort, + ]); + + const handleLoadMoreServerDocuments = useCallback(async () => { + if ( + !serverQueryActive || + !serverQueryHasMore || + serverQueryLoading || + serverQueryLoadingMore + ) { + return; + } + const requestVersion = serverQueryRequestRef.current; + const offset = serverDocuments?.length ?? 0; + setServerQueryLoadingMore(true); + try { + const result = await searchLibraryDocuments(kind, { + limit: DOCUMENT_PAGE_SIZE, + offset, + search: debouncedSearch.trim() || undefined, + fileType: tableQuery.fileType ?? undefined, + sortKey: tableQuery.sort?.key, + sortDirection: tableQuery.sort?.direction, + }); + if (requestVersion !== serverQueryRequestRef.current) return; + setServerDocuments((current) => { + if (current === null) return result.documents; + const existing = new Set(current.map((document) => document.id)); + return [ + ...current, + ...result.documents.filter((document) => !existing.has(document.id)), + ]; + }); + setServerQueryHasMore(result.documentsHasMore); + } catch (error) { + if (requestVersion === serverQueryRequestRef.current) { + console.error("[library] failed to load more search results", error); + } + } finally { + if (requestVersion === serverQueryRequestRef.current) { + setServerQueryLoadingMore(false); + } + } + }, [ + debouncedSearch, + kind, + serverDocuments?.length, + serverQueryActive, + serverQueryHasMore, + serverQueryLoading, + serverQueryLoadingMore, + tableQuery.fileType, + tableQuery.sort, + ]); + const operations = useMemo( () => ({ uploadDocument: (file: File) => uploadLibraryDocument(kind, file), - refreshCollection: () => loadLibrary(kind), + refreshCollection: async () => { + await loadLibrary(kind); + setServerQueryRefreshVersion((current) => current + 1); + }, createFolder: (name: string, parentFolderId?: string | null) => createLibraryFolder(kind, name, parentFolderId), renameFolder: (folderId: string, name: string) => renameLibraryFolder(kind, folderId, name), - deleteFolder: (folderId: string) => - deleteLibraryFolder(kind, folderId), + deleteFolder: (folderId: string) => deleteLibraryFolder(kind, folderId), moveFolder: (folderId: string, parentFolderId: string | null) => moveLibraryFolder(kind, folderId, parentFolderId), moveDocument: (documentId: string, folderId: string | null) => moveLibraryDocument(kind, documentId, folderId), renameDocument: (documentId: string, filename: string) => renameLibraryDocument(kind, documentId, filename), + bulkDeleteDocuments: (documentIds: string[]) => + bulkDeleteLibraryDocuments(kind, documentIds), }), [kind, loadLibrary], ); @@ -559,8 +748,7 @@ export function LibraryCollectionPage({ kind }: { kind: LibraryKind }) { { type: "search", value: search, - onChange: (value) => - setSearchForKind(kind, value), + onChange: (value) => setSearchForKind(kind, value), placeholder: `Search ${title.toLowerCase()}...`, }, ], @@ -570,9 +758,7 @@ export function LibraryCollectionPage({ kind }: { kind: LibraryKind }) { { icon: , label: ( - - {addCollectionLabel} - + {addCollectionLabel} ), title: `Add ${addCollectionLabel}`, onClick: addDocumentsAction ?? undefined, @@ -588,9 +774,7 @@ export function LibraryCollectionPage({ kind }: { kind: LibraryKind }) { items={LIBRARY_TABS} active={kind} onChange={(next) => - router.push( - next === "files" ? "/library" : "/library/templates", - ) + router.push(next === "files" ? "/library" : "/library/templates") } actions={ ({ + value: fileType, + label: fileType.toUpperCase(), + }))} + autoLoadOnScroll enableHeaderFilters emptyDropLabel={ kind === "templates" diff --git a/frontend/src/app/components/projects/ProjectsOverview.tsx b/frontend/src/app/components/projects/ProjectsOverview.tsx index 547a4251e..23bc386c3 100644 --- a/frontend/src/app/components/projects/ProjectsOverview.tsx +++ b/frontend/src/app/components/projects/ProjectsOverview.tsx @@ -4,7 +4,8 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { ChevronDown, Plus } from "lucide-react"; import { - listProjects, + getProjectFilterOptions, + type ProjectFilterOptions, updateProject, deleteProject, } from "@/app/lib/mikeApi"; @@ -12,6 +13,7 @@ import { deleteTabularReviewsWithConcurrency } from "@/app/lib/deleteTabularRevi import { useDebouncedValue } from "@/app/hooks/useDebouncedValue"; import { usePaginatedProjects } from "@/app/hooks/usePaginatedProjects"; import { OwnerOnlyPopup } from "@/app/components/popups/OwnerOnlyPopup"; +import { ConfirmPopup } from "@/app/components/popups/ConfirmPopup"; import { useAuth } from "@/app/contexts/AuthContext"; import type { Project } from "@/app/components/shared/types"; import { NewProjectModal } from "./NewProjectModal"; @@ -91,13 +93,13 @@ export function ProjectsOverview() { const [actionsOpen, setActionsOpen] = useState(false); const [search, setSearch] = useState(""); const [ownerOnlyAction, setOwnerOnlyAction] = useState(null); - // A separate, always-unpaginated fetch used only to enumerate the - // practice/owner filter dropdown options — the paginated rows below - // won't necessarily include every distinct practice/owner once there - // are more projects than fit on the first page. - const [filterOptionsProjects, setFilterOptionsProjects] = useState< - Project[] - >([]); + const [selectionCameFromSelectAll, setSelectionCameFromSelectAll] = + useState(false); + const [confirmDeleteAllOpen, setConfirmDeleteAllOpen] = useState(false); + const [filterOptions, setFilterOptions] = useState({ + practices: [], + owners: [], + }); const actionsRef = useRef(null); const router = useRouter(); const searchParams = useSearchParams(); @@ -136,17 +138,17 @@ export function ProjectsOverview() { useEffect(() => { if (authLoading || !isAuthenticated) return; - let cancelled = false; - listProjects() + const controller = new AbortController(); + getProjectFilterOptions(controller.signal) .then((data) => { - if (!cancelled) setFilterOptionsProjects(data); + if (!controller.signal.aborted) setFilterOptions(data); }) .catch(() => { // Filter option lists degrade to "no options" — not worth a // user-facing error for a purely cosmetic dropdown. }); return () => { - cancelled = true; + controller.abort(); }; }, [authLoading, isAuthenticated]); @@ -162,31 +164,8 @@ export function ProjectsOverview() { return () => document.removeEventListener("mousedown", handleClick); }, [actionsOpen]); - const practices = useMemo( - () => - Array.from( - new Set( - filterOptionsProjects - .map((project) => project.practice?.trim()) - .filter((practice): practice is string => !!practice), - ), - ).sort((a, b) => a.localeCompare(b)), - [filterOptionsProjects], - ); - const ownerOptions = useMemo(() => { - const labelByUserId = new Map(); - for (const project of filterOptionsProjects) { - if (!labelByUserId.has(project.user_id)) { - labelByUserId.set( - project.user_id, - getProjectOwnerLabel(project, user?.id), - ); - } - } - return Array.from(labelByUserId.entries()) - .map(([value, label]) => ({ value, label })) - .sort((a, b) => a.label.localeCompare(b.label)); - }, [filterOptionsProjects, user?.id]); + const practices = filterOptions.practices; + const ownerOptions = filterOptions.owners; const allSelected = visibleProjects.length > 0 && @@ -195,8 +174,13 @@ export function ProjectsOverview() { !allSelected && visibleProjects.some((p) => selectedIds.includes(p.id)); function toggleAll() { - if (allSelected) setSelectedIds([]); - else void selectAllMatching(); + if (allSelected) { + setSelectedIds([]); + setSelectionCameFromSelectAll(false); + } else { + setSelectionCameFromSelectAll(true); + void selectAllMatching(); + } } function toggleOne(id: string) { @@ -207,6 +191,8 @@ export function ProjectsOverview() { function clearSelection() { setSelectedIds([]); + setSelectionCameFromSelectAll(false); + setConfirmDeleteAllOpen(false); setActionsOpen(false); } @@ -357,9 +343,20 @@ export function ProjectsOverview() { ); } + function requestDeleteSelected() { + setActionsOpen(false); + if (selectionCameFromSelectAll) { + setConfirmDeleteAllOpen(true); + return; + } + void handleDeleteSelected(); + } + async function handleDeleteSelected() { const ids = [...selectedIds]; setActionsOpen(false); + setConfirmDeleteAllOpen(false); + setSelectionCameFromSelectAll(false); // Only the project owner can delete; the per-row delete is hidden // for shared projects but the bulk action can still pick them up // if a user toggled them across filters (or select-all-matching @@ -396,7 +393,7 @@ export function ProjectsOverview() { {actionsOpen && (
); } diff --git a/frontend/src/app/components/shared/AppSidebar.tsx b/frontend/src/app/components/shared/AppSidebar.tsx index ec46d29a2..fb75b650e 100644 --- a/frontend/src/app/components/shared/AppSidebar.tsx +++ b/frontend/src/app/components/shared/AppSidebar.tsx @@ -1,11 +1,19 @@ "use client"; -import { useState, useEffect, useMemo } from "react"; +import { + useState, + useEffect, + useMemo, + useCallback, + useRef, + type UIEvent, +} from "react"; import { PanelLeft, User, ChevronsUpDown, ChevronDown, + Loader2, } from "lucide-react"; import { useAuth } from "@/app/contexts/AuthContext"; import { useUserProfile } from "@/app/contexts/UserProfileContext"; @@ -21,10 +29,8 @@ import { TabularReviewSkeuoIcon, WorkflowSkeuoIcon, } from "@/app/components/shared/AppSidebarSkeuoIcons"; -import { - ProjectSvgIcon, -} from "@/app/components/shared/FolderSvgIcon"; -import { listProjects } from "@/app/lib/mikeApi"; +import { ProjectSvgIcon } from "@/app/components/shared/FolderSvgIcon"; +import { listProjectSummaries } from "@/app/lib/mikeApi"; import type { Project } from "@/app/components/shared/types"; import { cn } from "@/app/lib/utils"; import { @@ -36,10 +42,21 @@ const NAV_ITEMS = [ { href: "/assistant", label: "Assistant", icon: ChatSkeuoIcon }, { href: "/projects", label: "Projects", icon: FolderSkeuoIcon }, { href: "/library", label: "Library", icon: LibrarySkeuoIcon }, - { href: "/tabular-reviews", label: "Tabular Review", icon: TabularReviewSkeuoIcon }, + { + href: "/tabular-reviews", + label: "Tabular Review", + icon: TabularReviewSkeuoIcon, + }, { href: "/workflows", label: "Workflows", icon: WorkflowSkeuoIcon }, ]; +const RECENT_PROJECT_PAGE_SIZE = 10; +const RECENT_PROJECT_LIST_HEIGHT_CLASS = "h-44"; + +function isNearScrollEnd(element: HTMLDivElement) { + return element.scrollHeight - element.scrollTop - element.clientHeight <= 32; +} + interface AppSidebarProps { isOpen: boolean; onToggle: () => void; @@ -48,7 +65,7 @@ interface AppSidebarProps { export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) { const { user } = useAuth(); const { profile } = useUserProfile(); - const { chats, hasMoreChats, loadMoreChats, setCurrentChatId } = + const { chats, loadingMoreChats, loadMoreChats, setCurrentChatId } = useChatHistoryContext(); const router = useRouter(); const pathname = usePathname(); @@ -66,36 +83,97 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) { const [isDropdownOpen, setIsDropdownOpen] = useState(false); const [projectsCollapsed, setProjectsCollapsed] = useState(false); const [historyCollapsed, setHistoryCollapsed] = useState(false); - const [projectNames, setProjectNames] = useState>( - {}, - ); - const [recentProjects, setRecentProjects] = useState( - null, - ); + const [recentProjects, setRecentProjects] = useState(null); + const [hasMoreRecentProjects, setHasMoreRecentProjects] = useState(false); + const [loadingMoreRecentProjects, setLoadingMoreRecentProjects] = + useState(false); + const loadingMoreRecentProjectsRef = useRef(false); useEffect(() => { - if (!user) return; - listProjects() + if (!user) { + setRecentProjects([]); + setHasMoreRecentProjects(false); + setLoadingMoreRecentProjects(false); + loadingMoreRecentProjectsRef.current = false; + return; + } + + const controller = new AbortController(); + setRecentProjects(null); + setHasMoreRecentProjects(false); + setLoadingMoreRecentProjects(false); + loadingMoreRecentProjectsRef.current = false; + + listProjectSummaries({ + limit: RECENT_PROJECT_PAGE_SIZE + 1, + signal: controller.signal, + }) .then((projects) => { - const map: Record = {}; - for (const p of projects) map[p.id] = p.name; - setProjectNames(map); - setRecentProjects( - [...projects] - .sort( - (a, b) => - Date.parse(b.updated_at || b.created_at) - - Date.parse(a.updated_at || a.created_at), - ) - .slice(0, 5), - ); + if (controller.signal.aborted) return; + setRecentProjects(projects.slice(0, RECENT_PROJECT_PAGE_SIZE)); + setHasMoreRecentProjects(projects.length > RECENT_PROJECT_PAGE_SIZE); }) .catch(() => { - setProjectNames({}); + if (controller.signal.aborted) return; setRecentProjects([]); + setHasMoreRecentProjects(false); }); + + return () => controller.abort(); }, [user]); + const loadMoreRecentProjects = useCallback(async () => { + if ( + !user || + recentProjects === null || + !hasMoreRecentProjects || + loadingMoreRecentProjectsRef.current + ) { + return; + } + + loadingMoreRecentProjectsRef.current = true; + setLoadingMoreRecentProjects(true); + try { + const projects = await listProjectSummaries({ + limit: RECENT_PROJECT_PAGE_SIZE + 1, + offset: recentProjects.length, + }); + const page = projects.slice(0, RECENT_PROJECT_PAGE_SIZE); + setRecentProjects((current) => { + const existing = new Set((current ?? []).map((project) => project.id)); + return [ + ...(current ?? []), + ...page.filter((project) => !existing.has(project.id)), + ]; + }); + setHasMoreRecentProjects(projects.length > RECENT_PROJECT_PAGE_SIZE); + } catch { + // Keep the current page and allow the next scroll to retry. + } finally { + loadingMoreRecentProjectsRef.current = false; + setLoadingMoreRecentProjects(false); + } + }, [hasMoreRecentProjects, recentProjects, user]); + + const handleRecentProjectsScroll = useCallback( + (event: UIEvent) => { + if (isNearScrollEnd(event.currentTarget)) { + void loadMoreRecentProjects(); + } + }, + [loadMoreRecentProjects], + ); + + const handleChatHistoryScroll = useCallback( + (event: UIEvent) => { + if (isNearScrollEnd(event.currentTarget)) { + void loadMoreChats(); + } + }, + [loadMoreChats], + ); + const handleToggle = () => { if (isOpen) setShouldAnimate(true); onToggle(); @@ -105,8 +183,7 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) { const handleClickOutside = () => setIsDropdownOpen(false); if (isDropdownOpen) { document.addEventListener("click", handleClickOutside); - return () => - document.removeEventListener("click", handleClickOutside); + return () => document.removeEventListener("click", handleClickOutside); } }, [isDropdownOpen]); @@ -196,8 +273,7 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) { ? pathname === href : href === "/projects" ? pathname === href - : pathname === href || - pathname.startsWith(href + "/"); + : pathname === href || pathname.startsWith(href + "/"); return (
{!projectsCollapsed && ( - <> +
{!recentProjects ? (
{[50, 65, 45].map((w, i) => ( @@ -272,35 +350,26 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) { ) : recentProjects.length === 0 ? (
No projects yet
) : (
{recentProjects.map((project) => { const isActive = - pathname === - `/projects/${project.id}` || - pathname.startsWith( - `/projects/${project.id}/`, - ); + pathname === `/projects/${project.id}` || + pathname.startsWith(`/projects/${project.id}/`); return ( ); })} + {loadingMoreRecentProjects && ( +
+
)} - +
+ )} +
)}
{/* Assistant History */} -
+
{!chats ? (
@@ -364,9 +445,7 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) { ) : chats.length === 0 ? (
No chats yet @@ -375,30 +454,17 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) { <>
{chats.map((chat) => ( { - setCurrentChatId( - chat.id, - ); + setCurrentChatId(chat.id); router.push( chat.project_id ? `/projects/${chat.project_id}/assistant/chat/${chat.id}` @@ -408,17 +474,9 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) { /> ))}
- {hasMoreChats && ( -
- + {loadingMoreChats && ( +
+
)} @@ -433,9 +491,7 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) { {user && (
{isExpanded && (
- {docs.length === 0 && - projectFolders.length === 0 ? ( + {loadingProjectLevels.has(`${project.id}:root`) ? ( +

+ + Loading project files +

+ ) : docs.length === 0 && projectFolders.length === 0 ? (

Empty

@@ -699,18 +1028,32 @@ export function FileDirectory({ docs, null, 1, + undefined, + project.id, )} - {(q - ? docs - : folderDocuments( - docs, + {(q ? docs : folderDocuments(docs, null)).map( + (doc) => renderDocumentRow(doc, 1), + )} + {!q && ( + + void loadMoreProjectDocuments( + project.id, null, ) - ).map((doc) => - renderDocumentRow( - doc, - 1, - ), + } + /> )} )} @@ -726,6 +1069,26 @@ export function FileDirectory({ No projects yet

)} + {activeTab === "projects" && !q && ( + void loadMoreProjects()} + /> + )} + {activeTab === "projects" && !!q && ( + void loadMoreSearchResults()} + /> + )}
)} @@ -759,9 +1122,7 @@ function FileDirectoryMetaCells({ <> {version ?? "--"} {created ?? "--"} - - {size ?? "--"} - + {size ?? "--"} ); } diff --git a/frontend/src/app/components/shared/types.ts b/frontend/src/app/components/shared/types.ts index 5f44a2648..699baeeb6 100644 --- a/frontend/src/app/components/shared/types.ts +++ b/frontend/src/app/components/shared/types.ts @@ -77,6 +77,7 @@ export interface Chat { project_id: string | null; user_id: string; creator_display_name?: string | null; + project_name?: string | null; title: string | null; created_at: string; } @@ -382,9 +383,7 @@ export type CaseCitation = { * anchors. Case citations anchor to a CourtListener cluster and include a * quoted opinion passage. */ -export type Citation = - | DocumentCitation - | CaseCitation; +export type Citation = DocumentCitation | CaseCitation; const PAGE_BREAK_SENTINEL = "[[PAGE_BREAK]]"; @@ -430,7 +429,9 @@ export function getCitationCells( .map((q) => ({ sheet: q.sheet, cell: q.cell })); } -function expandDocumentQuoteEntry(entry: DocumentCitationQuote): CitationQuote[] { +function expandDocumentQuoteEntry( + entry: DocumentCitationQuote, +): CitationQuote[] { const rangeMatch = typeof entry.page === "string" ? entry.page.match(/^(\d+)\s*-\s*(\d+)$/) @@ -467,9 +468,7 @@ export function getDocumentCitationQuotes( * highlighting in the PDF viewer. A single-page citation yields one entry; a * cross-page citation with page "N-M" and a `[[PAGE_BREAK]]` split yields two. */ -export function expandCitationToEntries( - a: Citation, -): CitationQuote[] { +export function expandCitationToEntries(a: Citation): CitationQuote[] { if (a.kind === "case") return []; return getDocumentCitationQuotes(a).flatMap(expandDocumentQuoteEntry); } @@ -517,10 +516,7 @@ export function formatCitationQuotePage( * Reader-friendly version of a single raw quote: replaces [[PAGE_BREAK]] with * "...". Spreadsheet quotes now carry plain cell values, so no stripping. */ -export function cleanCitationQuoteText( - _a: Citation, - rawQuote: string, -): string { +export function cleanCitationQuoteText(_a: Citation, rawQuote: string): string { return rawQuote.replaceAll(PAGE_BREAK_SENTINEL, "..."); } diff --git a/frontend/src/app/components/shared/useDirectoryData.ts b/frontend/src/app/components/shared/useDirectoryData.ts index 7d4a50010..662c99d55 100644 --- a/frontend/src/app/components/shared/useDirectoryData.ts +++ b/frontend/src/app/components/shared/useDirectoryData.ts @@ -1,11 +1,22 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; -import { getLibrary, listProjects } from "@/app/lib/mikeApi"; +import { + getProjectDirectoryLevel, + getLibrary, + getLibraryFolderChildren, + listProjectSummaries, + type LibraryKind, +} from "@/app/lib/mikeApi"; import type { Document, LibraryFolder, Project } from "./types"; export type DirectoryTab = "files" | "templates" | "projects"; +type LibraryDirectoryTab = Exclude; + +const DIRECTORY_PAGE_SIZE = 50; +const ROOT_LEVEL_KEY = "root"; + const EMPTY_LOADING: Record = { files: false, templates: false, @@ -24,51 +35,111 @@ function sortDocuments(docs: Document[]) { ); } -async function loadFiles() { - const files = await getLibrary("files"); - return { - documents: sortDocuments(files.documents), - folders: files.folders, - }; +function libraryKind(tab: LibraryDirectoryTab): LibraryKind { + return tab === "files" ? "files" : "templates"; } -async function loadTemplates() { - const templates = await getLibrary("templates"); - return { - documents: sortDocuments(templates.documents), - folders: templates.folders, - }; +function documentFolderId(document: Document): string | null { + return document.folder_id ?? document.library_folder_id ?? null; } -async function loadProjects() { - // One batched request. Fanning out getProject(id) per project caused an - // N+1 burst on every directory-modal open that could overwhelm the - // Supabase gateway once an account had accumulated projects. - const projects = await listProjects({ includeDocuments: true }); - return projects.map((project) => ({ - ...project, - document_count: - project.documents?.length ?? project.document_count ?? 0, - })); +function mergeById(current: T[], incoming: T[]): T[] { + const next = new Map(current.map((item) => [item.id, item])); + incoming.forEach((item) => next.set(item.id, item)); + return [...next.values()]; } export function useDirectoryData( enabled: boolean, initialTab: DirectoryTab = "files", ) { - const [standaloneDocuments, setStandaloneDocuments] = useState([]); + const [standaloneDocuments, setStandaloneDocuments] = useState( + [], + ); const [templateDocuments, setTemplateDocuments] = useState([]); const [fileFolders, setFileFolders] = useState([]); const [templateFolders, setTemplateFolders] = useState([]); const [projects, setProjects] = useState([]); + const [projectsHasMore, setProjectsHasMore] = useState(false); + const [loadingMoreProjects, setLoadingMoreProjects] = useState(false); + const [loadedProjectLevels, setLoadedProjectLevels] = useState>( + new Set(), + ); + const [loadingProjectLevels, setLoadingProjectLevels] = useState>( + new Set(), + ); + const [projectDocumentsHasMoreByLevel, setProjectDocumentsHasMoreByLevel] = + useState>({}); const [loadingTabs, setLoadingTabs] = useState>(EMPTY_LOADING); + const [loadedFolderIds, setLoadedFolderIds] = useState< + Record> + >({ + files: new Set(), + templates: new Set(), + }); + const [loadingFolderIds, setLoadingFolderIds] = useState< + Record> + >({ + files: new Set(), + templates: new Set(), + }); + const [documentsHasMoreByLevel, setDocumentsHasMoreByLevel] = useState< + Record> + >({ files: {}, templates: {} }); + const [loadingMoreDocumentsByLevel, setLoadingMoreDocumentsByLevel] = + useState>>({ + files: {}, + templates: {}, + }); const loadingTabsRef = useRef>({ ...EMPTY_LOADING, }); const loadedTabsRef = useRef>({ ...EMPTY_LOADED, }); + const loadedFolderIdsRef = useRef>>({ + files: new Set(), + templates: new Set(), + }); + const folderRequestsRef = useRef>>(new Map()); + const moreRequestsRef = useRef>>(new Map()); + const standaloneDocumentsRef = useRef([]); + const templateDocumentsRef = useRef([]); + const projectsRef = useRef([]); + const projectLevelRequestsRef = useRef>>(new Map()); + const loadingMoreProjectsRef = useRef(false); + + useEffect(() => { + standaloneDocumentsRef.current = standaloneDocuments; + }, [standaloneDocuments]); + + useEffect(() => { + templateDocumentsRef.current = templateDocuments; + }, [templateDocuments]); + + useEffect(() => { + projectsRef.current = projects; + }, [projects]); + + const setLibraryDocuments = useCallback( + (tab: LibraryDirectoryTab, update: (current: Document[]) => Document[]) => { + if (tab === "files") setStandaloneDocuments(update); + else setTemplateDocuments(update); + }, + [], + ); + + const setLibraryFolders = useCallback( + ( + tab: LibraryDirectoryTab, + update: (current: LibraryFolder[]) => LibraryFolder[], + ) => { + if (tab === "files") setFileFolders(update); + else setTemplateFolders(update); + }, + [], + ); const loadTab = useCallback( async (tab: DirectoryTab) => { @@ -86,16 +157,31 @@ export function useDirectoryData( }; setLoadingTabs((prev) => ({ ...prev, [tab]: true })); try { - if (tab === "files") { - const files = await loadFiles(); - setStandaloneDocuments(files.documents); - setFileFolders(files.folders); - } else if (tab === "templates") { - const templates = await loadTemplates(); - setTemplateDocuments(templates.documents); - setTemplateFolders(templates.folders); + if (tab === "projects") { + const rows = await listProjectSummaries({ + limit: DIRECTORY_PAGE_SIZE + 1, + }); + setProjects( + rows.slice(0, DIRECTORY_PAGE_SIZE).map((project) => ({ + ...project, + documents: [], + folders: [], + })), + ); + setProjectsHasMore(rows.length > DIRECTORY_PAGE_SIZE); } else { - setProjects(await loadProjects()); + const result = await getLibrary(libraryKind(tab), { + limit: DIRECTORY_PAGE_SIZE, + }); + setLibraryDocuments(tab, () => sortDocuments(result.documents)); + setLibraryFolders(tab, () => result.folders); + setDocumentsHasMoreByLevel((prev) => ({ + ...prev, + [tab]: { + ...prev[tab], + [ROOT_LEVEL_KEY]: result.documentsHasMore, + }, + })); } loadedTabsRef.current = { ...loadedTabsRef.current, @@ -119,6 +205,274 @@ export function useDirectoryData( setLoadingTabs((prev) => ({ ...prev, [tab]: false })); } }, + [enabled, setLibraryDocuments, setLibraryFolders], + ); + + const loadFolderChildren = useCallback( + async (tab: LibraryDirectoryTab, folderId: string) => { + if (!enabled || loadedFolderIdsRef.current[tab].has(folderId)) return; + const requestKey = `${tab}:${folderId}`; + const existing = folderRequestsRef.current.get(requestKey); + if (existing) return existing; + + setLoadingFolderIds((prev) => ({ + ...prev, + [tab]: new Set(prev[tab]).add(folderId), + })); + const request = (async () => { + try { + const result = await getLibraryFolderChildren( + libraryKind(tab), + folderId, + { + limit: DIRECTORY_PAGE_SIZE, + }, + ); + setLibraryDocuments(tab, (current) => + sortDocuments(mergeById(current, result.documents)), + ); + setLibraryFolders(tab, (current) => + mergeById(current, result.folders), + ); + const nextLoaded = new Set(loadedFolderIdsRef.current[tab]).add( + folderId, + ); + loadedFolderIdsRef.current = { + ...loadedFolderIdsRef.current, + [tab]: nextLoaded, + }; + setLoadedFolderIds((prev) => ({ + ...prev, + [tab]: nextLoaded, + })); + setDocumentsHasMoreByLevel((prev) => ({ + ...prev, + [tab]: { + ...prev[tab], + [folderId]: result.documentsHasMore, + }, + })); + } catch (error) { + console.error( + "[file-directory] failed to load folder children", + error, + ); + } finally { + setLoadingFolderIds((prev) => { + const next = new Set(prev[tab]); + next.delete(folderId); + return { ...prev, [tab]: next }; + }); + folderRequestsRef.current.delete(requestKey); + } + })(); + folderRequestsRef.current.set(requestKey, request); + return request; + }, + [enabled, setLibraryDocuments, setLibraryFolders], + ); + + const loadMoreLibraryDocuments = useCallback( + async (tab: LibraryDirectoryTab, parentId: string | null) => { + if (!enabled) return; + const levelKey = parentId ?? ROOT_LEVEL_KEY; + const requestKey = `${tab}:${levelKey}`; + const existing = moreRequestsRef.current.get(requestKey); + if (existing) return existing; + + const currentDocuments = + tab === "files" + ? standaloneDocumentsRef.current + : templateDocumentsRef.current; + const offset = currentDocuments.filter( + (document) => documentFolderId(document) === parentId, + ).length; + setLoadingMoreDocumentsByLevel((prev) => ({ + ...prev, + [tab]: { ...prev[tab], [levelKey]: true }, + })); + + const request = (async () => { + try { + const result = parentId + ? await getLibraryFolderChildren(libraryKind(tab), parentId, { + limit: DIRECTORY_PAGE_SIZE, + offset, + }) + : await getLibrary(libraryKind(tab), { + limit: DIRECTORY_PAGE_SIZE, + offset, + }); + setLibraryDocuments(tab, (current) => + sortDocuments(mergeById(current, result.documents)), + ); + setLibraryFolders(tab, (current) => + mergeById(current, result.folders), + ); + setDocumentsHasMoreByLevel((prev) => ({ + ...prev, + [tab]: { + ...prev[tab], + [levelKey]: result.documentsHasMore, + }, + })); + } catch (error) { + console.error( + "[file-directory] failed to load more documents", + error, + ); + } finally { + setLoadingMoreDocumentsByLevel((prev) => ({ + ...prev, + [tab]: { ...prev[tab], [levelKey]: false }, + })); + moreRequestsRef.current.delete(requestKey); + } + })(); + moreRequestsRef.current.set(requestKey, request); + return request; + }, + [enabled, setLibraryDocuments, setLibraryFolders], + ); + + const loadMoreProjects = useCallback(async () => { + if (!enabled || !projectsHasMore || loadingMoreProjectsRef.current) { + return; + } + loadingMoreProjectsRef.current = true; + setLoadingMoreProjects(true); + try { + const rows = await listProjectSummaries({ + limit: DIRECTORY_PAGE_SIZE + 1, + offset: projectsRef.current.length, + }); + setProjects((current) => + mergeById( + current, + rows.slice(0, DIRECTORY_PAGE_SIZE).map((project) => ({ + ...project, + documents: [], + folders: [], + })), + ), + ); + setProjectsHasMore(rows.length > DIRECTORY_PAGE_SIZE); + } catch (error) { + console.error("[file-directory] failed to load more projects", error); + } finally { + loadingMoreProjectsRef.current = false; + setLoadingMoreProjects(false); + } + }, [enabled, projectsHasMore]); + + const loadProjectLevel = useCallback( + async (projectId: string, parentFolderId: string | null = null) => { + if (!enabled) return; + const levelKey = `${projectId}:${parentFolderId ?? ROOT_LEVEL_KEY}`; + if (loadedProjectLevels.has(levelKey)) return; + const existing = projectLevelRequestsRef.current.get(levelKey); + if (existing) return existing; + + setLoadingProjectLevels((current) => new Set(current).add(levelKey)); + const request = (async () => { + try { + const result = await getProjectDirectoryLevel(projectId, { + parentFolderId, + limit: DIRECTORY_PAGE_SIZE, + }); + setProjects((current) => + current.map((project) => + project.id === projectId + ? { + ...project, + documents: sortDocuments( + mergeById(project.documents ?? [], result.documents), + ), + folders: mergeById(project.folders ?? [], result.folders), + } + : project, + ), + ); + setLoadedProjectLevels((current) => new Set(current).add(levelKey)); + setProjectDocumentsHasMoreByLevel((current) => ({ + ...current, + [levelKey]: result.documentsHasMore, + })); + } catch (error) { + console.error( + "[file-directory] failed to load project folder", + error, + ); + } finally { + setLoadingProjectLevels((current) => { + const next = new Set(current); + next.delete(levelKey); + return next; + }); + projectLevelRequestsRef.current.delete(levelKey); + } + })(); + projectLevelRequestsRef.current.set(levelKey, request); + return request; + }, + [enabled, loadedProjectLevels], + ); + + const loadMoreProjectDocuments = useCallback( + async (projectId: string, parentFolderId: string | null = null) => { + if (!enabled) return; + const levelKey = `${projectId}:${parentFolderId ?? ROOT_LEVEL_KEY}`; + const requestKey = `more:${levelKey}`; + const existing = projectLevelRequestsRef.current.get(requestKey); + if (existing) return existing; + const project = projectsRef.current.find( + (candidate) => candidate.id === projectId, + ); + const offset = (project?.documents ?? []).filter( + (document) => documentFolderId(document) === parentFolderId, + ).length; + setLoadingProjectLevels((current) => new Set(current).add(requestKey)); + const request = (async () => { + try { + const result = await getProjectDirectoryLevel(projectId, { + parentFolderId, + limit: DIRECTORY_PAGE_SIZE, + offset, + }); + setProjects((current) => + current.map((candidate) => + candidate.id === projectId + ? { + ...candidate, + documents: sortDocuments( + mergeById(candidate.documents ?? [], result.documents), + ), + folders: mergeById(candidate.folders ?? [], result.folders), + } + : candidate, + ), + ); + setProjectDocumentsHasMoreByLevel((current) => ({ + ...current, + [levelKey]: result.documentsHasMore, + })); + } catch (error) { + console.error( + "[file-directory] failed to load more project files", + error, + ); + } finally { + setLoadingProjectLevels((current) => { + const next = new Set(current); + next.delete(requestKey); + return next; + }); + projectLevelRequestsRef.current.delete(requestKey); + } + })(); + projectLevelRequestsRef.current.set(requestKey, request); + return request; + }, [enabled], ); @@ -143,6 +497,20 @@ export function useDirectoryData( fileFolders, templateFolders, projects, + projectsHasMore, + loadingMoreProjects, + loadedProjectLevels, + loadingProjectLevels, + projectDocumentsHasMoreByLevel, + loadedFolderIds, + loadingFolderIds, + documentsHasMoreByLevel, + loadingMoreDocumentsByLevel, loadTab, + loadFolderChildren, + loadMoreLibraryDocuments, + loadMoreProjects, + loadProjectLevel, + loadMoreProjectDocuments, }; } diff --git a/frontend/src/app/components/workflows/UseWorkflowModal.tsx b/frontend/src/app/components/workflows/UseWorkflowModal.tsx index 089dba628..041fbf185 100644 --- a/frontend/src/app/components/workflows/UseWorkflowModal.tsx +++ b/frontend/src/app/components/workflows/UseWorkflowModal.tsx @@ -51,9 +51,12 @@ export function UseWorkflowModal({ workflow, onClose, skipSelect = false }: Prop useEffect(() => { if (!workflow) return; let cancelled = false; - Promise.all([listWorkflows("assistant"), listWorkflows("tabular")]) - .then(([assistant, tabular]) => { - if (!cancelled) setPickerWorkflows([...assistant, ...tabular]); + listWorkflows() + .then((workflows) => { + if (cancelled) return; + setPickerWorkflows(workflows); + const fullSelected = workflows.find((candidate) => candidate.id === workflow.id); + if (fullSelected) setSelected(fullSelected); }) .catch(() => { if (!cancelled) setPickerWorkflows([]); @@ -61,22 +64,28 @@ export function UseWorkflowModal({ workflow, onClose, skipSelect = false }: Prop return () => { cancelled = true; }; - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [workflow?.id]); // Configure screen state const [inProject, setInProject] = useState(false); - const [selectedProjectId, setSelectedProjectId] = useState( - null, - ); + const [selectedProjectId, setSelectedProjectId] = useState(null); const [selectedDocuments, setSelectedDocuments] = useState([]); const [assistantPrompt, setAssistantPrompt] = useState(""); const [saving, setSaving] = useState(false); const router = useRouter(); const { saveChat, setNewChatMessages } = useChatHistoryContext(); - const { loading: dirLoading, projects } = useDirectoryData( - screen === "details", + const { + loading: dirLoading, + projects, + loadProjectLevel, + loadedProjectLevels, + loadingProjectLevels, + projectDocumentsHasMoreByLevel, + loadMoreProjectDocuments, + } = useDirectoryData( + screen === "details" || screen === "documents", "projects", ); @@ -88,7 +97,7 @@ export function UseWorkflowModal({ workflow, onClose, skipSelect = false }: Prop } else { setSelected(null); } - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [workflow?.id]); // Reset configure state on back @@ -140,11 +149,7 @@ export function UseWorkflowModal({ workflow, onClose, skipSelect = false }: Prop }, ]); handleClose(); - router.push( - projectId - ? `/projects/${projectId}/assistant/chat/${chatId}` - : `/assistant/chat/${chatId}`, - ); + router.push(projectId ? `/projects/${projectId}/assistant/chat/${chatId}` : `/assistant/chat/${chatId}`); } finally { setSaving(false); } @@ -165,9 +170,7 @@ export function UseWorkflowModal({ workflow, onClose, skipSelect = false }: Prop }); handleClose(); router.push( - projectId - ? `/projects/${projectId}/tabular-reviews/${review.id}` - : `/tabular-reviews/${review.id}`, + projectId ? `/projects/${projectId}/tabular-reviews/${review.id}` : `/tabular-reviews/${review.id}`, ); } finally { setSaving(false); @@ -178,9 +181,7 @@ export function UseWorkflowModal({ workflow, onClose, skipSelect = false }: Prop const projectDocs = selectedProject?.documents ?? []; const projectOptions = projects.map((project) => ({ value: project.id, - label: - project.name + - (project.cm_number ? ` (#${project.cm_number})` : ""), + label: project.name + (project.cm_number ? ` (#${project.cm_number})` : ""), })); const location = inProject ? "project" : "workspace"; const locationOptions = @@ -236,15 +237,15 @@ export function UseWorkflowModal({ workflow, onClose, skipSelect = false }: Prop } : screen === "details" ? { - label: "Back", - onClick: () => setScreen("select"), - disabled: saving, - } + label: "Back", + onClick: () => setScreen("select"), + disabled: saving, + } : { - label: "Back", - onClick: () => setScreen("details"), - disabled: saving, - } + label: "Back", + onClick: () => setScreen("details"), + disabled: saving, + } } primaryAction={ screen === "select" @@ -256,24 +257,24 @@ export function UseWorkflowModal({ workflow, onClose, skipSelect = false }: Prop ? { label: "Next", onClick: () => setScreen("documents"), - disabled: - saving || (inProject && !selectedProjectId), - } - : wf.metadata.type === "assistant" - ? { - label: saving ? "Starting…" : "Start Chat", - onClick: handleStartChat, - disabled: - saving || (inProject && !selectedProjectId), - } - : { - label: saving ? "Creating…" : "Create Review", - onClick: handleCreateReview, disabled: saving || - selectedDocuments.length === 0 || - (inProject && !selectedProjectId), + (inProject && + (!selectedProjectId || + !loadedProjectLevels.has(`${selectedProjectId}:root`) || + loadingProjectLevels.has(`${selectedProjectId}:root`))), } + : wf.metadata.type === "assistant" + ? { + label: saving ? "Starting…" : "Start Chat", + onClick: handleStartChat, + disabled: saving || (inProject && !selectedProjectId), + } + : { + label: saving ? "Creating…" : "Create Review", + onClick: handleCreateReview, + disabled: saving || selectedDocuments.length === 0 || (inProject && !selectedProjectId), + } } cancelAction={false} > @@ -315,9 +316,7 @@ export function UseWorkflowModal({ workflow, onClose, skipSelect = false }: Prop {inProject && (
- - Project - + Project { setSelectedProjectId(value || null); setSelectedDocuments([]); + if (value) { + void loadProjectLevel(value, null); + } }} placeholder={ dirLoading ? "Loading projects..." : projects.length - ? "Select project..." - : "No projects found" + ? "Select project..." + : "No projects found" } disabled={dirLoading || projects.length === 0} /> @@ -346,9 +348,7 @@ export function UseWorkflowModal({ workflow, onClose, skipSelect = false }: Prop - setAssistantPrompt(e.target.value) - } + onChange={(e) => setAssistantPrompt(e.target.value)} placeholder="Add any additional instructions..." rows={4} /> @@ -364,12 +364,78 @@ export function UseWorkflowModal({ workflow, onClose, skipSelect = false }: Prop
loadProjectLevel(selectedProjectId, folderId) + : undefined + } + documentsHasMoreByFolder={ + inProject && selectedProjectId + ? Object.fromEntries( + Object.entries(projectDocumentsHasMoreByLevel).flatMap(([key, value]) => { + const prefix = `${selectedProjectId}:`; + return key.startsWith(prefix) ? [[key.slice(prefix.length), value]] : []; + }), + ) + : undefined + } + loadingFolderIds={ + inProject && selectedProjectId + ? new Set( + [...loadingProjectLevels] + .filter( + (key) => + key.startsWith(`${selectedProjectId}:`) && + !key.startsWith("more:"), + ) + .map((key) => key.slice(selectedProjectId.length + 1)), + ) + : undefined + } + loadedFolderIds={ + inProject && selectedProjectId + ? new Set( + [...loadedProjectLevels] + .filter((key) => key.startsWith(`${selectedProjectId}:`)) + .map((key) => key.slice(selectedProjectId.length + 1)), + ) + : undefined + } + loadingMoreFolderIds={ + inProject && selectedProjectId + ? new Set( + [...loadingProjectLevels] + .filter((key) => key.startsWith(`more:${selectedProjectId}:`)) + .map((key) => key.slice(`more:${selectedProjectId}:`.length)), + ) + : undefined + } + onLoadMoreFolderDocuments={ + inProject && selectedProjectId + ? (folderId) => loadMoreProjectDocuments(selectedProjectId, folderId) + : undefined + } + rootDocumentsHasMore={ + inProject && selectedProjectId + ? !!projectDocumentsHasMoreByLevel[`${selectedProjectId}:root`] + : false + } + loadingMoreRootDocuments={ + !!( + inProject && + selectedProjectId && + loadingProjectLevels.has(`more:${selectedProjectId}:root`) + ) + } + onLoadMoreRootDocuments={ + inProject && selectedProjectId + ? () => loadMoreProjectDocuments(selectedProjectId, null) + : undefined + } />
diff --git a/frontend/src/app/components/workflows/WorkflowList.tsx b/frontend/src/app/components/workflows/WorkflowList.tsx index 9bc255db9..d2d2fae4a 100644 --- a/frontend/src/app/components/workflows/WorkflowList.tsx +++ b/frontend/src/app/components/workflows/WorkflowList.tsx @@ -1,14 +1,11 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; +import { Plus, User, ChevronDown } from "lucide-react"; import { - Plus, - User, - ChevronDown, -} from "lucide-react"; -import { - listWorkflows, + getWorkflowFilterOptions, + type WorkflowFilterOptions, deleteWorkflow, listHiddenWorkflows, hideWorkflow, @@ -16,26 +13,22 @@ import { } from "@/app/lib/mikeApi"; import { useDebouncedValue } from "@/app/hooks/useDebouncedValue"; import { usePaginatedWorkflows } from "@/app/hooks/usePaginatedWorkflows"; +import { useAuth } from "@/app/contexts/AuthContext"; +import { deleteTabularReviewsWithConcurrency } from "@/app/lib/deleteTabularReviewsWithConcurrency"; import type { Workflow } from "../shared/types"; import { UseWorkflowModal } from "./UseWorkflowModal"; import { NewWorkflowModal } from "./NewWorkflowModal"; import { TableToolbar } from "../shared/TableToolbar"; import { RowActionMenuItems, RowActions } from "../shared/RowActions"; import { OwnerOnlyPopup } from "@/app/components/popups/OwnerOnlyPopup"; +import { ConfirmPopup } from "@/app/components/popups/ConfirmPopup"; import { MikeIcon } from "@/app/components/chat/mike-icon"; import { PageHeader } from "@/app/components/shared/PageHeader"; import { PillButton } from "@/app/components/ui/pill-button"; import { TabPillButton } from "@/app/components/ui/tab-pill-button"; import { TableLoadMoreRow } from "@/app/components/shared/TableLoadMoreRow"; -import { - LiquidDropdownButton, - LiquidDropdownSurface, -} from "@/app/components/ui/liquid-dropdown"; -import { - ChatSkeuoIcon, - TabularReviewSkeuoIcon, - WorkflowSkeuoIcon, -} from "@/app/components/shared/AppSidebarSkeuoIcons"; +import { LiquidDropdownButton, LiquidDropdownSurface } from "@/app/components/ui/liquid-dropdown"; +import { ChatSkeuoIcon, TabularReviewSkeuoIcon, WorkflowSkeuoIcon } from "@/app/components/shared/AppSidebarSkeuoIcons"; import { workflowDetailPath } from "./workflowRoutes"; import { TABLE_CHECKBOX_CLASS, @@ -72,35 +65,31 @@ const SORT_OPTIONS: TableFilterOption[] = [ export function WorkflowList() { const router = useRouter(); + const { user } = useAuth(); const searchParams = useSearchParams(); const [selected, setSelected] = useState(null); const [newModalOpen, setNewModalOpen] = useState(false); - const [editingWorkflow, setEditingWorkflow] = useState( - null, - ); + const [editingWorkflow, setEditingWorkflow] = useState(null); const [hiddenSystemIds, setHiddenSystemIds] = useState([]); const [actionsOpen, setActionsOpen] = useState(false); const [activeTab, setActiveTab] = useState("all"); const [practiceFilter, setPracticeFilter] = useState(null); - const [jurisdictionFilter, setJurisdictionFilter] = useState( - null, - ); + const [jurisdictionFilter, setJurisdictionFilter] = useState(null); const [languageFilter, setLanguageFilter] = useState(null); - const [sourceFilter, setSourceFilter] = - useState(null); + const [sourceFilter, setSourceFilter] = useState(null); const [sort, setSort] = useState<{ key: WorkflowSortKey; direction: TableSortDirection; } | null>(null); const [search, setSearch] = useState(""); const [ownerOnlyAction, setOwnerOnlyAction] = useState(null); - // A separate, always-unpaginated fetch used only to enumerate the - // practice/jurisdiction/language filter dropdown options — the paginated - // owned+shared rows below won't necessarily include every distinct - // value once there's more than one page. - const [filterOptionsWorkflows, setFilterOptionsWorkflows] = useState< - Workflow[] - >([]); + const [selectionCameFromSelectAll, setSelectionCameFromSelectAll] = useState(false); + const [confirmDeleteAllOpen, setConfirmDeleteAllOpen] = useState(false); + const [databaseFilterOptions, setDatabaseFilterOptions] = useState({ + practices: [], + jurisdictions: [], + languages: [], + }); const actionsRef = useRef(null); const previewEmptyStates = searchParams.get("emptyStates") === "1"; const debouncedSearch = useDebouncedValue(search, 250); @@ -116,8 +105,10 @@ export function WorkflowList() { loadMore, selectedWorkflowIds: selectedIds, setSelectedWorkflowIds: setSelectedIds, - selectAllMatchingOwned, + selectAllMatching, + getWorkflowOwnerId, } = usePaginatedWorkflows({ + dbEnabled: activeTab !== "system" && sourceFilter !== "system", type: activeTab === "assistant" || activeTab === "tabular" ? activeTab : undefined, search: debouncedSearch, selectionKey: search, @@ -136,22 +127,36 @@ export function WorkflowList() { }, []); useEffect(() => { - Promise.all([listWorkflows("assistant"), listWorkflows("tabular")]) - .then(([assistant, tabular]) => - setFilterOptionsWorkflows([...assistant, ...tabular]), - ) + if (activeTab === "system" || sourceFilter === "system") { + return; + } + + const controller = new AbortController(); + void getWorkflowFilterOptions({ + type: activeTab === "assistant" || activeTab === "tabular" ? activeTab : undefined, + scope: sourceFilter === "user" ? "owned" : sourceFilter === "shared" ? "shared" : "all", + signal: controller.signal, + }) + .then((options) => { + if (!controller.signal.aborted) { + setDatabaseFilterOptions(options); + } + }) .catch(() => { - // Filter option lists degrade to "no options" — not worth a - // user-facing error for purely cosmetic dropdowns. + if (!controller.signal.aborted) { + setDatabaseFilterOptions({ + practices: [], + jurisdictions: [], + languages: [], + }); + } }); - }, []); + return () => controller.abort(); + }, [activeTab, sourceFilter]); useEffect(() => { function handleClick(e: MouseEvent) { - if ( - actionsRef.current && - !actionsRef.current.contains(e.target as Node) - ) { + if (actionsRef.current && !actionsRef.current.contains(e.target as Node)) { setActionsOpen(false); } } @@ -164,12 +169,8 @@ export function WorkflowList() { const userWorkflows = visibleDbWorkflows.filter((wf) => wf.is_owner !== false); const sharedWorkflows = visibleDbWorkflows.filter((wf) => wf.is_owner === false); - const hiddenSystem = visibleSystemAll.filter((wf) => - hiddenSystemIds.includes(wf.id), - ); - const visibleSystem = visibleSystemAll.filter( - (wf) => !hiddenSystemIds.includes(wf.id), - ); + const hiddenSystem = visibleSystemAll.filter((wf) => hiddenSystemIds.includes(wf.id)); + const visibleSystem = visibleSystemAll.filter((wf) => !hiddenSystemIds.includes(wf.id)); const systemRows = [...visibleSystem, ...hiddenSystem]; const activeRows = [...userWorkflows, ...sharedWorkflows, ...visibleSystem]; const tabRows = @@ -179,87 +180,60 @@ export function WorkflowList() { ? systemRows : activeRows.filter((workflow) => workflow.metadata.type === activeTab); const sourceRows = - sourceFilter === null - ? tabRows - : tabRows.filter( - (workflow) => getWorkflowSource(workflow) === sourceFilter, - ); - - // Parallel derivation over the always-complete filterOptionsWorkflows - // fetch, purely to enumerate dropdown options — mirrors the render - // pipeline above exactly (same tab/source bucketing rules) but never - // sees a partial page, so options stay complete regardless of how many - // pages of owned/shared workflows have been loaded. - const optSystem = filterOptionsWorkflows.filter((wf) => wf.is_system); - const optUser = filterOptionsWorkflows.filter( - (wf) => !wf.is_system && wf.is_owner !== false, - ); - const optShared = filterOptionsWorkflows.filter( - (wf) => !wf.is_system && wf.is_owner === false, - ); - const optVisibleSystem = optSystem.filter( - (wf) => !hiddenSystemIds.includes(wf.id), - ); - const optHiddenSystem = optSystem.filter((wf) => - hiddenSystemIds.includes(wf.id), - ); - const optActiveRows = [...optUser, ...optShared, ...optVisibleSystem]; - const optAllRows = [...optUser, ...optShared, ...optVisibleSystem, ...optHiddenSystem]; - const optTabRows = - activeTab === "all" - ? optActiveRows - : activeTab === "system" - ? [...optVisibleSystem, ...optHiddenSystem] - : optActiveRows.filter((workflow) => workflow.metadata.type === activeTab); - const optSourceRows = - sourceFilter === null - ? optTabRows - : optTabRows.filter( - (workflow) => getWorkflowSource(workflow) === sourceFilter, - ); - const practices = Array.from( - new Set( - optSourceRows.map((wf) => wf.metadata.practice).filter((p): p is string => !!p), - ), - ).sort(); - const jurisdictions = Array.from( - new Set( - optAllRows - .flatMap((wf) => wf.metadata.jurisdictions ?? []) - .filter((jurisdiction): jurisdiction is string => !!jurisdiction), - ), - ).sort(); - const languages = Array.from( - new Set( - optAllRows - .map((wf) => wf.metadata.language) - .filter((language): language is string => !!language), - ), - ).sort(); + sourceFilter === null ? tabRows : tabRows.filter((workflow) => getWorkflowSource(workflow) === sourceFilter); + + const systemFilterRows = useMemo(() => { + if (sourceFilter !== null && sourceFilter !== "system") return []; + return systemWorkflows.filter((workflow) => { + if ((activeTab === "assistant" || activeTab === "tabular") && workflow.metadata.type !== activeTab) { + return false; + } + return activeTab === "system" || !hiddenSystemIds.includes(workflow.id); + }); + }, [activeTab, hiddenSystemIds, sourceFilter, systemWorkflows]); + const facetValues = useMemo(() => { + const includeDatabase = activeTab !== "system" && sourceFilter !== "system"; + const combine = (databaseValues: string[], systemValues: string[]) => + Array.from(new Set([...databaseValues, ...systemValues])).sort((a, b) => a.localeCompare(b)); + return { + practices: combine( + includeDatabase ? databaseFilterOptions.practices : [], + systemFilterRows.flatMap((workflow) => + workflow.metadata.practice ? [workflow.metadata.practice] : [], + ), + ), + jurisdictions: combine( + includeDatabase ? databaseFilterOptions.jurisdictions : [], + systemFilterRows.flatMap((workflow) => workflow.metadata.jurisdictions ?? []), + ), + languages: combine( + includeDatabase ? databaseFilterOptions.languages : [], + systemFilterRows.flatMap((workflow) => + workflow.metadata.language ? [workflow.metadata.language] : [], + ), + ), + }; + }, [activeTab, databaseFilterOptions, sourceFilter, systemFilterRows]); + const { practices, jurisdictions, languages } = facetValues; const q = search.toLowerCase(); const filtered = sourceRows .filter((wf) => !practiceFilter || wf.metadata.practice === practiceFilter) - .filter( - (wf) => - !jurisdictionFilter || - wf.metadata.jurisdictions?.includes(jurisdictionFilter), - ) + .filter((wf) => !jurisdictionFilter || wf.metadata.jurisdictions?.includes(jurisdictionFilter)) .filter((wf) => !languageFilter || wf.metadata.language === languageFilter) .filter((wf) => !q || wf.metadata.title.toLowerCase().includes(q)) .sort((a, b) => compareWorkflows(a, b, sort)); - const allSelected = - filtered.length > 0 && - filtered.every((wf) => selectedIds.includes(wf.id)); - const someSelected = - !allSelected && filtered.some((wf) => selectedIds.includes(wf.id)); + const allSelected = filtered.length > 0 && filtered.every((wf) => selectedIds.includes(wf.id)); + const someSelected = !allSelected && filtered.some((wf) => selectedIds.includes(wf.id)); function toggleAll() { if (allSelected) { setSelectedIds([]); + setSelectionCameFromSelectAll(false); return; } + setSelectionCameFromSelectAll(true); // If everything currently displayable is already fully in memory // (a pure-system view, or every DB page has already been loaded), // just select what's visible — no network round-trip needed. @@ -267,18 +241,18 @@ export function WorkflowList() { if (allSystemView || !hasMore) { setSelectedIds(filtered.map((wf) => wf.id)); } else { - void selectAllMatchingOwned(); + void selectAllMatching(filtered.filter((workflow) => workflow.is_system).map((workflow) => workflow.id)); } } function toggleOne(id: string) { - setSelectedIds((prev) => - prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id], - ); + setSelectedIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])); } function clearSelection() { setSelectedIds([]); + setSelectionCameFromSelectAll(false); + setConfirmDeleteAllOpen(false); setActionsOpen(false); } @@ -307,10 +281,7 @@ export function WorkflowList() { clearSelection(); } - function handleSortChange( - key: WorkflowSortKey, - direction: TableSortDirection | null, - ) { + function handleSortChange(key: WorkflowSortKey, direction: TableSortDirection | null) { setSort(direction ? { key, direction } : null); clearSelection(); } @@ -329,41 +300,35 @@ export function WorkflowList() { }); } + function requestBulkRemove() { + setActionsOpen(false); + if (selectionCameFromSelectAll && !selectedOnlySystem) { + setConfirmDeleteAllOpen(true); + return; + } + void handleBulkRemove(); + } + async function handleBulkRemove() { const ids = [...selectedIds]; setActionsOpen(false); + setConfirmDeleteAllOpen(false); + setSelectionCameFromSelectAll(false); setSelectedIds([]); - const systemIds = ids.filter((id) => - systemWorkflows.some((workflow) => workflow.id === id), - ); + const systemIds = ids.filter((id) => systemWorkflows.some((workflow) => workflow.id === id)); const nonSystemIds = ids.filter((id) => !systemIds.includes(id)); - // Any non-system id still loaded in dbWorkflows carries its own - // is_owner flag — trust it directly. Any id NOT found there can only - // have arrived via selectAllMatchingOwned (scope-restricted to - // owned rows), so it's safe to treat as owned without a lookup. const ownedIds = nonSystemIds.filter((id) => { - const loaded = dbWorkflows.find((workflow) => workflow.id === id); - return loaded ? loaded.is_owner !== false : true; + const ownerId = getWorkflowOwnerId(id); + return !!ownerId && ownerId === user?.id; }); - const blockedSharedIds = nonSystemIds.filter( - (id) => !ownedIds.includes(id), - ); + const blockedSharedIds = nonSystemIds.filter((id) => !ownedIds.includes(id)); if (systemIds.length > 0) { - setHiddenSystemIds((prev) => [ - ...prev, - ...systemIds.filter((id) => !prev.includes(id)), - ]); - await Promise.all( - systemIds.map((id) => hideWorkflow(id).catch(() => {})), - ); + setHiddenSystemIds((prev) => [...prev, ...systemIds.filter((id) => !prev.includes(id))]); + await deleteTabularReviewsWithConcurrency(systemIds, hideWorkflow); } if (ownedIds.length > 0) { - await Promise.all( - ownedIds.map((id) => deleteWorkflow(id).catch(() => {})), - ); - setDbWorkflows((prev) => - prev.filter((w) => !ownedIds.includes(w.id)), - ); + const { deletedIds } = await deleteTabularReviewsWithConcurrency(ownedIds, deleteWorkflow); + setDbWorkflows((prev) => prev.filter((w) => !deletedIds.includes(w.id))); } if (blockedSharedIds.length > 0) { setOwnerOnlyAction( @@ -377,7 +342,7 @@ export function WorkflowList() { setActionsOpen(false); setSelectedIds([]); setHiddenSystemIds((prev) => prev.filter((id) => !ids.includes(id))); - await Promise.all(ids.map((id) => unhideWorkflow(id).catch(() => {}))); + await deleteTabularReviewsWithConcurrency(ids, unhideWorkflow); } const getTypeMeta = (type: Workflow["metadata"]["type"]) => @@ -387,10 +352,8 @@ export function WorkflowList() { label: "Assistant", Icon: ChatSkeuoIcon, }; - const nameSortDirection = - sort?.key === "name" ? sort.direction : null; - const typeSortDirection = - sort?.key === "type" ? sort.direction : null; + const nameSortDirection = sort?.key === "name" ? sort.direction : null; + const typeSortDirection = sort?.key === "type" ? sort.direction : null; const nameFilterButton = ( ); - const selectedHiddenSystemIds = selectedIds.filter((id) => - hiddenSystemIds.includes(id), - ); - const selectedSystemIds = selectedIds.filter((id) => - systemWorkflows.some((workflow) => workflow.id === id), - ); - const selectedOnlySystem = - selectedIds.length > 0 && selectedIds.length === selectedSystemIds.length; - const selectedOnlyHiddenSystem = - selectedIds.length > 0 && - selectedIds.length === selectedHiddenSystemIds.length; + const selectedHiddenSystemIds = selectedIds.filter((id) => hiddenSystemIds.includes(id)); + const selectedSystemIds = selectedIds.filter((id) => systemWorkflows.some((workflow) => workflow.id === id)); + const selectedOnlySystem = selectedIds.length > 0 && selectedIds.length === selectedSystemIds.length; + const selectedOnlyHiddenSystem = selectedIds.length > 0 && selectedIds.length === selectedHiddenSystemIds.length; const toolbarActions = selectedIds.length > 0 ? (
- setActionsOpen((v) => !v)} - > + setActionsOpen((v) => !v)}> Actions @@ -502,7 +456,7 @@ export function WorkflowList() { ) : (
); } @@ -936,18 +826,8 @@ function compareWorkflows( if (!sort) return 0; const direction = sort.direction === "asc" ? 1 : -1; - const aValue = - sort.key === "name" - ? a.metadata.title - : a.metadata.type === "tabular" - ? "Tabular" - : "Assistant"; - const bValue = - sort.key === "name" - ? b.metadata.title - : b.metadata.type === "tabular" - ? "Tabular" - : "Assistant"; + const aValue = sort.key === "name" ? a.metadata.title : a.metadata.type === "tabular" ? "Tabular" : "Assistant"; + const bValue = sort.key === "name" ? b.metadata.title : b.metadata.type === "tabular" ? "Tabular" : "Assistant"; return aValue.localeCompare(bValue) * direction; } diff --git a/frontend/src/app/contexts/ChatHistoryContext.tsx b/frontend/src/app/contexts/ChatHistoryContext.tsx index 2bca6d42f..5d550af7a 100644 --- a/frontend/src/app/contexts/ChatHistoryContext.tsx +++ b/frontend/src/app/contexts/ChatHistoryContext.tsx @@ -6,6 +6,7 @@ import { useContext, useEffect, useMemo, + useRef, useState, type ReactNode, } from "react"; @@ -21,19 +22,16 @@ import type { Chat, Message } from "@/app/components/shared/types"; interface ChatHistoryContextType { chats: Chat[] | null; hasMoreChats: boolean; + loadingMoreChats: boolean; currentChatId: string | null; setCurrentChatId: (chatId: string | null) => void; loadChats: () => Promise; - loadMoreChats: () => void; + loadMoreChats: () => Promise; saveChat: (projectId?: string) => Promise; renameChat: (chatId: string, title: string) => Promise; newChatMessages: Message[] | null; setNewChatMessages: (messages: Message[] | null) => void; - replaceChatId: ( - oldChatId: string, - newChatId: string, - title?: string, - ) => void; + replaceChatId: (oldChatId: string, newChatId: string, title?: string) => void; deleteChat: (chatId: string) => Promise; } @@ -42,13 +40,14 @@ const ChatHistoryContext = createContext( ); const INITIAL_CHAT_LIMIT = 20; -const CHAT_LIMIT_INCREMENT = 10; +const CHAT_PAGE_SIZE = 10; export function ChatHistoryProvider({ children }: { children: ReactNode }) { const { user } = useAuth(); const [chats, setChats] = useState(null); - const [chatLimit, setChatLimit] = useState(INITIAL_CHAT_LIMIT); const [hasMoreChats, setHasMoreChats] = useState(false); + const [loadingMoreChats, setLoadingMoreChats] = useState(false); + const loadingMoreChatsRef = useRef(false); const [currentChatId, setCurrentChatId] = useState(null); const [newChatMessages, setNewChatMessages] = useState( null, @@ -62,21 +61,21 @@ export function ChatHistoryProvider({ children }: { children: ReactNode }) { } try { - const data = await listChats({ limit: chatLimit + 1 }); - setChats(data.slice(0, chatLimit)); - setHasMoreChats(data.length > chatLimit); + const data = await listChats({ limit: INITIAL_CHAT_LIMIT + 1 }); + setChats(data.slice(0, INITIAL_CHAT_LIMIT)); + setHasMoreChats(data.length > INITIAL_CHAT_LIMIT); } catch { setChats([]); setHasMoreChats(false); } - }, [chatLimit, user]); + }, [user]); useEffect(() => { if (!user) { - // eslint-disable-next-line react-hooks/set-state-in-effect -- clear chat state on logout inside the effect that loads chats setChats([]); - setChatLimit(INITIAL_CHAT_LIMIT); setHasMoreChats(false); + setLoadingMoreChats(false); + loadingMoreChatsRef.current = false; setCurrentChatId(null); return; } @@ -84,9 +83,39 @@ export function ChatHistoryProvider({ children }: { children: ReactNode }) { void loadChats(); }, [user, loadChats]); - const loadMoreChats = useCallback(() => { - setChatLimit((prev) => prev + CHAT_LIMIT_INCREMENT); - }, []); + const loadMoreChats = useCallback(async () => { + if ( + !user || + !hasMoreChats || + loadingMoreChatsRef.current || + chats === null + ) { + return; + } + + loadingMoreChatsRef.current = true; + setLoadingMoreChats(true); + try { + const data = await listChats({ + limit: CHAT_PAGE_SIZE + 1, + offset: chats.length, + }); + const page = data.slice(0, CHAT_PAGE_SIZE); + setChats((current) => { + const existing = new Set((current ?? []).map((chat) => chat.id)); + return [ + ...(current ?? []), + ...page.filter((chat) => !existing.has(chat.id)), + ]; + }); + setHasMoreChats(data.length > CHAT_PAGE_SIZE); + } catch { + // Preserve the current page and allow another scroll to retry. + } finally { + loadingMoreChatsRef.current = false; + setLoadingMoreChats(false); + } + }, [chats, hasMoreChats, user]); const replaceChatId = useCallback( (oldChatId: string, newChatId: string, title?: string) => { @@ -142,9 +171,7 @@ export function ChatHistoryProvider({ children }: { children: ReactNode }) { const renameChatFn = useCallback( async (chatId: string, title: string) => { setChats((prev) => - (prev ?? []).map((c) => - c.id === chatId ? { ...c, title } : c, - ), + (prev ?? []).map((c) => (c.id === chatId ? { ...c, title } : c)), ); try { await renameChat(chatId, title); @@ -172,6 +199,7 @@ export function ChatHistoryProvider({ children }: { children: ReactNode }) { () => ({ chats, hasMoreChats, + loadingMoreChats, currentChatId, setCurrentChatId, loadChats, @@ -186,6 +214,7 @@ export function ChatHistoryProvider({ children }: { children: ReactNode }) { [ chats, hasMoreChats, + loadingMoreChats, currentChatId, loadChats, loadMoreChats, diff --git a/frontend/src/app/hooks/usePaginatedProjects.ts b/frontend/src/app/hooks/usePaginatedProjects.ts index 902e45c8b..b701fa185 100644 --- a/frontend/src/app/hooks/usePaginatedProjects.ts +++ b/frontend/src/app/hooks/usePaginatedProjects.ts @@ -8,26 +8,20 @@ import { } from "react"; import type { Project } from "@/app/components/shared/types"; import { listProjectIds, listProjectsPage } from "@/app/lib/mikeApi"; +import { appendUniqueRows, paginationError, splitOverfetchedPage } from "@/app/lib/paginatedRows"; -export type ProjectSortKey = "name" | "cm" | "files" | "chats" | "reviews" | "created"; +export type ProjectSortKey = + | "name" + | "cm" + | "files" + | "chats" + | "reviews" + | "created"; export type ProjectSortDirection = "asc" | "desc"; export type ProjectScope = "all" | "mine" | "shared"; const PAGE_SIZE = 30; -function pageRows(rows: Project[]) { - return { - hasMore: rows.length > PAGE_SIZE, - rows: rows.slice(0, PAGE_SIZE), - }; -} - -function asError(value: unknown) { - return value instanceof Error - ? value - : new Error("Unable to load projects"); -} - // Server-side-paginated projects list, cloned from usePaginatedTabularReviews // (same shape: limit+1 over-fetch to derive hasMore without a count query, // queryKey-scoped selection state so changing filters can't leak stale @@ -85,14 +79,11 @@ export function usePaginatedProjects(options: { }>({ queryKey, ids: [] }); const selectedProjectIds = selection.queryKey === queryKey ? selection.ids : []; - const setSelectedProjectIds: Dispatch> = - useCallback( + const setSelectedProjectIds: Dispatch> = useCallback( (value) => { setSelection((current) => { - const currentIds = - current.queryKey === queryKey ? current.ids : []; - const ids = - typeof value === "function" ? value(currentIds) : value; + const currentIds = current.queryKey === queryKey ? current.ids : []; + const ids = typeof value === "function" ? value(currentIds) : value; return { queryKey, ids }; }); }, @@ -142,7 +133,7 @@ export function usePaginatedProjects(options: { }) .then((rows) => { if (requestVersion !== requestVersionRef.current) return; - const firstPage = pageRows(rows); + const firstPage = splitOverfetchedPage(rows, PAGE_SIZE); setProjects(firstPage.rows); setHasMore(firstPage.hasMore); }) @@ -153,7 +144,7 @@ export function usePaginatedProjects(options: { ) return; console.error("[projects] failed to load", error); - setError(asError(error)); + setError(paginationError(error, "Unable to load projects")); setHasMore(false); }) .finally(() => { @@ -203,16 +194,8 @@ export function usePaginatedProjects(options: { }); if (requestVersion !== requestVersionRef.current) return; - const nextPage = pageRows(rows); - setProjects((current) => { - const existingIds = new Set(current.map((project) => project.id)); - return [ - ...current, - ...nextPage.rows.filter( - (project) => !existingIds.has(project.id), - ), - ]; - }); + const nextPage = splitOverfetchedPage(rows, PAGE_SIZE); + setProjects((current) => appendUniqueRows(current, nextPage.rows)); setHasMore(nextPage.hasMore); } catch (error) { if ( @@ -220,7 +203,7 @@ export function usePaginatedProjects(options: { requestVersion === requestVersionRef.current ) { console.error("[projects] failed to load more", error); - setLoadMoreError(asError(error)); + setLoadMoreError(paginationError(error, "Unable to load projects")); } } finally { if ( @@ -273,9 +256,7 @@ export function usePaginatedProjects(options: { setSelectAllOwners({ queryKey, - ownerById: Object.fromEntries( - rows.map((row) => [row.id, row.user_id]), - ), + ownerById: Object.fromEntries(rows.map((row) => [row.id, row.user_id])), }); setSelectedProjectIds(rows.map((row) => row.id)); } finally { diff --git a/frontend/src/app/hooks/usePaginatedTabularReviews.ts b/frontend/src/app/hooks/usePaginatedTabularReviews.ts index 434257fe7..656434121 100644 --- a/frontend/src/app/hooks/usePaginatedTabularReviews.ts +++ b/frontend/src/app/hooks/usePaginatedTabularReviews.ts @@ -8,6 +8,7 @@ import { } from "react"; import type { TabularReview } from "@/app/components/shared/types"; import { listTabularReviewIds, listTabularReviews } from "@/app/lib/mikeApi"; +import { appendUniqueRows, paginationError, splitOverfetchedPage } from "@/app/lib/paginatedRows"; export type TabularReviewSortKey = "name" | "columns" | "documents" | "created"; export type TabularReviewSortDirection = "asc" | "desc"; @@ -15,19 +16,6 @@ export type TabularReviewScope = "all" | "in-project" | "standalone"; const PAGE_SIZE = 30; -function pageRows(rows: TabularReview[]) { - return { - hasMore: rows.length > PAGE_SIZE, - rows: rows.slice(0, PAGE_SIZE), - }; -} - -function asError(value: unknown) { - return value instanceof Error - ? value - : new Error("Unable to load tabular reviews"); -} - export function usePaginatedTabularReviews(options: { projectId?: string; search?: string; @@ -69,14 +57,11 @@ export function usePaginatedTabularReviews(options: { }>({ queryKey, ids: [] }); const selectedReviewIds = selection.queryKey === queryKey ? selection.ids : []; - const setSelectedReviewIds: Dispatch> = - useCallback( + const setSelectedReviewIds: Dispatch> = useCallback( (value) => { setSelection((current) => { - const currentIds = - current.queryKey === queryKey ? current.ids : []; - const ids = - typeof value === "function" ? value(currentIds) : value; + const currentIds = current.queryKey === queryKey ? current.ids : []; + const ids = typeof value === "function" ? value(currentIds) : value; return { queryKey, ids }; }); }, @@ -123,7 +108,7 @@ export function usePaginatedTabularReviews(options: { }) .then((rows) => { if (requestVersion !== requestVersionRef.current) return; - const firstPage = pageRows(rows); + const firstPage = splitOverfetchedPage(rows, PAGE_SIZE); setReviews(firstPage.rows); setHasMore(firstPage.hasMore); }) @@ -134,7 +119,7 @@ export function usePaginatedTabularReviews(options: { ) return; console.error("[tabular reviews] failed to load", error); - setError(asError(error)); + setError(paginationError(error, "Unable to load tabular reviews")); setHasMore(false); }) .finally(() => { @@ -174,16 +159,8 @@ export function usePaginatedTabularReviews(options: { }); if (requestVersion !== requestVersionRef.current) return; - const nextPage = pageRows(rows); - setReviews((current) => { - const existingIds = new Set(current.map((review) => review.id)); - return [ - ...current, - ...nextPage.rows.filter( - (review) => !existingIds.has(review.id), - ), - ]; - }); + const nextPage = splitOverfetchedPage(rows, PAGE_SIZE); + setReviews((current) => appendUniqueRows(current, nextPage.rows)); setHasMore(nextPage.hasMore); } catch (error) { if ( @@ -191,7 +168,9 @@ export function usePaginatedTabularReviews(options: { requestVersion === requestVersionRef.current ) { console.error("[tabular reviews] failed to load more", error); - setLoadMoreError(asError(error)); + setLoadMoreError( + paginationError(error, "Unable to load tabular reviews"), + ); } } finally { if ( @@ -240,9 +219,7 @@ export function usePaginatedTabularReviews(options: { setSelectAllOwners({ queryKey, - ownerById: Object.fromEntries( - rows.map((row) => [row.id, row.user_id]), - ), + ownerById: Object.fromEntries(rows.map((row) => [row.id, row.user_id])), }); setSelectedReviewIds(rows.map((row) => row.id)); } finally { diff --git a/frontend/src/app/hooks/usePaginatedWorkflows.ts b/frontend/src/app/hooks/usePaginatedWorkflows.ts index 5e48679c8..59a5084c2 100644 --- a/frontend/src/app/hooks/usePaginatedWorkflows.ts +++ b/frontend/src/app/hooks/usePaginatedWorkflows.ts @@ -12,6 +12,7 @@ import { listWorkflowIds, listWorkflowsPage, } from "@/app/lib/mikeApi"; +import { appendUniqueRows, paginationError, splitOverfetchedPage } from "@/app/lib/paginatedRows"; export type WorkflowSortKey = "name" | "type" | "created"; export type WorkflowSortDirection = "asc" | "desc"; @@ -20,19 +21,6 @@ type WorkflowTypeFilter = "assistant" | "tabular" | undefined; const PAGE_SIZE = 30; -function pageRows(rows: Workflow[]) { - return { - hasMore: rows.length > PAGE_SIZE, - rows: rows.slice(0, PAGE_SIZE), - }; -} - -function asError(value: unknown) { - return value instanceof Error - ? value - : new Error("Unable to load workflows"); -} - /** * Server-side-paginated owned+shared workflows, plus the always-eager, * always-fully-loaded static system-workflow bucket (37 entries, no @@ -50,6 +38,7 @@ function asError(value: unknown) { * `setSelectedWorkflowIds` with no hook involvement needed). */ export function usePaginatedWorkflows(options: { + dbEnabled?: boolean; type?: WorkflowTypeFilter; search?: string; selectionKey?: string; @@ -86,6 +75,7 @@ export function usePaginatedWorkflows(options: { languageFilter = null, jurisdictionFilter = null, sort, + dbEnabled = true, } = options; const sortKey = sort?.key; const sortDirection = sort?.direction; @@ -112,10 +102,8 @@ export function usePaginatedWorkflows(options: { useCallback( (value) => { setSelection((current) => { - const currentIds = - current.queryKey === queryKey ? current.ids : []; - const ids = - typeof value === "function" ? value(currentIds) : value; + const currentIds = current.queryKey === queryKey ? current.ids : []; + const ids = typeof value === "function" ? value(currentIds) : value; return { queryKey, ids }; }); }, @@ -153,6 +141,11 @@ export function usePaginatedWorkflows(options: { setLoadingMore(false); setError(null); setLoadMoreError(null); + if (!dbEnabled) { + setDbLoading(false); + setHasMore(false); + return; + } setDbLoading(true); void listWorkflowsPage({ @@ -169,7 +162,7 @@ export function usePaginatedWorkflows(options: { }) .then((rows) => { if (requestVersion !== requestVersionRef.current) return; - const firstPage = pageRows(rows); + const firstPage = splitOverfetchedPage(rows, PAGE_SIZE); setDbWorkflows(firstPage.rows); setHasMore(firstPage.hasMore); }) @@ -180,7 +173,7 @@ export function usePaginatedWorkflows(options: { ) return; console.error("[workflows] failed to load", error); - setError(asError(error)); + setError(paginationError(error, "Unable to load workflows")); setHasMore(false); }) .finally(() => { @@ -204,10 +197,11 @@ export function usePaginatedWorkflows(options: { search, sortDirection, sortKey, + dbEnabled, ]); const loadMore = useCallback(async () => { - if (dbLoading || loadingMoreRef.current || !hasMore) return; + if (!dbEnabled || dbLoading || loadingMoreRef.current || !hasMore) return; const requestVersion = requestVersionRef.current; const offset = dbWorkflows.length; @@ -234,16 +228,8 @@ export function usePaginatedWorkflows(options: { }); if (requestVersion !== requestVersionRef.current) return; - const nextPage = pageRows(rows); - setDbWorkflows((current) => { - const existingIds = new Set(current.map((workflow) => workflow.id)); - return [ - ...current, - ...nextPage.rows.filter( - (workflow) => !existingIds.has(workflow.id), - ), - ]; - }); + const nextPage = splitOverfetchedPage(rows, PAGE_SIZE); + setDbWorkflows((current) => appendUniqueRows(current, nextPage.rows)); setHasMore(nextPage.hasMore); } catch (error) { if ( @@ -251,7 +237,7 @@ export function usePaginatedWorkflows(options: { requestVersion === requestVersionRef.current ) { console.error("[workflows] failed to load more", error); - setLoadMoreError(asError(error)); + setLoadMoreError(paginationError(error, "Unable to load workflows")); } } finally { if ( @@ -265,6 +251,7 @@ export function usePaginatedWorkflows(options: { } }, [ dbLoading, + dbEnabled, hasMore, type, dbWorkflows.length, @@ -280,19 +267,36 @@ export function usePaginatedWorkflows(options: { setRetryVersion((current) => current + 1); }, []); - // Selects every OWNED workflow matching the current filters, not just - // the page(s) already loaded. Shared workflows are deliberately excluded - // — bulk delete is owner-only, so "select all" for that action should - // never pull in workflows the user can't actually delete. Fetches only - // ids (+ owner), not full workflow payloads. - const selectAllMatchingOwned = useCallback(async () => { + const [selectAllOwners, setSelectAllOwners] = useState<{ + queryKey: string; + ownerById: Record; + }>({ queryKey, ownerById: {} }); + const getWorkflowOwnerId = useCallback( + (id: string): string | undefined => { + const loaded = dbWorkflows.find((workflow) => workflow.id === id); + if (loaded) return loaded.user_id ?? undefined; + return selectAllOwners.queryKey === queryKey + ? selectAllOwners.ownerById[id] + : undefined; + }, + [dbWorkflows, queryKey, selectAllOwners], + ); + + // Select every DB-backed workflow matching the active source scope and + // filters. System workflow ids can be supplied by the caller because + // that static bucket is already fully loaded in memory. + const selectAllMatching = useCallback( + async (additionalIds: string[] = []) => { if (selectionQueryPending) return; if (!hasMore) { setSelectedWorkflowIds( - dbWorkflows - .filter((workflow) => workflow.is_owner !== false) - .map((workflow) => workflow.id), + Array.from( + new Set([ + ...additionalIds, + ...dbWorkflows.map((workflow) => workflow.id), + ]), + ), ); return; } @@ -303,28 +307,40 @@ export function usePaginatedWorkflows(options: { const rows = await listWorkflowIds({ type, search: search || undefined, - scope: "owned", + scope, practice: practiceFilter || undefined, language: languageFilter || undefined, jurisdiction: jurisdictionFilter || undefined, }); if (requestVersion !== requestVersionRef.current) return; - setSelectedWorkflowIds(rows.map((row) => row.id)); + setSelectAllOwners({ + queryKey, + ownerById: Object.fromEntries( + rows.map((row) => [row.id, row.user_id]), + ), + }); + setSelectedWorkflowIds( + Array.from(new Set([...additionalIds, ...rows.map((row) => row.id)])), + ); } finally { setSelectingAllRequest(false); } - }, [ + }, + [ hasMore, + queryKey, type, dbWorkflows, + scope, practiceFilter, languageFilter, jurisdictionFilter, search, selectionQueryPending, setSelectedWorkflowIds, - ]); + ], + ); return { systemWorkflows, @@ -339,7 +355,8 @@ export function usePaginatedWorkflows(options: { retry, selectedWorkflowIds, setSelectedWorkflowIds, - selectAllMatchingOwned, + selectAllMatching, selectingAll: selectingAllRequest || selectionQueryPending, + getWorkflowOwnerId, }; } diff --git a/frontend/src/app/lib/mikeApi.test.ts b/frontend/src/app/lib/mikeApi.test.ts index d2c01bf06..186f23fa6 100644 --- a/frontend/src/app/lib/mikeApi.test.ts +++ b/frontend/src/app/lib/mikeApi.test.ts @@ -1,11 +1,4 @@ -import { - afterEach, - beforeEach, - describe, - expect, - it, - vi, -} from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { AssistantEvent, Chat } from "@/app/components/shared/types"; // mikeApi resolves the auth header through the module-level Supabase client, @@ -55,10 +48,12 @@ import { getCourtlistenerOpinions, getDocumentUrl, getLibrary, + getLibraryFilterOptions, getLibraryFolderChildren, getMcpConnector, getOllamaModels, getProject, + getProjectFilterOptions, getProjectPeople, getTabularChatMessages, getTabularChats, @@ -66,6 +61,7 @@ import { getTabularReviewPeople, getUserProfile, getWorkflow, + getWorkflowFilterOptions, hideWorkflow, isMfaRequiredError, listChats, @@ -74,6 +70,7 @@ import { listMcpConnectors, listProjectChats, listProjectIds, + listProjectSummaries, listProjects, listProjectsPage, listStandaloneDocuments, @@ -101,7 +98,9 @@ import { renameProjectFolder, renameTabularChat, replaceDocumentVersionFile, - saveApiKey, + saveApiKey, + searchProjectDirectory, + searchLibraryDocuments, setMcpToolEnabled, shareWorkflow, startMcpConnectorOAuth, @@ -266,8 +265,8 @@ describe("apiRequest plumbing (via thin wrappers)", () => { ); }); - // Regression guard: the sidebar nav, the document-picker directory view, - // and the tabular-review project pickers all call listProjects() with no + // Regression guard: legacy tabular-review project pickers call + // listProjects() with no // arguments and need every project back. The backend route decides // whether to paginate purely by checking whether pagination-related // query params are present at all — if listProjects() ever started @@ -357,8 +356,10 @@ describe("apiRequest plumbing (via thin wrappers)", () => { ); fetchMock.mockResolvedValue(jsonResponse([])); - await listChats({ limit: 5 }); - expect(lastFetchCall().url).toBe("http://localhost:3001/chat?limit=5"); + await listChats({ limit: 5, offset: 10 }); + expect(lastFetchCall().url).toBe( + "http://localhost:3001/chat?limit=5&offset=10", + ); }); }); @@ -529,9 +530,7 @@ describe("getChat message mapping", () => { describe("mapTRMessages", () => { it("maps user and assistant rows including annotations", () => { - const events: AssistantEvent[] = [ - { type: "content", text: "Answer" }, - ]; + const events: AssistantEvent[] = [{ type: "content", text: "Answer" }]; const mapped = mapTRMessages([ { id: "m1", @@ -639,10 +638,7 @@ describe("streamChat", () => { }); it("returns the streaming Response body unconsumed", async () => { - const chunks = [ - 'data: {"type":"content_delta","text":"Hel', - 'lo"}\n\n', - ]; + const chunks = ['data: {"type":"content_delta","text":"Hel', 'lo"}\n\n']; fetchMock.mockResolvedValue(streamResponse(chunks)); const response = await streamChat({ @@ -850,12 +846,42 @@ describe("listProjectsPage", () => { await listProjectsPage({ scope: "all", limit: 10 }); + expect(lastFetchCall().url).toBe("http://localhost:3001/projects?limit=10"); + }); +}); + +describe("listProjectSummaries", () => { + it("uses the projects collection with the summary view", async () => { + fetchMock.mockResolvedValue(jsonResponse([])); + + await listProjectSummaries({ limit: 11, offset: 10 }); + expect(lastFetchCall().url).toBe( - "http://localhost:3001/projects?limit=10", + "http://localhost:3001/projects?limit=11&offset=10&view=summary", ); }); }); +describe("searchProjectDirectory", () => { + it("uses the projects collection directory-search view", async () => { + fetchMock.mockResolvedValue(jsonResponse([])); + const controller = new AbortController(); + + await searchProjectDirectory({ + search: "agreement", + limit: 51, + offset: 10, + signal: controller.signal, + }); + + const { url, init } = lastFetchCall(); + expect(url).toBe( + "http://localhost:3001/projects?view=directory-search&search=agreement&limit=51&offset=10", + ); + expect(init.signal).toBe(controller.signal); + }); +}); + describe("listProjectIds", () => { it("requests the bare id list when no filters are given", async () => { fetchMock.mockResolvedValue(jsonResponse([])); @@ -897,13 +923,22 @@ describe("listProjectIds", () => { }); }); +describe("getProjectFilterOptions", () => { + it("loads lightweight project facets and forwards cancellation", async () => { + fetchMock.mockResolvedValue( + jsonResponse({ practices: ["Litigation"], owners: [] }), + ); + const controller = new AbortController(); + + await getProjectFilterOptions(controller.signal); + + const { url, init } = lastFetchCall(); + expect(url).toBe("http://localhost:3001/projects/filter-options"); + expect(init.signal).toBe(controller.signal); + }); +}); + describe("listWorkflows", () => { - // Regression guard: the workflow picker modal, the chat slash-menu - // picker, and UseWorkflowModal's own independent fetch all rely on this - // function sending zero pagination-related query params — the backend - // route decides whether to paginate purely on their presence. If this - // ever grew a stray param, those callers would start getting a - // truncated, system-workflow-free list back with no error. it("sends only the type param, never a pagination knob", async () => { fetchMock.mockResolvedValue(jsonResponse([])); @@ -1000,9 +1035,7 @@ describe("listSystemWorkflows", () => { await listSystemWorkflows(); - expect(lastFetchCall().url).toBe( - "http://localhost:3001/workflows/system", - ); + expect(lastFetchCall().url).toBe("http://localhost:3001/workflows/system"); }); it("appends the type filter when given", async () => { @@ -1016,6 +1049,64 @@ describe("listSystemWorkflows", () => { }); }); +describe("getWorkflowFilterOptions", () => { + it("scopes workflow facets by type and ownership", async () => { + fetchMock.mockResolvedValue( + jsonResponse({ practices: [], languages: [], jurisdictions: [] }), + ); + const controller = new AbortController(); + + await getWorkflowFilterOptions({ + type: "assistant", + scope: "shared", + signal: controller.signal, + }); + + const { url, init } = lastFetchCall(); + expect(url).toBe( + "http://localhost:3001/workflows/filter-options?type=assistant&scope=shared", + ); + expect(init.signal).toBe(controller.signal); + }); +}); + +describe("Library search", () => { + it("sends every server-side query option and returns flat results", async () => { + fetchMock.mockResolvedValue( + jsonResponse({ documents: [{ id: "d1" }], documentsHasMore: true }), + ); + const controller = new AbortController(); + + const result = await searchLibraryDocuments("templates", { + limit: 50, + offset: 100, + search: "agreement", + fileType: "docx", + sortKey: "updated", + sortDirection: "desc", + signal: controller.signal, + }); + + expect(result.documentsHasMore).toBe(true); + const { url, init } = lastFetchCall(); + expect(url).toBe( + "http://localhost:3001/library/templates?view=search&limit=50&offset=100" + + "&search=agreement&file_type=docx&sort_key=updated&sort_direction=desc", + ); + expect(init.signal).toBe(controller.signal); + }); + + it("loads the complete file-type facet list", async () => { + fetchMock.mockResolvedValue(jsonResponse({ fileTypes: ["docx", "pdf"] })); + + await getLibraryFilterOptions("files"); + + expect(lastFetchCall().url).toBe( + "http://localhost:3001/library/files/filter-options", + ); + }); +}); + describe("tabular review CRUD", () => { it("createTabularReview posts the folder grouping mode through unchanged", async () => { fetchMock.mockResolvedValue(jsonResponse({ id: "r1" })); @@ -1122,9 +1213,9 @@ describe("uploadReviewDocument", () => { const [uploadCall, patchCall] = fetchMock.mock.calls; expect(uploadCall[0]).toBe("http://localhost:3001/single-documents"); // With no prior ids the review ends up with exactly the new document. - expect(JSON.parse((patchCall[1] as RequestInit).body as string)).toEqual( - { document_ids: ["new-doc"] }, - ); + expect(JSON.parse((patchCall[1] as RequestInit).body as string)).toEqual({ + document_ids: ["new-doc"], + }); }); }); @@ -1180,9 +1271,7 @@ describe("tabular cell operations", () => { expect(cell.flag).toBe("green"); const { url, init } = lastFetchCall(); - expect(url).toBe( - "http://localhost:3001/tabular-review/r1/regenerate-cell", - ); + expect(url).toBe("http://localhost:3001/tabular-review/r1/regenerate-cell"); expect(JSON.parse(init.body as string)).toEqual({ row_id: "row-1", column_index: 2, @@ -1277,9 +1366,9 @@ describe("multipart upload endpoints", () => { expect((init.body as FormData).get("filename")).toBe("renamed.pdf"); fetchMock.mockResolvedValue(new Response("nope", { status: 409 })); - await expect( - replaceDocumentVersionFile("d1", "v1", file), - ).rejects.toThrow("nope"); + await expect(replaceDocumentVersionFile("d1", "v1", file)).rejects.toThrow( + "nope", + ); }); }); @@ -1398,9 +1487,7 @@ describe("workflow endpoints", () => { fetchMock.mockResolvedValue(jsonResponse(["w2"])); await expect(listHiddenWorkflows()).resolves.toEqual(["w2"]); - expect(lastFetchCall().url).toBe( - "http://localhost:3001/workflows/hidden", - ); + expect(lastFetchCall().url).toBe("http://localhost:3001/workflows/hidden"); }); }); @@ -1425,7 +1512,8 @@ describe("thin endpoint wrappers", () => { // Account & profile { name: "createProject", - call: () => createProject("Acme v. Zenith", "CM-42", "litigation", ["a@b.c"]), + call: () => + createProject("Acme v. Zenith", "CM-42", "litigation", ["a@b.c"]), url: "/projects", method: "POST", body: { @@ -1628,7 +1716,7 @@ describe("thin endpoint wrappers", () => { { name: "getLibraryFolderChildren", call: () => getLibraryFolderChildren("files", "f1"), - url: "/library/files/folders/f1/children", + url: "/library/files?parent_folder_id=f1", }, { name: "getLibrary with pagination", @@ -1637,9 +1725,8 @@ describe("thin endpoint wrappers", () => { }, { name: "getLibraryFolderChildren with pagination", - call: () => - getLibraryFolderChildren("files", "f1", { limit: 50 }), - url: "/library/files/folders/f1/children?limit=50", + call: () => getLibraryFolderChildren("files", "f1", { limit: 50 }), + url: "/library/files?parent_folder_id=f1&limit=50", }, { name: "renameLibraryFolder", @@ -1804,8 +1891,7 @@ describe("thin endpoint wrappers", () => { }, { name: "shareWorkflow", - call: () => - shareWorkflow("w1", { emails: ["a@b.c"], allow_edit: false }), + call: () => shareWorkflow("w1", { emails: ["a@b.c"], allow_edit: false }), url: "/workflows/w1/share", method: "POST", body: { emails: ["a@b.c"], allow_edit: false }, @@ -1823,7 +1909,9 @@ describe("thin endpoint wrappers", () => { }, ]; - it.each(cases)("$name → $method $url", async ({ call, url, method, body }) => { + it.each(cases)( + "$name → $method $url", + async ({ call, url, method, body }) => { fetchMock.mockResolvedValue(jsonResponse({})); await call(); @@ -1842,7 +1930,8 @@ describe("thin endpoint wrappers", () => { expect(init.headers).toMatchObject({ Authorization: "Bearer token-123", }); - }); + }, + ); }); // --------------------------------------------------------------------------- @@ -1892,9 +1981,7 @@ describe("unwrapping and blob wrappers", () => { ); const chats = await exportChatData(); - expect(lastFetchCall().url).toBe( - "http://localhost:3001/user/chats/export", - ); + expect(lastFetchCall().url).toBe("http://localhost:3001/user/chats/export"); expect(chats.filename).toBe("x.zip"); expect(await chats.blob.text()).toBe("bytes"); diff --git a/frontend/src/app/lib/mikeApi.ts b/frontend/src/app/lib/mikeApi.ts index d8d406b03..bcd645b28 100644 --- a/frontend/src/app/lib/mikeApi.ts +++ b/frontend/src/app/lib/mikeApi.ts @@ -170,12 +170,11 @@ export async function listProjects(options?: { return apiRequest(`/projects${query}`); } -// Paginated sibling of listProjects() used only by ProjectsOverview.tsx. +// Paginated overview sibling of listProjects(), used by ProjectsOverview.tsx. // Deliberately a separate function, not an overload of listProjects — the // backend route decides whether to paginate based on whether any of these // query params are present at all, so listProjects() must keep sending none -// of them (every other caller — the sidebar, the document-picker directory -// view, the tabular-review project pickers — needs the full unpaginated list). +// of them (legacy project pickers still need the full unpaginated list). export async function listProjectsPage(pagination?: { limit?: number; offset?: number; @@ -192,11 +191,13 @@ export async function listProjectsPage(pagination?: { if (pagination?.offset) params.set("offset", String(pagination.offset)); if (pagination?.search) params.set("search", pagination.search); if (pagination?.sortKey) params.set("sort_key", pagination.sortKey); - if (pagination?.sortDirection) params.set("sort_direction", pagination.sortDirection); + if (pagination?.sortDirection) + params.set("sort_direction", pagination.sortDirection); if (pagination?.scope && pagination.scope !== "all") params.set("scope", pagination.scope); if (pagination?.practice) params.set("practice", pagination.practice); - if (pagination?.ownerUserId) params.set("owner_user_id", pagination.ownerUserId); + if (pagination?.ownerUserId) + params.set("owner_user_id", pagination.ownerUserId); const qs = params.toString() ? `?${params.toString()}` : ""; return apiRequest(`/projects${qs}`, { @@ -204,6 +205,67 @@ export async function listProjectsPage(pagination?: { }); } +export async function listProjectSummaries(pagination?: { + limit?: number; + offset?: number; + signal?: AbortSignal; +}): Promise { + const params = new URLSearchParams(); + if (pagination?.limit != null) params.set("limit", String(pagination.limit)); + if (pagination?.offset != null) + params.set("offset", String(pagination.offset)); + params.set("view", "summary"); + return apiRequest(`/projects?${params.toString()}`, { + signal: pagination?.signal, + }); +} + +export interface ProjectDirectoryLevel { + documents: Document[]; + folders: Folder[]; + documentsHasMore: boolean; +} + +export async function getProjectDirectoryLevel( + projectId: string, + options?: { + parentFolderId?: string | null; + limit?: number; + offset?: number; + signal?: AbortSignal; + }, +): Promise { + const params = new URLSearchParams(); + if (options?.parentFolderId) + params.set("parent_folder_id", options.parentFolderId); + if (options?.limit != null) params.set("limit", String(options.limit)); + if (options?.offset != null) params.set("offset", String(options.offset)); + const query = params.toString(); + return apiRequest( + `/projects/${projectId}/directory${query ? `?${query}` : ""}`, + { + signal: options?.signal, + }, + ); +} + +export async function searchProjectDirectory(options: { + search: string; + limit?: number; + offset?: number; + signal?: AbortSignal; +}): Promise { + const params = new URLSearchParams({ + view: "directory-search", + search: options.search, + }); + if (options.limit != null) params.set("limit", String(options.limit)); + if (options.offset != null) params.set("offset", String(options.offset)); + return apiRequest(`/projects?${params}`, { + signal: options.signal, + }); +} + export async function listProjectIds(options?: { search?: string; scope?: "all" | "mine" | "shared"; @@ -213,15 +275,28 @@ export async function listProjectIds(options?: { }): Promise<{ id: string; user_id: string }[]> { const params = new URLSearchParams(); if (options?.search) params.set("search", options.search); - if (options?.scope && options.scope !== "all") params.set("scope", options.scope); + if (options?.scope && options.scope !== "all") + params.set("scope", options.scope); if (options?.practice) params.set("practice", options.practice); if (options?.ownerUserId) params.set("owner_user_id", options.ownerUserId); const qs = params.toString() ? `?${params.toString()}` : ""; - return apiRequest<{ id: string; user_id: string }[]>( - `/projects/ids${qs}`, - { signal: options?.signal }, - ); + return apiRequest<{ id: string; user_id: string }[]>(`/projects/ids${qs}`, { + signal: options?.signal, + }); +} + +export interface ProjectFilterOptions { + practices: string[]; + owners: { value: string; label: string }[]; +} + +export async function getProjectFilterOptions( + signal?: AbortSignal, +): Promise { + return apiRequest("/projects/filter-options", { + signal, + }); } export async function createProject( @@ -414,9 +489,7 @@ export async function listMcpConnectors(): Promise { export async function getMcpConnector( connectorId: string, ): Promise { - return apiRequest( - `/user/mcp-connectors/${connectorId}`, - ); + return apiRequest(`/user/mcp-connectors/${connectorId}`); } export async function createMcpConnector(payload: { @@ -470,10 +543,10 @@ export async function refreshMcpConnectorTools( export async function startMcpConnectorOAuth( connectorId: string, ): Promise<{ authorizationUrl: string | null; alreadyAuthorized: boolean }> { - return apiRequest<{ authorizationUrl: string | null; alreadyAuthorized: boolean }>( - `/user/mcp-connectors/${connectorId}/oauth/start`, - { method: "POST" }, - ); + return apiRequest<{ + authorizationUrl: string | null; + alreadyAuthorized: boolean; + }>(`/user/mcp-connectors/${connectorId}/oauth/start`, { method: "POST" }); } export async function setMcpToolEnabled( @@ -558,14 +631,11 @@ export async function renameProjectFolder( folderId: string, name: string, ): Promise { - return apiRequest( - `/projects/${projectId}/folders/${folderId}`, - { + return apiRequest(`/projects/${projectId}/folders/${folderId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }), - }, - ); + }); } export async function deleteProjectFolder( @@ -582,14 +652,11 @@ export async function moveSubfolderToFolder( folderId: string, parentFolderId: string | null, ): Promise { - return apiRequest( - `/projects/${projectId}/folders/${folderId}`, - { + return apiRequest(`/projects/${projectId}/folders/${folderId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ parent_folder_id: parentFolderId }), - }, - ); + }); } export async function moveDocumentToFolder( @@ -635,6 +702,19 @@ export interface LibraryPagination { offset?: number; } +export interface LibrarySearchParams extends LibraryPagination { + search?: string; + fileType?: string; + sortKey?: "name" | "type" | "size" | "version" | "created" | "updated"; + sortDirection?: "asc" | "desc"; + signal?: AbortSignal; +} + +export interface LibrarySearchResults { + documents: Document[]; + documentsHasMore: boolean; +} + function libraryPaginationQuery(pagination?: LibraryPagination): string { const params = new URLSearchParams(); if (pagination?.limit != null) params.set("limit", String(pagination.limit)); @@ -658,11 +738,81 @@ export async function getLibraryFolderChildren( folderId: string, pagination?: LibraryPagination, ): Promise { + const params = new URLSearchParams({ parent_folder_id: folderId }); + if (pagination?.limit != null) + params.set("limit", String(pagination.limit)); + if (pagination?.offset != null) + params.set("offset", String(pagination.offset)); return apiRequest( - `/library/${kind}/folders/${folderId}/children${libraryPaginationQuery(pagination)}`, + `/library/${kind}?${params.toString()}`, ); } +export async function getLibraryLevels( + kind: LibraryKind, + levels: { parentId: string | null; limit: number }[], +): Promise<{ + levels: Array; +}> { + return apiRequest(`/library/${kind}/levels`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ levels }), + }); +} + +export async function searchLibraryDocuments( + kind: LibraryKind, + options: LibrarySearchParams, +): Promise { + const params = new URLSearchParams({ view: "search" }); + if (options.limit != null) params.set("limit", String(options.limit)); + if (options.offset != null) params.set("offset", String(options.offset)); + if (options.search) params.set("search", options.search); + if (options.fileType) params.set("file_type", options.fileType); + if (options.sortKey) params.set("sort_key", options.sortKey); + if (options.sortDirection) + params.set("sort_direction", options.sortDirection); + return apiRequest( + `/library/${kind}?${params.toString()}`, + { signal: options.signal }, + ); +} + +export async function getLibraryFilterOptions( + kind: LibraryKind, +): Promise<{ fileTypes: string[] }> { + return apiRequest<{ fileTypes: string[] }>(`/library/${kind}/filter-options`); +} + +export async function listLibraryDocumentIds( + kind: LibraryKind, + options?: { search?: string; fileType?: string; signal?: AbortSignal }, +): Promise { + const params = new URLSearchParams(); + if (options?.search) params.set("search", options.search); + if (options?.fileType) params.set("file_type", options.fileType); + const query = params.toString(); + return apiRequest( + `/library/${kind}/ids${query ? `?${query}` : ""}`, + { signal: options?.signal }, + ); +} + +export async function bulkDeleteLibraryDocuments( + kind: LibraryKind, + ids: string[], +): Promise<{ deletedIds: string[] }> { + return apiRequest<{ deletedIds: string[] }>( + `/library/${kind}/documents/bulk-delete`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ids }), + }, + ); +} + export async function uploadLibraryDocument( kind: LibraryKind, file: File, @@ -879,21 +1029,16 @@ export async function uploadProjectDocument( const authHeaders = await getAuthHeader(); const form = new FormData(); form.append("file", file); - const response = await fetch( - `${API_BASE}/projects/${projectId}/documents`, - { + const response = await fetch(`${API_BASE}/projects/${projectId}/documents`, { method: "POST", headers: { ...authHeaders }, body: form, - }, - ); + }); if (!response.ok) throw new Error(await response.text()); return response.json() as Promise; } -export async function uploadStandaloneDocument( - file: File, -): Promise { +export async function uploadStandaloneDocument(file: File): Promise { const authHeaders = await getAuthHeader(); const form = new FormData(); form.append("file", file); @@ -956,9 +1101,13 @@ export async function createChat(payload?: { }); } -export async function listChats(options?: { limit?: number }): Promise { +export async function listChats(options?: { + limit?: number; + offset?: number; +}): Promise { const params = new URLSearchParams(); if (options?.limit) params.set("limit", String(options.limit)); + if (options?.offset) params.set("offset", String(options.offset)); const query = params.toString(); return apiRequest(`/chat${query ? `?${query}` : ""}`); } @@ -1158,7 +1307,8 @@ export async function listTabularReviews( if (pagination?.offset) params.set("offset", String(pagination.offset)); if (pagination?.search) params.set("search", pagination.search); if (pagination?.sortKey) params.set("sort_key", pagination.sortKey); - if (pagination?.sortDirection) params.set("sort_direction", pagination.sortDirection); + if (pagination?.sortDirection) + params.set("sort_direction", pagination.sortDirection); if (pagination?.scope && pagination.scope !== "all") params.set("scope", pagination.scope); @@ -1437,10 +1587,10 @@ export async function clearTabularCells( type WorkflowType = Workflow["metadata"]["type"]; -export async function listWorkflows( - type: WorkflowType, -): Promise { - return apiRequest(`/workflows?type=${type}`); +export async function listWorkflows(type?: WorkflowType): Promise { + return apiRequest( + type ? `/workflows?type=${type}` : "/workflows", + ); } // Paginated sibling of listWorkflows() used only by WorkflowList.tsx. @@ -1470,12 +1620,14 @@ export async function listWorkflowsPage(pagination?: { if (pagination?.offset) params.set("offset", String(pagination.offset)); if (pagination?.search) params.set("search", pagination.search); if (pagination?.sortKey) params.set("sort_key", pagination.sortKey); - if (pagination?.sortDirection) params.set("sort_direction", pagination.sortDirection); + if (pagination?.sortDirection) + params.set("sort_direction", pagination.sortDirection); if (pagination?.scope && pagination.scope !== "all") params.set("scope", pagination.scope); if (pagination?.practice) params.set("practice", pagination.practice); if (pagination?.language) params.set("language", pagination.language); - if (pagination?.jurisdiction) params.set("jurisdiction", pagination.jurisdiction); + if (pagination?.jurisdiction) + params.set("jurisdiction", pagination.jurisdiction); const qs = params.toString() ? `?${params.toString()}` : ""; return apiRequest(`/workflows${qs}`, { @@ -1495,16 +1647,16 @@ export async function listWorkflowIds(options?: { const params = new URLSearchParams(); if (options?.type) params.set("type", options.type); if (options?.search) params.set("search", options.search); - if (options?.scope && options.scope !== "all") params.set("scope", options.scope); + if (options?.scope && options.scope !== "all") + params.set("scope", options.scope); if (options?.practice) params.set("practice", options.practice); if (options?.language) params.set("language", options.language); if (options?.jurisdiction) params.set("jurisdiction", options.jurisdiction); const qs = params.toString() ? `?${params.toString()}` : ""; - return apiRequest<{ id: string; user_id: string }[]>( - `/workflows/ids${qs}`, - { signal: options?.signal }, - ); + return apiRequest<{ id: string; user_id: string }[]>(`/workflows/ids${qs}`, { + signal: options?.signal, + }); } // Always-unpaginated: the static, code-generated system-workflow list (37 @@ -1517,6 +1669,30 @@ export async function listSystemWorkflows( return apiRequest(`/workflows/system${qs}`); } +export interface WorkflowFilterOptions { + practices: string[]; + languages: string[]; + jurisdictions: string[]; +} + +export async function getWorkflowFilterOptions(options?: { + type?: WorkflowType; + scope?: "all" | "owned" | "shared"; + signal?: AbortSignal; +}): Promise { + const params = new URLSearchParams(); + if (options?.type) params.set("type", options.type); + if (options?.scope && options.scope !== "all") + params.set("scope", options.scope); + const query = params.toString(); + return apiRequest( + `/workflows/filter-options${query ? `?${query}` : ""}`, + { + signal: options?.signal, + }, + ); +} + export async function getWorkflow(workflowId: string): Promise { return apiRequest(`/workflows/${workflowId}`); } diff --git a/frontend/src/app/lib/paginatedRows.ts b/frontend/src/app/lib/paginatedRows.ts new file mode 100644 index 000000000..8bbccaf32 --- /dev/null +++ b/frontend/src/app/lib/paginatedRows.ts @@ -0,0 +1,18 @@ +export function splitOverfetchedPage(rows: T[], pageSize: number) { + return { + hasMore: rows.length > pageSize, + rows: rows.slice(0, pageSize), + }; +} + +export function appendUniqueRows( + current: T[], + next: T[], +) { + const existingIds = new Set(current.map((row) => row.id)); + return [...current, ...next.filter((row) => !existingIds.has(row.id))]; +} + +export function paginationError(value: unknown, fallback: string) { + return value instanceof Error ? value : new Error(fallback); +} From 734e6add4afb383f0db05d6df0600f36f595d684 Mon Sep 17 00:00:00 2001 From: willchen96 Date: Wed, 12 Aug 2026 17:43:35 +0800 Subject: [PATCH 6/6] test: cover paginated collection helpers --- frontend/src/app/lib/mikeApi.test.ts | 122 +++++++++++++++++++++ frontend/src/app/lib/paginatedRows.test.ts | 42 +++++++ 2 files changed, 164 insertions(+) create mode 100644 frontend/src/app/lib/paginatedRows.test.ts diff --git a/frontend/src/app/lib/mikeApi.test.ts b/frontend/src/app/lib/mikeApi.test.ts index 186f23fa6..a716ac5ea 100644 --- a/frontend/src/app/lib/mikeApi.test.ts +++ b/frontend/src/app/lib/mikeApi.test.ts @@ -48,11 +48,13 @@ import { getCourtlistenerOpinions, getDocumentUrl, getLibrary, + getLibraryLevels, getLibraryFilterOptions, getLibraryFolderChildren, getMcpConnector, getOllamaModels, getProject, + getProjectDirectoryLevel, getProjectFilterOptions, getProjectPeople, getTabularChatMessages, @@ -67,6 +69,7 @@ import { listChats, listDocumentVersions, listHiddenWorkflows, + listLibraryDocumentIds, listMcpConnectors, listProjectChats, listProjectIds, @@ -99,6 +102,7 @@ import { renameTabularChat, replaceDocumentVersionFile, saveApiKey, + bulkDeleteLibraryDocuments, searchProjectDirectory, searchLibraryDocuments, setMcpToolEnabled, @@ -882,6 +886,40 @@ describe("searchProjectDirectory", () => { }); }); +describe("getProjectDirectoryLevel", () => { + it("serializes a folder level, pagination, and abort signal", async () => { + fetchMock.mockResolvedValue( + jsonResponse({ documents: [], folders: [], documentsHasMore: false }), + ); + const controller = new AbortController(); + + await getProjectDirectoryLevel("p1", { + parentFolderId: "folder-1", + limit: 50, + offset: 100, + signal: controller.signal, + }); + + const { url, init } = lastFetchCall(); + expect(url).toBe( + "http://localhost:3001/projects/p1/directory?parent_folder_id=folder-1&limit=50&offset=100", + ); + expect(init.signal).toBe(controller.signal); + }); + + it("requests the root level without optional query parameters", async () => { + fetchMock.mockResolvedValue( + jsonResponse({ documents: [], folders: [], documentsHasMore: false }), + ); + + await getProjectDirectoryLevel("p1"); + + expect(lastFetchCall().url).toBe( + "http://localhost:3001/projects/p1/directory", + ); + }); +}); + describe("listProjectIds", () => { it("requests the bare id list when no filters are given", async () => { fetchMock.mockResolvedValue(jsonResponse([])); @@ -1096,6 +1134,90 @@ describe("Library search", () => { expect(init.signal).toBe(controller.signal); }); + it("supports a search view with no optional filters", async () => { + fetchMock.mockResolvedValue( + jsonResponse({ documents: [], documentsHasMore: false }), + ); + + await searchLibraryDocuments("files", {}); + + expect(lastFetchCall().url).toBe( + "http://localhost:3001/library/files?view=search", + ); + }); + + it("loads multiple open directory levels in one request", async () => { + fetchMock.mockResolvedValue(jsonResponse({ levels: [] })); + + await getLibraryLevels("templates", [ + { parentId: null, limit: 50 }, + { parentId: "folder-1", limit: 100 }, + ]); + + const { url, init } = lastFetchCall(); + expect(url).toBe("http://localhost:3001/library/templates/levels"); + expect(init.method).toBe("POST"); + expect(JSON.parse(init.body as string)).toEqual({ + levels: [ + { parentId: null, limit: 50 }, + { parentId: "folder-1", limit: 100 }, + ], + }); + }); + + it("loads another page of one Library folder", async () => { + fetchMock.mockResolvedValue( + jsonResponse({ documents: [], folders: [], documentsHasMore: false }), + ); + + await getLibraryFolderChildren("files", "folder-1", { offset: 50 }); + + expect(lastFetchCall().url).toBe( + "http://localhost:3001/library/files?parent_folder_id=folder-1&offset=50", + ); + }); + + it("loads filtered Library IDs and forwards the abort signal", async () => { + fetchMock.mockResolvedValue(jsonResponse(["d1"])); + const controller = new AbortController(); + + await listLibraryDocumentIds("templates", { + search: "agreement", + fileType: "docx", + signal: controller.signal, + }); + + const { url, init } = lastFetchCall(); + expect(url).toBe( + "http://localhost:3001/library/templates/ids?search=agreement&file_type=docx", + ); + expect(init.signal).toBe(controller.signal); + }); + + it("loads all Library IDs without optional filters", async () => { + fetchMock.mockResolvedValue(jsonResponse([])); + + await listLibraryDocumentIds("files"); + + expect(lastFetchCall().url).toBe( + "http://localhost:3001/library/files/ids", + ); + }); + + it("bulk deletes Library documents", async () => { + fetchMock.mockResolvedValue(jsonResponse({ deletedIds: ["d1", "d2"] })); + + const result = await bulkDeleteLibraryDocuments("files", ["d1", "d2"]); + + const { url, init } = lastFetchCall(); + expect(result).toEqual({ deletedIds: ["d1", "d2"] }); + expect(url).toBe( + "http://localhost:3001/library/files/documents/bulk-delete", + ); + expect(init.method).toBe("POST"); + expect(JSON.parse(init.body as string)).toEqual({ ids: ["d1", "d2"] }); + }); + it("loads the complete file-type facet list", async () => { fetchMock.mockResolvedValue(jsonResponse({ fileTypes: ["docx", "pdf"] })); diff --git a/frontend/src/app/lib/paginatedRows.test.ts b/frontend/src/app/lib/paginatedRows.test.ts new file mode 100644 index 000000000..a0490842c --- /dev/null +++ b/frontend/src/app/lib/paginatedRows.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { + appendUniqueRows, + paginationError, + splitOverfetchedPage, +} from "./paginatedRows"; + +describe("paginatedRows", () => { + it("splits an over-fetched page and reports whether another page exists", () => { + expect(splitOverfetchedPage([1, 2, 3], 2)).toEqual({ + rows: [1, 2], + hasMore: true, + }); + expect(splitOverfetchedPage([1, 2], 2)).toEqual({ + rows: [1, 2], + hasMore: false, + }); + }); + + it("appends only rows whose IDs have not already been loaded", () => { + expect( + appendUniqueRows( + [{ id: "one", value: 1 }], + [ + { id: "one", value: 2 }, + { id: "two", value: 2 }, + ], + ), + ).toEqual([ + { id: "one", value: 1 }, + { id: "two", value: 2 }, + ]); + }); + + it("preserves Error instances and wraps non-errors with the fallback", () => { + const original = new Error("network failed"); + expect(paginationError(original, "fallback")).toBe(original); + expect(paginationError("failure", "fallback")).toEqual( + new Error("fallback"), + ); + }); +});