diff --git a/apps/rowboat/app/actions/shared-workflow.actions.ts b/apps/rowboat/app/actions/shared-workflow.actions.ts new file mode 100644 index 00000000..94123ebc --- /dev/null +++ b/apps/rowboat/app/actions/shared-workflow.actions.ts @@ -0,0 +1,68 @@ +"use server"; + +import { z } from "zod"; +import { nanoid } from "nanoid"; +import { Workflow } from "@/app/lib/types/workflow_types"; +import { db } from "@/app/lib/mongodb"; +import { SHARED_WORKFLOWS_COLLECTION } from "@/src/infrastructure/repositories/mongodb.shared-workflows.indexes"; + +const DEFAULT_TTL_SECONDS = 60 * 60 * 24; // 24 hours + +interface SharedWorkflowDoc { + _id: string; + workflow: unknown; + createdAt: Date; + expiresAt: Date; +} + +function validateWorkflowJson(obj: unknown) { + const parsed = Workflow.safeParse(obj); + if (!parsed.success) { + const message = parsed.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('; '); + throw new Error(`Invalid workflow JSON: ${message}`); + } + return parsed.data; +} + +export async function createSharedWorkflowFromJson(json: string): Promise<{ id: string; ttlSeconds: number; }> +{ + const obj = JSON.parse(json); + const workflow = validateWorkflowJson(obj); + + const coll = db.collection(SHARED_WORKFLOWS_COLLECTION); + const id = nanoid(); + const now = new Date(); + const expiresAt = new Date(now.getTime() + DEFAULT_TTL_SECONDS * 1000); + await coll.insertOne({ _id: id, workflow, createdAt: now, expiresAt }); + + return { id, ttlSeconds: DEFAULT_TTL_SECONDS }; +} + +export async function loadSharedWorkflow(idOrUrl: string): Promise> { + // If it's an http(s) URL, fetch JSON and validate + const isHttp = idOrUrl.startsWith('http://') || idOrUrl.startsWith('https://'); + if (isHttp) { + const resp = await fetch(idOrUrl, { cache: 'no-store' }); + if (!resp.ok) { + throw new Error(`Failed to fetch URL: ${resp.status} ${resp.statusText}`); + } + const text = await resp.text(); + const obj = JSON.parse(text); + return validateWorkflowJson(obj); + } + + // Otherwise, look up by shared id in MongoDB + const coll = db.collection(SHARED_WORKFLOWS_COLLECTION); + const doc = await coll.findOne( + { _id: idOrUrl }, + { projection: { workflow: 1, expiresAt: 1 } } + ); + if (!doc) { + throw new Error('Not found or expired'); + } + if (doc.expiresAt && doc.expiresAt.getTime() <= Date.now()) { + throw new Error('Not found or expired'); + } + return validateWorkflowJson(doc.workflow); +} + diff --git a/apps/rowboat/app/api/shared-workflow/route.ts b/apps/rowboat/app/api/shared-workflow/route.ts deleted file mode 100644 index 1adb0744..00000000 --- a/apps/rowboat/app/api/shared-workflow/route.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { z } from 'zod'; -import { Workflow } from '@/app/lib/types/workflow_types'; -import { nanoid } from 'nanoid'; -import { db } from '@/app/lib/mongodb'; -import { SHARED_WORKFLOWS_COLLECTION } from '@/src/infrastructure/repositories/mongodb.shared-workflows.indexes'; - -const DEFAULT_TTL_SECONDS = 60 * 60 * 24; // 24 hours - -interface SharedWorkflowDoc { - _id: string; - workflow: unknown; - createdAt: Date; - expiresAt: Date; -} - -function validateWorkflowJson(obj: unknown) { - const parsed = Workflow.safeParse(obj); - if (!parsed.success) { - const message = parsed.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('; '); - throw new Error(`Invalid workflow JSON: ${message}`); - } - return parsed.data; -} - -export async function GET(req: NextRequest) { - try { - const { searchParams } = new URL(req.url); - const id = searchParams.get('id'); - const url = searchParams.get('url'); - - if (id) { - const coll = db.collection(SHARED_WORKFLOWS_COLLECTION); - const doc = await coll.findOne( - { _id: id }, - { projection: { workflow: 1, expiresAt: 1 } } - ); - if (!doc) { - return NextResponse.json({ error: 'Not found or expired' }, { status: 404 }); - } - // Optional safeguard if TTL not yet cleaned up - if (doc.expiresAt && doc.expiresAt.getTime() <= Date.now()) { - return NextResponse.json({ error: 'Not found or expired' }, { status: 404 }); - } - return NextResponse.json(doc.workflow); - } - - if (!url) { - return NextResponse.json({ error: 'Missing "id" or "url" query param' }, { status: 400 }); - } - - if (url.startsWith('blob:')) { - return NextResponse.json({ error: 'Blob URLs are not accessible from the server. Use POST /api/shared-workflow to upload the workflow and share its id.' }, { status: 400 }); - } - - const isHttp = url.startsWith('http://') || url.startsWith('https://'); - if (!isHttp) { - return NextResponse.json({ error: 'Only http(s) URLs are supported in the "url" param' }, { status: 400 }); - } - - const resp = await fetch(url, { cache: 'no-store' }); - if (!resp.ok) { - return NextResponse.json({ error: `Failed to fetch URL: ${resp.status} ${resp.statusText}` }, { status: 400 }); - } - const text = await resp.text(); - const obj = JSON.parse(text); - const workflow = validateWorkflowJson(obj); - return NextResponse.json(workflow); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return NextResponse.json({ error: message }, { status: 400 }); - } -} - -export async function POST(req: NextRequest) { - try { - const contentType = req.headers.get('content-type') || ''; - let body: any; - if (contentType.includes('application/json')) { - body = await req.json(); - } else { - const text = await req.text(); - body = JSON.parse(text); - } - - const workflowCandidate = typeof body?.workflow === 'object' ? body.workflow : body; - const workflow = validateWorkflowJson(workflowCandidate); - const id = nanoid(); - const coll = db.collection(SHARED_WORKFLOWS_COLLECTION); - const now = new Date(); - const expiresAt = new Date(now.getTime() + DEFAULT_TTL_SECONDS * 1000); - await coll.insertOne({ _id: id, workflow, createdAt: now, expiresAt }); - - const origin = new URL(req.url).origin; - const href = `${origin}/api/shared-workflow?id=${id}`; - return NextResponse.json({ id, href, ttlSeconds: DEFAULT_TTL_SECONDS }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return NextResponse.json({ error: message }, { status: 400 }); - } -} diff --git a/apps/rowboat/app/projects/[projectId]/workflow/workflow_editor.tsx b/apps/rowboat/app/projects/[projectId]/workflow/workflow_editor.tsx index 01a8ef00..2f49ee8e 100644 --- a/apps/rowboat/app/projects/[projectId]/workflow/workflow_editor.tsx +++ b/apps/rowboat/app/projects/[projectId]/workflow/workflow_editor.tsx @@ -10,6 +10,7 @@ import { PipelineConfig } from "../entities/pipeline_config"; import { ToolConfig } from "../entities/tool_config"; import { App as ChatApp } from "../playground/app"; import { z } from "zod"; +import { createSharedWorkflowFromJson } from '@/app/actions/shared-workflow.actions'; import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Spinner, Tooltip, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, useDisclosure } from "@heroui/react"; import { PromptConfig } from "../entities/prompt_config"; import { DataSourceConfig } from "../entities/datasource_config"; @@ -1603,22 +1604,13 @@ export function WorkflowEditor({ document.body.removeChild(a); } - // Share: upload JSON to server to get a share ID and reveal copy button + // Share: create a shared workflow via server action to get an ID and reveal copy button const [shareUrl, setShareUrl] = useState(null); async function handleShareWorkflow() { try { // POST to server to create a share token const json = buildWorkflowExportJson(); - const resp = await fetch('/api/shared-workflow', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: json, - }); - if (!resp.ok) { - console.error('Failed to create share link'); - return; - } - const data = await resp.json(); + const data = await createSharedWorkflowFromJson(json); const createUrl = `${window.location.origin}/projects?shared=${encodeURIComponent(data.id)}`; setShareUrl(createUrl); } catch (e) { diff --git a/apps/rowboat/app/projects/components/build-assistant-section.tsx b/apps/rowboat/app/projects/components/build-assistant-section.tsx index 24ebc527..325da36c 100644 --- a/apps/rowboat/app/projects/components/build-assistant-section.tsx +++ b/apps/rowboat/app/projects/components/build-assistant-section.tsx @@ -11,6 +11,7 @@ import { Button } from "@/components/ui/button"; import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; import { TextareaWithSend } from "@/app/components/ui/textarea-with-send"; import { Workflow } from '../../lib/types/workflow_types'; +import { loadSharedWorkflow } from '@/app/actions/shared-workflow.actions'; import { PictureImg } from '@/components/ui/picture-img'; import { Tabs, Tab } from "@/components/ui/tabs"; import { Project } from "@/src/entities/models/project"; @@ -306,13 +307,7 @@ export function BuildAssistantSection() { if (sharedId || importUrl) { try { setAutoCreateLoading(true); - const qs = sharedId ? `id=${encodeURIComponent(sharedId)}` : `url=${encodeURIComponent(importUrl!)}`; - const resp = await fetch(`/api/shared-workflow?${qs}`, { cache: 'no-store' }); - if (!resp.ok) { - const data = await resp.json().catch(() => ({})); - throw new Error(data.error || `Failed to load shared workflow (${resp.status})`); - } - const workflowObj = await resp.json(); + const workflowObj = await loadSharedWorkflow(sharedId || importUrl!); await createProjectFromJsonWithOptions({ workflowJson: JSON.stringify(workflowObj), router, diff --git a/apps/rowboat/app/projects/components/create-project.tsx b/apps/rowboat/app/projects/components/create-project.tsx index ec82c2bf..fb6b755c 100644 --- a/apps/rowboat/app/projects/components/create-project.tsx +++ b/apps/rowboat/app/projects/components/create-project.tsx @@ -12,6 +12,7 @@ import { HorizontalDivider } from "@/components/ui/horizontal-divider"; import { Tooltip } from "@heroui/react"; import { BillingUpgradeModal } from "@/components/common/billing-upgrade-modal"; import { Workflow } from '@/app/lib/types/workflow_types'; +import { loadSharedWorkflow } from '@/app/actions/shared-workflow.actions'; import { Modal } from '@/components/ui/modal'; import { Upload, Send, X } from "lucide-react"; @@ -172,14 +173,8 @@ export function CreateProject({ defaultName, onOpenProjectPane, isProjectPaneOpe setAutoCreateLoading(true); try { if (sharedId || importUrl) { - // Fetch workflow JSON via our API route - const qs = sharedId ? `id=${encodeURIComponent(sharedId)}` : `url=${encodeURIComponent(importUrl!)}`; - const resp = await fetch(`/api/shared-workflow?${qs}`, { cache: 'no-store' }); - if (!resp.ok) { - const data = await resp.json().catch(() => ({})); - throw new Error(data.error || `Failed to load shared workflow (${resp.status})`); - } - const workflowObj = await resp.json(); + // Load workflow via server action (supports id or URL) + const workflowObj = await loadSharedWorkflow(sharedId || importUrl!); await createProjectFromJsonWithOptions({ workflowJson: JSON.stringify(workflowObj), router,