mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-24 20:28:16 +02:00
moved workflow sharing from api to server side actions
This commit is contained in:
parent
62c1230cff
commit
c2a0dd87bd
5 changed files with 76 additions and 127 deletions
68
apps/rowboat/app/actions/shared-workflow.actions.ts
Normal file
68
apps/rowboat/app/actions/shared-workflow.actions.ts
Normal file
|
|
@ -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<SharedWorkflowDoc>(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<z.infer<typeof Workflow>> {
|
||||||
|
// 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<SharedWorkflowDoc>(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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<SharedWorkflowDoc>(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<SharedWorkflowDoc>(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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { PipelineConfig } from "../entities/pipeline_config";
|
||||||
import { ToolConfig } from "../entities/tool_config";
|
import { ToolConfig } from "../entities/tool_config";
|
||||||
import { App as ChatApp } from "../playground/app";
|
import { App as ChatApp } from "../playground/app";
|
||||||
import { z } from "zod";
|
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 { Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Spinner, Tooltip, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, useDisclosure } from "@heroui/react";
|
||||||
import { PromptConfig } from "../entities/prompt_config";
|
import { PromptConfig } from "../entities/prompt_config";
|
||||||
import { DataSourceConfig } from "../entities/datasource_config";
|
import { DataSourceConfig } from "../entities/datasource_config";
|
||||||
|
|
@ -1603,22 +1604,13 @@ export function WorkflowEditor({
|
||||||
document.body.removeChild(a);
|
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<string | null>(null);
|
const [shareUrl, setShareUrl] = useState<string | null>(null);
|
||||||
async function handleShareWorkflow() {
|
async function handleShareWorkflow() {
|
||||||
try {
|
try {
|
||||||
// POST to server to create a share token
|
// POST to server to create a share token
|
||||||
const json = buildWorkflowExportJson();
|
const json = buildWorkflowExportJson();
|
||||||
const resp = await fetch('/api/shared-workflow', {
|
const data = await createSharedWorkflowFromJson(json);
|
||||||
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 createUrl = `${window.location.origin}/projects?shared=${encodeURIComponent(data.id)}`;
|
const createUrl = `${window.location.origin}/projects?shared=${encodeURIComponent(data.id)}`;
|
||||||
setShareUrl(createUrl);
|
setShareUrl(createUrl);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { Button } from "@/components/ui/button";
|
||||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||||
import { TextareaWithSend } from "@/app/components/ui/textarea-with-send";
|
import { TextareaWithSend } from "@/app/components/ui/textarea-with-send";
|
||||||
import { Workflow } from '../../lib/types/workflow_types';
|
import { Workflow } from '../../lib/types/workflow_types';
|
||||||
|
import { loadSharedWorkflow } from '@/app/actions/shared-workflow.actions';
|
||||||
import { PictureImg } from '@/components/ui/picture-img';
|
import { PictureImg } from '@/components/ui/picture-img';
|
||||||
import { Tabs, Tab } from "@/components/ui/tabs";
|
import { Tabs, Tab } from "@/components/ui/tabs";
|
||||||
import { Project } from "@/src/entities/models/project";
|
import { Project } from "@/src/entities/models/project";
|
||||||
|
|
@ -160,13 +161,7 @@ export function BuildAssistantSection() {
|
||||||
if (sharedId || importUrl) {
|
if (sharedId || importUrl) {
|
||||||
try {
|
try {
|
||||||
setAutoCreateLoading(true);
|
setAutoCreateLoading(true);
|
||||||
const qs = sharedId ? `id=${encodeURIComponent(sharedId)}` : `url=${encodeURIComponent(importUrl!)}`;
|
const workflowObj = await loadSharedWorkflow(sharedId || 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();
|
|
||||||
await createProjectFromJsonWithOptions({
|
await createProjectFromJsonWithOptions({
|
||||||
workflowJson: JSON.stringify(workflowObj),
|
workflowJson: JSON.stringify(workflowObj),
|
||||||
router,
|
router,
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { HorizontalDivider } from "@/components/ui/horizontal-divider";
|
||||||
import { Tooltip } from "@heroui/react";
|
import { Tooltip } from "@heroui/react";
|
||||||
import { BillingUpgradeModal } from "@/components/common/billing-upgrade-modal";
|
import { BillingUpgradeModal } from "@/components/common/billing-upgrade-modal";
|
||||||
import { Workflow } from '@/app/lib/types/workflow_types';
|
import { Workflow } from '@/app/lib/types/workflow_types';
|
||||||
|
import { loadSharedWorkflow } from '@/app/actions/shared-workflow.actions';
|
||||||
import { Modal } from '@/components/ui/modal';
|
import { Modal } from '@/components/ui/modal';
|
||||||
import { Upload, Send, X } from "lucide-react";
|
import { Upload, Send, X } from "lucide-react";
|
||||||
|
|
||||||
|
|
@ -172,14 +173,8 @@ export function CreateProject({ defaultName, onOpenProjectPane, isProjectPaneOpe
|
||||||
setAutoCreateLoading(true);
|
setAutoCreateLoading(true);
|
||||||
try {
|
try {
|
||||||
if (sharedId || importUrl) {
|
if (sharedId || importUrl) {
|
||||||
// Fetch workflow JSON via our API route
|
// Load workflow via server action (supports id or URL)
|
||||||
const qs = sharedId ? `id=${encodeURIComponent(sharedId)}` : `url=${encodeURIComponent(importUrl!)}`;
|
const workflowObj = await loadSharedWorkflow(sharedId || 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();
|
|
||||||
await createProjectFromJsonWithOptions({
|
await createProjectFromJsonWithOptions({
|
||||||
workflowJson: JSON.stringify(workflowObj),
|
workflowJson: JSON.stringify(workflowObj),
|
||||||
router,
|
router,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue