mike/backend/src/routes/workflows.ts

780 lines
24 KiB
TypeScript

import { Router, type NextFunction, type Request, type Response } from "express";
import { requireAuth } from "../middleware/auth";
import { createServerSupabase } from "../lib/supabase";
import {
SYSTEM_WORKFLOW_IDS,
SYSTEM_WORKFLOWS,
type SystemWorkflow,
} from "../lib/systemWorkflows";
import { findMissingUserEmails } from "../lib/userLookup";
export const workflowsRouter = Router();
type Db = ReturnType<typeof createServerSupabase>;
const isDev = process.env.NODE_ENV !== "production";
const devLog = (...args: Parameters<typeof console.log>) => {
if (isDev) console.log(...args);
};
type WorkflowRecord = {
id: string;
user_id: string | null;
is_system?: boolean;
title?: string;
type?: string;
prompt_md?: string | null;
columns_config?: unknown;
language?: string | null;
version?: string | null;
practice?: string | null;
jurisdictions?: string[] | null;
created_at?: string;
[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 = {
id: string;
workflow_id: string;
submitted_by_user_id: string;
submitter_email: string | null;
submitter_name: string | null;
contributor_mode?: "named" | "anonymous";
status: OpenSourceSubmissionStatus;
snapshot: unknown;
submitted_at: string;
updated_at: string;
reviewed_at?: string | null;
review_notes?: string | null;
};
type OpenSourceSubmissionSummary = Pick<
OpenSourceSubmissionRow,
"id" | "status" | "submitted_at" | "updated_at"
> & {
reviewed_at?: string | null;
};
const DEFAULT_WORKFLOW_CONTRIBUTOR: WorkflowContributor = {
name: "Mike",
organisation: null,
role: null,
linkedin: null,
};
const DEFAULT_WORKFLOW_LANGUAGE = "English";
const DEFAULT_WORKFLOW_PRACTICE = "General Transactions";
const DEFAULT_WORKFLOW_JURISDICTIONS = ["General"];
const WORKFLOW_CONTRIBUTIONS_ENABLED =
process.env.WORKFLOW_CONTRIBUTIONS_ENABLED === "true";
type WorkflowAccess =
| {
workflow: WorkflowRecord;
allowEdit: boolean;
isOwner: boolean;
}
| null;
type AsyncRoute = (req: Request, res: Response) => Promise<unknown>;
function asyncRoute(handler: AsyncRoute) {
return (req: Request, res: Response, next: NextFunction) => {
void handler(req, res).catch(next);
};
}
function withWorkflowAccess<T extends object>(
workflow: T,
access: { allowEdit: boolean; isOwner: boolean; sharedByName?: string | null },
) {
return {
...workflow,
allow_edit: access.allowEdit,
is_owner: access.isOwner,
shared_by_name: access.sharedByName ?? null,
};
}
function withOpenSourceSubmission<T extends object>(
workflow: T,
submission: OpenSourceSubmissionSummary | null,
) {
return {
...workflow,
open_source_submission: submission,
};
}
function withSystemWorkflowAccess(workflow: SystemWorkflow) {
return withWorkflowAccess(workflow, {
allowEdit: false,
isOwner: false,
});
}
function workflowTypeFrom(value: unknown): WorkflowType {
return value === "tabular" ? "tabular" : "assistant";
}
function metadataFromWorkflowRecord(workflow: WorkflowRecord): WorkflowMetadata {
return {
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,
};
}
function normalizeOptionalString(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed || null;
}
function normalizeJurisdictions(value: unknown): string[] | null {
if (!Array.isArray(value)) return null;
const items = value
.map((item) => normalizeOptionalString(item))
.filter((item): item is string => !!item);
return items.length > 0 ? Array.from(new Set(items)) : null;
}
function normalizeContributors(value: unknown): WorkflowContributor[] | null {
if (!Array.isArray(value)) return null;
const contributors = value
.map((item): WorkflowContributor | null => {
if (!item || typeof item !== "object" || Array.isArray(item)) return null;
const record = item as Record<string, unknown>;
const name = normalizeOptionalString(record.name);
if (!name) return null;
return {
name,
organisation: normalizeOptionalString(record.organisation),
role: normalizeOptionalString(record.role),
linkedin: normalizeOptionalString(record.linkedin),
};
})
.filter((item): item is WorkflowContributor => !!item);
return contributors.length ? contributors : null;
}
function contributorFromName(name: unknown): WorkflowContributor {
return {
...DEFAULT_WORKFLOW_CONTRIBUTOR,
name: normalizeOptionalString(name) ?? DEFAULT_WORKFLOW_CONTRIBUTOR.name,
};
}
async function resolveWorkflowAccess(
workflowId: string,
userId: string,
userEmail: string | null | undefined,
db: Db,
): Promise<WorkflowAccess> {
const { data: workflow } = await db
.from("workflows")
.select("*")
.eq("id", workflowId)
.single();
if (!workflow) return null;
const workflowRecord = workflow as WorkflowRecord;
if (workflowRecord.user_id === userId) {
return { workflow: workflowRecord, allowEdit: true, isOwner: true };
}
const normalizedUserEmail = (userEmail ?? "").trim().toLowerCase();
if (!normalizedUserEmail) return null;
const { data: share } = await db
.from("workflow_shares")
.select("allow_edit")
.eq("workflow_id", workflowId)
.eq("shared_with_email", normalizedUserEmail)
.maybeSingle();
if (!share) return null;
return { workflow: workflowRecord, allowEdit: !!share.allow_edit, isOwner: false };
}
function toOpenSourceSubmissionSummary(
row: OpenSourceSubmissionRow,
): OpenSourceSubmissionSummary {
return {
id: row.id,
status: row.status,
submitted_at: row.submitted_at,
updated_at: row.updated_at,
reviewed_at: row.reviewed_at ?? null,
};
}
async function getLatestOpenSourceSubmission(
db: Db,
workflowId: string,
userId: string,
): Promise<OpenSourceSubmissionSummary | null> {
const { data, error } = await db
.from("workflow_open_source_submissions")
.select("id, status, submitted_at, updated_at, reviewed_at")
.eq("workflow_id", workflowId)
.eq("submitted_by_user_id", userId)
.order("submitted_at", { ascending: false })
.limit(1)
.maybeSingle();
if (error) throw error;
return data ? toOpenSourceSubmissionSummary(data as OpenSourceSubmissionRow) : null;
}
function buildOpenSourceSnapshot(
workflow: WorkflowRecord,
contributors: WorkflowContributor[],
contributorMode: "named" | "anonymous",
) {
return {
workflow_id: workflow.id,
metadata: {
...metadataFromWorkflowRecord(workflow),
contributors,
},
skill_md: workflow.prompt_md ?? null,
columns_config: workflow.columns_config ?? null,
contributor_mode: contributorMode,
created_at: workflow.created_at ?? null,
};
}
function validateOpenSourceWorkflow(workflow: WorkflowRecord): string | null {
if (workflow.type === "assistant") {
return typeof workflow.prompt_md === "string" && workflow.prompt_md.trim()
? 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
? null
: "Tabular workflows need at least one column before they can be opened source.";
}
return "Workflow type must be 'assistant' or 'tabular'.";
}
// GET /workflows
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 workflowType = typeof type === "string" && type ? type : null;
const { data, error } = await db.rpc("get_workflows_overview", {
p_user_id: userId,
p_user_email: userEmail ?? null,
p_type: workflowType,
});
if (error) {
return void res.status(500).json({ detail: error.message });
}
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);
res.json([...systemWorkflows, ...databaseWorkflows]);
}));
// POST /workflows
workflowsRouter.post("/", requireAuth, asyncRoute(async (req, res) => {
const userId = res.locals.userId as string;
const {
metadata,
skill_md,
columns_config,
} = req.body as {
metadata?: Partial<WorkflowMetadata>;
skill_md?: string;
columns_config?: unknown;
};
const title = metadata?.title;
const type = metadata?.type;
if (!title?.trim())
return void res.status(400).json({ detail: "metadata.title is required" });
if (type !== "assistant" && type !== "tabular")
return void res
.status(400)
.json({ detail: "metadata.type must be 'assistant' or 'tabular'" });
const db = createServerSupabase();
devLog("[workflows/create] request", {
userId,
title: title.trim(),
type,
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,
practice: metadata?.practice ?? null,
jurisdictions:
normalizeJurisdictions(metadata?.jurisdictions) ??
DEFAULT_WORKFLOW_JURISDICTIONS,
});
const { data, error } = await db
.from("workflows")
.insert({
user_id: userId,
title: title.trim(),
type,
prompt_md: skill_md ?? null,
columns_config: columns_config ?? null,
language:
normalizeOptionalString(metadata?.language) ?? DEFAULT_WORKFLOW_LANGUAGE,
practice:
normalizeOptionalString(metadata?.practice) ?? DEFAULT_WORKFLOW_PRACTICE,
jurisdictions:
normalizeJurisdictions(metadata?.jurisdictions) ??
DEFAULT_WORKFLOW_JURISDICTIONS,
})
.select("*")
.single();
if (error) {
devLog("[workflows/create] insert error", {
userId,
title: title.trim(),
type,
code: error.code,
message: error.message,
details: error.details,
hint: error.hint,
});
return void res.status(500).json({ detail: error.message });
}
devLog("[workflows/create] inserted", {
id: data?.id,
user_id: data?.user_id,
title: data?.title,
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;
const userEmail = res.locals.userEmail as string | undefined;
const { workflowId } = req.params;
const updates: Record<string, unknown> = {};
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 (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);
if (!access || !access.allowEdit) {
return void res
.status(404)
.json({ detail: "Workflow not found or not editable" });
}
const { data, error } = await db
.from("workflows")
.update(updates)
.eq("id", workflowId)
.select("*")
.single();
if (error || !data)
return void res
.status(404)
.json({ detail: "Workflow not found or not editable" });
res.json(
withWorkflowAccess(withDatabaseWorkflow(data as WorkflowRecord), {
allowEdit: access.allowEdit,
isOwner: access.isOwner,
}),
);
}
// PUT /workflows/:workflowId
workflowsRouter.put("/:workflowId", requireAuth, asyncRoute(handleWorkflowUpdate));
// PATCH /workflows/:workflowId
workflowsRouter.patch("/:workflowId", requireAuth, asyncRoute(handleWorkflowUpdate));
// DELETE /workflows/:workflowId
workflowsRouter.delete("/:workflowId", requireAuth, asyncRoute(async (req, res) => {
const userId = res.locals.userId as string;
const { workflowId } = req.params;
const systemWorkflow = SYSTEM_WORKFLOWS.find(
(workflow) => workflow.id === workflowId,
);
if (systemWorkflow) {
return void res.json(withSystemWorkflowAccess(systemWorkflow));
}
const db = createServerSupabase();
const { error } = await db
.from("workflows")
.delete()
.eq("id", workflowId)
.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) => {
const userId = res.locals.userId as string;
const db = createServerSupabase();
const { data, error } = await db
.from("hidden_workflows")
.select("workflow_id")
.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) => {
const userId = res.locals.userId as string;
const { workflow_id } = req.body as { workflow_id: string };
if (!workflow_id?.trim())
return void res.status(400).json({ detail: "workflow_id is required" });
const db = createServerSupabase();
const { error } = await db
.from("hidden_workflows")
.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) => {
const userId = res.locals.userId as string;
const { workflowId } = req.params;
const db = createServerSupabase();
const { error } = await db
.from("hidden_workflows")
.delete()
.eq("user_id", userId)
.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) => {
if (!WORKFLOW_CONTRIBUTIONS_ENABLED) {
return void res.status(404).json({ detail: "Workflow contributions are disabled" });
}
const userId = res.locals.userId as string;
const userEmail = res.locals.userEmail as string | undefined;
const { workflowId } = req.params;
const openSourceBody = req.body as {
contributor_mode?: unknown;
contributor?: unknown;
};
const requestedContributorMode =
openSourceBody.contributor_mode === "named"
? "named"
: "anonymous";
const db = createServerSupabase();
const { data: workflow, error: workflowError } = await db
.from("workflows")
.select("*")
.eq("id", workflowId)
.eq("user_id", userId)
.maybeSingle();
if (workflowError) {
return void res.status(500).json({ detail: workflowError.message });
}
if (!workflow) {
return void res
.status(404)
.json({ detail: "Workflow not found or not open-sourceable" });
}
const workflowRecord = workflow as WorkflowRecord;
const validationError = validateOpenSourceWorkflow(workflowRecord);
if (validationError) {
return void res.status(400).json({ detail: validationError });
}
const { data: profile } = await db
.from("user_profiles")
.select("display_name")
.eq("user_id", userId)
.maybeSingle();
const submitterName =
typeof profile?.display_name === "string" && profile.display_name.trim()
? profile.display_name.trim()
: null;
const submittedContributor =
normalizeContributors([openSourceBody.contributor])?.[0] ??
contributorFromName(submitterName || userEmail);
const publicContributors =
requestedContributorMode === "named"
? [submittedContributor]
: [DEFAULT_WORKFLOW_CONTRIBUTOR];
const now = new Date().toISOString();
const snapshot = buildOpenSourceSnapshot(
workflowRecord,
publicContributors,
requestedContributorMode,
);
const { data: pendingSubmission, error: pendingError } = await db
.from("workflow_open_source_submissions")
.select("*")
.eq("workflow_id", workflowId)
.eq("submitted_by_user_id", userId)
.eq("status", "pending")
.maybeSingle();
if (pendingError) {
return void res.status(500).json({ detail: pendingError.message });
}
if (pendingSubmission) {
const { data: updated, error: updateError } = await db
.from("workflow_open_source_submissions")
.update({
submitter_email: userEmail ?? null,
submitter_name:
requestedContributorMode === "named" ? submitterName : null,
contributor_mode: requestedContributorMode,
snapshot,
updated_at: now,
})
.eq("id", pendingSubmission.id)
.select("id, status, submitted_at, updated_at, reviewed_at")
.single();
if (updateError || !updated) {
return void res.status(500).json({
detail: updateError?.message ?? "Failed to update submission",
});
}
return void res.json({
...toOpenSourceSubmissionSummary(updated as OpenSourceSubmissionRow),
mode: "updated",
});
}
const { data: created, error: createError } = await db
.from("workflow_open_source_submissions")
.insert({
workflow_id: workflowId,
submitted_by_user_id: userId,
submitter_email: userEmail ?? null,
submitter_name:
requestedContributorMode === "named" ? submitterName : null,
contributor_mode: requestedContributorMode,
status: "pending",
snapshot,
submitted_at: now,
updated_at: now,
})
.select("id, status, submitted_at, updated_at, reviewed_at")
.single();
if (createError || !created) {
return void res.status(500).json({
detail: createError?.message ?? "Failed to create submission",
});
}
res.status(201).json({
...toOpenSourceSubmissionSummary(created as OpenSourceSubmissionRow),
mode: "created",
});
}));
// GET /workflows/:workflowId
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;
const systemWorkflow = SYSTEM_WORKFLOWS.find(
(workflow) => workflow.id === workflowId,
);
if (systemWorkflow) {
return void res.json(withSystemWorkflowAccess(systemWorkflow));
}
const db = createServerSupabase();
const access = await resolveWorkflowAccess(workflowId, userId, userEmail, db);
if (!access)
return void res.status(404).json({ detail: "Workflow not found" });
const openSourceSubmission = access.isOwner
? await getLatestOpenSourceSubmission(db, workflowId, userId)
: null;
res.json(
withOpenSourceSubmission(
withWorkflowAccess(withDatabaseWorkflow(access.workflow), {
allowEdit: access.allowEdit,
isOwner: access.isOwner,
}),
openSourceSubmission,
),
);
}));
// GET /workflows/:workflowId/shares
workflowsRouter.get("/:workflowId/shares", requireAuth, asyncRoute(async (req, res) => {
const userId = res.locals.userId as string;
const { workflowId } = req.params;
const db = createServerSupabase();
const { data: wf } = await db
.from("workflows")
.select("id")
.eq("id", workflowId)
.eq("user_id", userId)
.single();
if (!wf) return void res.status(404).json({ detail: "Workflow not found or not editable" });
const { data: shares, error } = await db
.from("workflow_shares")
.select("id, shared_with_email, allow_edit, created_at")
.eq("workflow_id", workflowId)
.order("created_at", { ascending: true });
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) => {
const userId = res.locals.userId as string;
const { workflowId, shareId } = req.params;
const db = createServerSupabase();
const { data: wf } = await db
.from("workflows")
.select("id")
.eq("id", workflowId)
.eq("user_id", userId)
.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);
res.status(204).send();
}));
// POST /workflows/:workflowId/share
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 };
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),
),
];
if (normalizedEmails.length === 0) {
return void res.status(400).json({ detail: "emails is required" });
}
const normalizedUserEmail = userEmail?.trim().toLowerCase();
if (normalizedUserEmail && normalizedEmails.includes(normalizedUserEmail)) {
return void res
.status(400)
.json({ detail: "You cannot share a workflow with yourself." });
}
const db = createServerSupabase();
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.`,
});
}
// Verify ownership
const { data: wf } = await db
.from("workflows")
.select("id")
.eq("id", workflowId)
.eq("user_id", userId)
.single();
if (!wf) return void res.status(404).json({ detail: "Workflow not found or not editable" });
const rows = normalizedEmails.map((email: string) => ({
workflow_id: workflowId,
shared_by_user_id: userId,
shared_with_email: email,
allow_edit: allow_edit ?? false,
}));
// Upsert on (workflow_id, shared_with_email) so re-sharing to the same
// person updates the existing row instead of stacking duplicates.
const { error } = await db
.from("workflow_shares")
.upsert(rows, { onConflict: "workflow_id,shared_with_email" });
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) => {
if (res.headersSent) return next(err);
console.error("[workflows] unhandled route error", err);
res.status(500).json({ detail: "Failed to process workflow request" });
},
);