mirror of
https://github.com/willchen96/mike.git
synced 2026-07-24 23:41:04 +02:00
feat: workflow, UI and document support updates
This commit is contained in:
parent
a5fe6d6e04
commit
82dcaefc43
139 changed files with 12554 additions and 2233 deletions
|
|
@ -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=="],
|
||||
|
|
|
|||
|
|
@ -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')),
|
||||
|
|
|
|||
41
backend/migrations/20260703_01_user_profile_email.sql
Normal file
41
backend/migrations/20260703_01_user_profile_email.sql
Normal 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;
|
||||
$$;
|
||||
84
backend/migrations/20260703_02_project_practice.sql
Normal file
84
backend/migrations/20260703_02_project_practice.sql
Normal 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;
|
||||
$$;
|
||||
19
backend/migrations/20260704_01_chat_message_citations.sql
Normal file
19
backend/migrations/20260704_01_chat_message_citations.sql
Normal 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 $$;
|
||||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
113
backend/src/lib/userLookup.ts
Normal file
113
backend/src/lib/userLookup.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
}),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue