feat: workflow, UI and document support updates

This commit is contained in:
willchen96 2026-07-08 18:27:28 +08:00
parent a5fe6d6e04
commit 82dcaefc43
139 changed files with 12554 additions and 2233 deletions

View file

@ -25,6 +25,7 @@
"multer": "^1.4.5-lts.2",
"pdfjs-dist": "^4.10.38",
"resend": "^4.5.1",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"zod": "^3.25.76",
},
"devDependencies": {
@ -769,6 +770,8 @@
"ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
"xlsx": ["xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", { "bin": { "xlsx": "./bin/xlsx.njs" } }],
"xml": ["xml@1.0.1", "", {}, "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw=="],
"xml-js": ["xml-js@1.6.11", "", { "dependencies": { "sax": "^1.2.4" }, "bin": "bin/cli.js" }, "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g=="],

View file

@ -15,7 +15,6 @@ create table if not exists public.workflow_open_source_submissions (
submitted_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
reviewed_at timestamptz,
reviewed_by_user_id text,
review_notes text,
constraint workflow_open_source_submissions_status_check
check (status in ('pending', 'approved', 'rejected')),

View file

@ -0,0 +1,41 @@
-- Mirror auth.users.email into user_profiles so backend sharing checks can
-- resolve one email without scanning Supabase Auth users.
alter table public.user_profiles
add column if not exists email text;
update public.user_profiles up
set email = lower(au.email)
from auth.users au
where up.user_id = au.id
and au.email is not null
and (
up.email is null
or up.email <> lower(au.email)
);
create unique index if not exists user_profiles_email_lower_unique
on public.user_profiles (lower(email))
where email is not null and btrim(email) <> '';
create index if not exists idx_user_profiles_email
on public.user_profiles(email);
create or replace function public.handle_new_user()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
begin
insert into public.user_profiles (user_id, email)
values (new.id, lower(new.email))
on conflict (user_id) do update
set email = excluded.email,
updated_at = now();
return new;
exception when others then
-- Never block signup if the profile insert fails.
return new;
end;
$$;

View file

@ -0,0 +1,84 @@
-- Add optional practice metadata to projects and expose it in the overview RPC.
alter table public.projects
add column if not exists practice text;
drop function if exists public.get_projects_overview(text, text);
create or replace function public.get_projects_overview(
p_user_id text,
p_user_email text default null
)
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)
)
),
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 vp.created_at desc;
$$;

View file

@ -0,0 +1,19 @@
do $$
begin
if exists (
select 1
from information_schema.columns
where table_schema = 'public'
and table_name = 'chat_messages'
and column_name = 'annotations'
) and not exists (
select 1
from information_schema.columns
where table_schema = 'public'
and table_name = 'chat_messages'
and column_name = 'citations'
) then
alter table public.chat_messages
rename column annotations to citations;
end if;
end $$;

View file

@ -4,7 +4,6 @@
"private": true,
"scripts": {
"dev": "tsx watch src/index.ts",
"prebuild": "node ../scripts/build-workflows.js",
"build": "tsc",
"start": "node dist/index.js"
},

View file

@ -539,7 +539,7 @@ export async function buildWorkflowStore(
// Seed system workflows first.
for (const wf of SYSTEM_ASSISTANT_WORKFLOWS) {
store.set(wf.id, { title: wf.title, prompt_md: wf.prompt_md });
store.set(wf.id, { title: wf.title, skill_md: wf.skill_md });
}
// Then overlay user-owned assistant workflows.
@ -550,7 +550,7 @@ export async function buildWorkflowStore(
.eq("type", "assistant");
for (const wf of workflows ?? []) {
if (wf.prompt_md) {
store.set(wf.id, { title: wf.title, prompt_md: wf.prompt_md });
store.set(wf.id, { title: wf.title, skill_md: wf.prompt_md });
}
}
@ -573,7 +573,7 @@ export async function buildWorkflowStore(
if (wf.prompt_md) {
store.set(wf.id, {
title: wf.title,
prompt_md: wf.prompt_md,
skill_md: wf.prompt_md,
});
}
}

View file

@ -32,6 +32,7 @@ Citation rules:
- Keep quotes short, ideally 25 words or fewer, and tightly matched to the claim.
- "page" means the sequential [Page N] marker in the provided text, not printed page numbers inside the document. Non-spreadsheet unpaginated files may have no [Page N] markers; omit "page" (or use 1) when none is present.
- For spreadsheet sources (content shown as "## Sheet: <name>" markdown tables with a "Row" column and column-letter headers), cite by cell instead of page: set "sheet" to the sheet name and "cell" to the A1 address or range you are quoting (e.g. "B7" or "B7:C9", combining the column-letter header with the "Row" number). Put the plain cell value in "quote" with no "Row"/column-letter labels or "|" separators. Omit "page" for spreadsheet citations.
- A cell tagged "⟨merged A1:C1⟩" spans that whole range: its value belongs to the anchor cell and the other covered cells are shown blank. When citing anything in a merged range, set "cell" to the full range from the tag (e.g. "A1:C1"), not a covered cell like "B1". Do not include the "⟨merged ...⟩" tag text in "quote".
- For a continuous quote crossing two pages, set "page" to "N-M" and include [[PAGE_BREAK]] at the page break. Otherwise, use separate quote objects.
- For legacy compatibility, you may also include top-level "page" and "quote" matching the first quote.
- Omit the <CITATIONS> block when there are no citations.

View file

@ -777,7 +777,7 @@ export async function runToolCalls(
toolResults.push({
role: "tool",
tool_call_id: tc.id,
content: wf ? wf.prompt_md : `Workflow '${wfId}' not found.`,
content: wf ? wf.skill_md : `Workflow '${wfId}' not found.`,
});
} else if (tc.function.name === "read_table_cells" && tabularStore) {
const colIndices = args.col_indices as number[] | undefined;

View file

@ -23,7 +23,7 @@ export type DocStore = Map<
{ storage_path: string; file_type: string; filename: string }
>;
export type WorkflowStore = Map<string, { title: string; prompt_md: string }>;
export type WorkflowStore = Map<string, { title: string; skill_md: string }>;
export type DocIndex = Record<
string,

View file

@ -33,6 +33,16 @@ function renderSheet(sheetName: string, ws: XLSX.WorkSheet): string | null {
if (!ref) return null;
const range = XLSX.utils.decode_range(ref);
// Map each merged range's top-left (anchor) address to its encoded range so we
// can tag the anchor inline (e.g. `Amount ⟨merged B2:C2⟩`). The covered cells
// stay blank, so the model never reads a covered address (e.g. B1 inside
// A1:C1) as its own value; the tag tells it the anchor spans that range, and
// to cite the whole range for anything in it.
const mergeAnchors = new Map<string, string>();
for (const m of ws["!merges"] ?? []) {
mergeAnchors.set(XLSX.utils.encode_cell(m.s), XLSX.utils.encode_range(m));
}
// Build a trimmed grid: capture formatted text for every cell in the used
// range, then drop trailing empty columns and fully empty rows so we don't
// emit oceans of blank cells.
@ -44,7 +54,13 @@ function renderSheet(sheetName: string, ws: XLSX.WorkSheet): string | null {
let rowHasContent = false;
for (let c = range.s.c; c <= range.e.c; c++) {
const addr = XLSX.utils.encode_cell({ r, c });
const text = sanitizeCellText(cellDisplayText(ws[addr]));
let text = sanitizeCellText(cellDisplayText(ws[addr]));
const mergeRange = mergeAnchors.get(addr);
if (mergeRange) {
text = text
? `${text} ⟨merged ${mergeRange}`
: `⟨merged ${mergeRange}`;
}
cells[c - range.s.c] = text;
if (text) {
rowHasContent = true;
@ -72,13 +88,6 @@ function renderSheet(sheetName: string, ws: XLSX.WorkSheet): string | null {
const lines = [`## Sheet: ${sheetName}`, "", headerRow, separator, ...bodyRows];
// Note merged ranges once so the model understands spanned headers/labels.
const merges = ws["!merges"];
if (merges && merges.length > 0) {
const encoded = merges.map((m) => XLSX.utils.encode_range(m));
lines.push("", `Merged ranges: ${encoded.join(", ")}`);
}
return lines.join("\n");
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,113 @@
import type { SupabaseClient } from "@supabase/supabase-js";
type Db = SupabaseClient<any, "public", any>;
export type ProfileUserInfo = {
id: string;
email: string;
display_name: string | null;
};
export function normalizeEmail(value: unknown) {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
export function normalizeDisplayName(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
export async function loadProfileUsersByEmail(db: Db) {
const { data, error } = await db
.from("user_profiles")
.select("user_id, email, display_name")
.not("email", "is", null);
if (error) throw error;
const userByEmail = new Map<string, ProfileUserInfo>();
const userById = new Map<string, ProfileUserInfo>();
for (const row of data ?? []) {
const email = normalizeEmail(row.email);
if (!email) continue;
const info = {
id: row.user_id as string,
email,
display_name: normalizeDisplayName(row.display_name),
};
userByEmail.set(email, info);
userById.set(info.id, info);
}
return { userByEmail, userById };
}
export async function findProfileUserByEmail(db: Db, email: string) {
const normalized = normalizeEmail(email);
if (!normalized) return null;
const { data, error } = await db
.from("user_profiles")
.select("user_id, email, display_name")
.eq("email", normalized)
.maybeSingle();
if (error) throw error;
if (!data) return null;
return {
id: data.user_id as string,
email: normalized,
display_name: normalizeDisplayName(data.display_name),
};
}
export async function findMissingUserEmails(db: Db, emails: string[]) {
const normalizedEmails = [...new Set(emails.map(normalizeEmail).filter(Boolean))];
if (normalizedEmails.length === 0) return [];
const { data, error } = await db
.from("user_profiles")
.select("email")
.in("email", normalizedEmails);
if (error) throw error;
const found = new Set(
(data ?? [])
.map((row) => normalizeEmail(row.email))
.filter(Boolean),
);
return normalizedEmails.filter((email) => !found.has(email));
}
export async function syncProfileEmail(
db: Db,
userId: string,
email: string | null | undefined,
) {
const normalizedEmail = normalizeEmail(email);
if (!userId || !normalizedEmail) return null;
const { data: existing, error: loadError } = await db
.from("user_profiles")
.select("email")
.eq("user_id", userId)
.maybeSingle();
if (loadError) return loadError;
if (!existing) {
const { error } = await db.from("user_profiles").insert({
user_id: userId,
email: normalizedEmail,
});
return error;
}
if (normalizeEmail(existing.email) === normalizedEmail) return null;
const { error } = await db
.from("user_profiles")
.update({
email: normalizedEmail,
updated_at: new Date().toISOString(),
})
.eq("user_id", userId);
return error;
}

View file

@ -32,12 +32,25 @@ type WorkflowRecord = {
[key: string]: unknown;
};
type WorkflowType = "assistant" | "tabular";
type WorkflowContributor = {
name: string;
organisation: string | null;
role: string | null;
linkedin: string | null;
};
type WorkflowMetadata = {
title: string;
description: string | null;
type: WorkflowType;
contributors: WorkflowContributor[];
language: string;
version: string | null;
practice: string | null;
jurisdictions: string[] | null;
};
type OpenSourceSubmissionStatus = "pending" | "approved" | "rejected";
type OpenSourceSubmissionRow = {
@ -52,7 +65,6 @@ type OpenSourceSubmissionRow = {
submitted_at: string;
updated_at: string;
reviewed_at?: string | null;
reviewed_by_user_id?: string | null;
review_notes?: string | null;
};
@ -120,9 +132,42 @@ function withSystemWorkflowAccess(workflow: SystemWorkflow) {
});
}
function withDatabaseWorkflow<T extends object>(workflow: T) {
function workflowTypeFrom(value: unknown): WorkflowType {
return value === "tabular" ? "tabular" : "assistant";
}
function metadataFromWorkflowRecord(workflow: WorkflowRecord): WorkflowMetadata {
return {
...workflow,
title: workflow.title ?? "",
description: null,
type: workflowTypeFrom(workflow.type),
contributors:
normalizeContributors(workflow.contributors) ?? [
DEFAULT_WORKFLOW_CONTRIBUTOR,
],
language: workflow.language ?? DEFAULT_WORKFLOW_LANGUAGE,
version: workflow.version ?? null,
practice: workflow.practice ?? DEFAULT_WORKFLOW_PRACTICE,
jurisdictions: workflow.jurisdictions ?? DEFAULT_WORKFLOW_JURISDICTIONS,
};
}
function withDatabaseWorkflow(workflow: WorkflowRecord) {
const {
title: _title,
type: _type,
contributors: _contributors,
language: _language,
version: _version,
practice: _practice,
jurisdictions: _jurisdictions,
prompt_md,
...rest
} = workflow;
return {
...rest,
metadata: metadataFromWorkflowRecord(workflow),
skill_md: prompt_md ?? null,
is_system: false,
};
}
@ -179,7 +224,7 @@ async function resolveWorkflowAccess(
.eq("id", workflowId)
.single();
if (!workflow) return null;
const workflowRecord = withDatabaseWorkflow(workflow as WorkflowRecord);
const workflowRecord = workflow as WorkflowRecord;
if (workflowRecord.user_id === userId) {
return { workflow: workflowRecord, allowEdit: true, isOwner: true };
}
@ -234,16 +279,13 @@ function buildOpenSourceSnapshot(
) {
return {
workflow_id: workflow.id,
title: workflow.title ?? "",
type: workflow.type ?? "",
prompt_md: workflow.prompt_md ?? null,
metadata: {
...metadataFromWorkflowRecord(workflow),
contributors,
},
skill_md: workflow.prompt_md ?? null,
columns_config: workflow.columns_config ?? null,
contributors,
contributor_mode: contributorMode,
language: workflow.language ?? DEFAULT_WORKFLOW_LANGUAGE,
version: workflow.version ?? null,
practice: workflow.practice ?? DEFAULT_WORKFLOW_PRACTICE,
jurisdictions: workflow.jurisdictions ?? DEFAULT_WORKFLOW_JURISDICTIONS,
created_at: workflow.created_at ?? null,
};
}
@ -280,11 +322,11 @@ workflowsRouter.get("/", requireAuth, asyncRoute(async (req, res) => {
}
const systemWorkflows = SYSTEM_WORKFLOWS.filter(
(workflow) => !workflowType || workflow.type === workflowType,
(workflow) => !workflowType || workflow.metadata.type === workflowType,
).map(withSystemWorkflowAccess);
const databaseWorkflows = ((data ?? []) as WorkflowRecord[]).filter(
(workflow) => !SYSTEM_WORKFLOW_IDS.has(workflow.id),
);
).map(withDatabaseWorkflow);
res.json([...systemWorkflows, ...databaseWorkflows]);
}));
@ -293,40 +335,36 @@ workflowsRouter.get("/", requireAuth, asyncRoute(async (req, res) => {
workflowsRouter.post("/", requireAuth, asyncRoute(async (req, res) => {
const userId = res.locals.userId as string;
const {
title,
type,
prompt_md,
metadata,
skill_md,
columns_config,
language,
practice,
jurisdictions,
} = req.body as {
title: string;
type: string;
prompt_md?: string;
metadata?: Partial<WorkflowMetadata>;
skill_md?: string;
columns_config?: unknown;
language?: unknown;
practice?: string | null;
jurisdictions?: unknown;
};
const title = metadata?.title;
const type = metadata?.type;
if (!title?.trim())
return void res.status(400).json({ detail: "title is required" });
if (!["assistant", "tabular"].includes(type))
return void res.status(400).json({ detail: "metadata.title is required" });
if (type !== "assistant" && type !== "tabular")
return void res
.status(400)
.json({ detail: "type must be 'assistant' or 'tabular'" });
.json({ detail: "metadata.type must be 'assistant' or 'tabular'" });
const db = createServerSupabase();
devLog("[workflows/create] request", {
userId,
title: title.trim(),
type,
hasPrompt: typeof prompt_md === "string" && prompt_md.length > 0,
hasSkill: typeof skill_md === "string" && skill_md.length > 0,
columnCount: Array.isArray(columns_config) ? columns_config.length : null,
language: normalizeOptionalString(language) ?? DEFAULT_WORKFLOW_LANGUAGE,
practice: practice ?? null,
language:
normalizeOptionalString(metadata?.language) ?? DEFAULT_WORKFLOW_LANGUAGE,
practice: metadata?.practice ?? null,
jurisdictions:
normalizeJurisdictions(jurisdictions) ?? DEFAULT_WORKFLOW_JURISDICTIONS,
normalizeJurisdictions(metadata?.jurisdictions) ??
DEFAULT_WORKFLOW_JURISDICTIONS,
});
const { data, error } = await db
.from("workflows")
@ -334,13 +372,15 @@ workflowsRouter.post("/", requireAuth, asyncRoute(async (req, res) => {
user_id: userId,
title: title.trim(),
type,
prompt_md: prompt_md ?? null,
prompt_md: skill_md ?? null,
columns_config: columns_config ?? null,
language: normalizeOptionalString(language) ?? DEFAULT_WORKFLOW_LANGUAGE,
language:
normalizeOptionalString(metadata?.language) ?? DEFAULT_WORKFLOW_LANGUAGE,
practice:
normalizeOptionalString(practice) ?? DEFAULT_WORKFLOW_PRACTICE,
normalizeOptionalString(metadata?.practice) ?? DEFAULT_WORKFLOW_PRACTICE,
jurisdictions:
normalizeJurisdictions(jurisdictions) ?? DEFAULT_WORKFLOW_JURISDICTIONS,
normalizeJurisdictions(metadata?.jurisdictions) ??
DEFAULT_WORKFLOW_JURISDICTIONS,
})
.select("*")
.single();
@ -362,7 +402,7 @@ workflowsRouter.post("/", requireAuth, asyncRoute(async (req, res) => {
title: data?.title,
type: data?.type,
});
res.status(201).json(withDatabaseWorkflow(data));
res.status(201).json(withDatabaseWorkflow(data as WorkflowRecord));
}));
async function handleWorkflowUpdate(req: Request, res: Response) {
@ -370,15 +410,17 @@ async function handleWorkflowUpdate(req: Request, res: Response) {
const userEmail = res.locals.userEmail as string | undefined;
const { workflowId } = req.params;
const updates: Record<string, unknown> = {};
if (req.body.title != null) updates.title = req.body.title;
if (req.body.prompt_md != null) updates.prompt_md = req.body.prompt_md;
const metadata = req.body.metadata as Partial<WorkflowMetadata> | undefined;
if (metadata?.title != null) updates.title = metadata.title;
if (req.body.skill_md != null) updates.prompt_md = req.body.skill_md;
if (req.body.columns_config != null)
updates.columns_config = req.body.columns_config;
if ("language" in req.body)
updates.language = normalizeOptionalString(req.body.language);
if ("practice" in req.body) updates.practice = req.body.practice ?? null;
if ("jurisdictions" in req.body)
updates.jurisdictions = normalizeJurisdictions(req.body.jurisdictions);
if (metadata && "language" in metadata)
updates.language = normalizeOptionalString(metadata.language);
if (metadata && "practice" in metadata)
updates.practice = metadata.practice ?? null;
if (metadata && "jurisdictions" in metadata)
updates.jurisdictions = normalizeJurisdictions(metadata.jurisdictions);
const db = createServerSupabase();
const access = await resolveWorkflowAccess(workflowId, userId, userEmail, db);
@ -398,7 +440,7 @@ async function handleWorkflowUpdate(req: Request, res: Response) {
.status(404)
.json({ detail: "Workflow not found or not editable" });
res.json(
withWorkflowAccess(withDatabaseWorkflow(data), {
withWorkflowAccess(withDatabaseWorkflow(data as WorkflowRecord), {
allowEdit: access.allowEdit,
isOwner: access.isOwner,
}),
@ -506,7 +548,7 @@ workflowsRouter.post("/:workflowId/open-source", requireAuth, asyncRoute(async (
.json({ detail: "Workflow not found or not open-sourceable" });
}
const workflowRecord = withDatabaseWorkflow(workflow as WorkflowRecord);
const workflowRecord = workflow as WorkflowRecord;
const validationError = validateOpenSourceWorkflow(workflowRecord);
if (validationError) {
return void res.status(400).json({ detail: validationError });
@ -620,7 +662,7 @@ workflowsRouter.get("/:workflowId", requireAuth, asyncRoute(async (req, res) =>
: null;
res.json(
withOpenSourceSubmission(
withWorkflowAccess(access.workflow, {
withWorkflowAccess(withDatabaseWorkflow(access.workflow), {
allowEdit: access.allowEdit,
isOwner: access.isOwner,
}),