Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions backend/migrations/20260724_02_tabular_folder_rows.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
-- Support one tabular-review row per project folder, with multiple source documents.
alter table public.tabular_reviews
add column if not exists document_grouping text not null default 'document'
check (document_grouping in ('document', 'folder'));

create table if not exists public.tabular_review_rows (
id uuid primary key default gen_random_uuid(),
review_id uuid not null references public.tabular_reviews(id) on delete cascade,
label text not null,
row_type text not null check (row_type in ('document', 'folder')),
folder_id uuid references public.project_subfolders(id) on delete set null,
document_id uuid references public.documents(id) on delete set null,
sort_index integer not null default 0,
created_at timestamptz not null default now()
);
create index if not exists idx_tabular_review_rows_review
on public.tabular_review_rows(review_id, sort_index);
alter table public.tabular_review_rows enable row level security;

create table if not exists public.tabular_review_row_sources (
row_id uuid not null references public.tabular_review_rows(id) on delete cascade,
document_id uuid not null references public.documents(id) on delete cascade,
sort_index integer not null default 0,
created_at timestamptz not null default now(),
primary key (row_id, document_id)
);
create index if not exists idx_tabular_review_row_sources_document
on public.tabular_review_row_sources(document_id);
alter table public.tabular_review_row_sources enable row level security;

alter table public.tabular_cells
add column if not exists row_id uuid references public.tabular_review_rows(id) on delete cascade;
alter table public.tabular_cells
alter column document_id drop not null;
create index if not exists idx_tabular_cells_review_row
on public.tabular_cells(review_id, row_id, column_index);

-- Preserve every existing document row when upgrading an active database.
insert into public.tabular_review_rows (review_id, label, row_type, document_id, sort_index)
select distinct on (cell.review_id, cell.document_id)
cell.review_id,
coalesce(document.filename, 'Untitled document'),
'document',
cell.document_id,
row_number() over (partition by cell.review_id order by cell.document_id) - 1
from public.tabular_cells cell
join public.documents document on document.id = cell.document_id
where cell.row_id is null
and not exists (
select 1 from public.tabular_review_rows row
where row.review_id = cell.review_id and row.document_id = cell.document_id
);

insert into public.tabular_review_row_sources (row_id, document_id)
select row.id, row.document_id
from public.tabular_review_rows row
where row.document_id is not null
on conflict (row_id, document_id) do nothing;

update public.tabular_cells cell
set row_id = row.id
from public.tabular_review_rows row
where cell.row_id is null
and row.review_id = cell.review_id
and row.document_id = cell.document_id;

revoke all on public.tabular_review_rows from anon, authenticated;
revoke all on public.tabular_review_row_sources from anon, authenticated;
38 changes: 37 additions & 1 deletion backend/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,7 @@ create table if not exists public.tabular_reviews (
document_ids jsonb,
workflow_id uuid references public.workflows(id) on delete set null,
practice text,
document_grouping text not null default 'document' check (document_grouping in ('document', 'folder')),
shared_with jsonb not null default '[]'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
Expand Down Expand Up @@ -680,10 +681,40 @@ as $$
order by vp.created_at desc;
$$;

create table if not exists public.tabular_cells (
create table if not exists public.tabular_review_rows (
id uuid primary key default gen_random_uuid(),
review_id uuid not null references public.tabular_reviews(id) on delete cascade,
label text not null,
row_type text not null check (row_type in ('document', 'folder')),
folder_id uuid references public.project_subfolders(id) on delete set null,
document_id uuid references public.documents(id) on delete set null,
sort_index integer not null default 0,
created_at timestamptz not null default now()
);

create index if not exists idx_tabular_review_rows_review
on public.tabular_review_rows(review_id, sort_index);

alter table public.tabular_review_rows enable row level security;

create table if not exists public.tabular_review_row_sources (
row_id uuid not null references public.tabular_review_rows(id) on delete cascade,
document_id uuid not null references public.documents(id) on delete cascade,
sort_index integer not null default 0,
created_at timestamptz not null default now(),
primary key (row_id, document_id)
);

create index if not exists idx_tabular_review_row_sources_document
on public.tabular_review_row_sources(document_id);

alter table public.tabular_review_row_sources enable row level security;

create table if not exists public.tabular_cells (
id uuid primary key default gen_random_uuid(),
review_id uuid not null references public.tabular_reviews(id) on delete cascade,
row_id uuid references public.tabular_review_rows(id) on delete cascade,
document_id uuid references public.documents(id) on delete cascade,
column_index integer not null,
content text,
citations jsonb,
Expand All @@ -694,6 +725,9 @@ create table if not exists public.tabular_cells (
create index if not exists idx_tabular_cells_review
on public.tabular_cells(review_id, document_id, column_index);

create index if not exists idx_tabular_cells_review_row
on public.tabular_cells(review_id, row_id, column_index);

create or replace function public.get_tabular_reviews_overview(
p_user_id text,
p_user_email text default null,
Expand Down Expand Up @@ -875,6 +909,8 @@ revoke all on public.chats from anon, authenticated;
revoke all on public.chat_messages from anon, authenticated;
revoke all on public.tabular_reviews from anon, authenticated;
revoke all on public.tabular_cells from anon, authenticated;
revoke all on public.tabular_review_rows from anon, authenticated;
revoke all on public.tabular_review_row_sources from anon, authenticated;
revoke all on public.tabular_review_chats from anon, authenticated;
revoke all on public.tabular_review_chat_messages from anon, authenticated;
revoke all on public.user_api_keys from anon, authenticated;
Expand Down
127 changes: 126 additions & 1 deletion backend/src/__tests__/integration/tabular.routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,31 @@ describe("tabular.routes", () => {
data: { id: "r9", title: "Gamma", document_ids: ["d1"] },
error: null,
};
supabaseState.tables.documents = {
data: [
{
id: "d1",
filename: "Agreement.pdf",
file_type: "pdf",
folder_id: null,
},
],
error: null,
};
supabaseState.tables.tabular_review_rows = {
data: [
{
id: "row-1",
review_id: "r9",
label: "Agreement.pdf",
row_type: "document",
folder_id: null,
document_id: "d1",
sort_index: 0,
},
],
error: null,
};
// d2 is not accessible — it must be filtered out of the insert.
filterAccessibleDocumentIds.mockResolvedValue(["d1"]);

Expand All @@ -199,20 +224,120 @@ describe("tabular.routes", () => {
expect(reviewInsert?.payload).toMatchObject({
document_ids: ["d1"],
});
// Cells are created for accessible docs × columns only (1 × 1).
// Cells are created for accessible review rows × columns only (1 × 1).
const cellInsert = supabaseState.inserts.find(
(i) => i.table === "tabular_cells",
);
expect(cellInsert?.payload).toEqual([
{
review_id: "r9",
row_id: "row-1",
document_id: "d1",
column_index: 0,
status: "pending",
},
]);
});

it("groups project-folder documents into one review row", async () => {
supabaseState.tables.tabular_reviews = {
data: { id: "r10", title: "Grouped", document_ids: ["d1", "d2", "d3"] },
error: null,
};
supabaseState.tables.documents = {
data: [
{ id: "d1", filename: "A.pdf", file_type: "pdf", folder_id: "f1" },
{ id: "d2", filename: "B.pdf", file_type: "pdf", folder_id: "f1" },
{ id: "d3", filename: "Loose.pdf", file_type: "pdf", folder_id: null },
],
error: null,
};
supabaseState.tables.project_subfolders = {
data: [{ id: "f1", name: "Contracts", parent_folder_id: null }],
error: null,
};
supabaseState.tables.tabular_review_rows = {
data: [
{
id: "row-folder",
review_id: "r10",
label: "Contracts",
row_type: "folder",
folder_id: "f1",
document_id: null,
sort_index: 0,
},
{
id: "row-document",
review_id: "r10",
label: "Loose.pdf",
row_type: "document",
folder_id: null,
document_id: "d3",
sort_index: 1,
},
],
error: null,
};

const res = await request(app)
.post("/tabular-review")
.set(...AUTH)
.send({
title: "Grouped",
project_id: "p1",
document_ids: ["d1", "d2", "d3"],
document_grouping: "folder",
columns_config: [{ index: 0, name: "Col", prompt: "p" }],
});

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([
{
review_id: "r10",
label: "Contracts",
row_type: "folder",
folder_id: "f1",
document_id: null,
sort_index: 0,
},
{
review_id: "r10",
label: "Loose.pdf",
row_type: "document",
folder_id: null,
document_id: "d3",
sort_index: 1,
},
]);
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([
{
review_id: "r10",
row_id: "row-folder",
document_id: null,
column_index: 0,
status: "pending",
},
{
review_id: "r10",
row_id: "row-document",
document_id: "d3",
column_index: 0,
status: "pending",
},
]);
});

it("returns 404 when project access is denied", async () => {
checkProjectAccess.mockResolvedValue({ ok: false });

Expand Down
Loading