diff --git a/apps/sim/app/api/templates/[id]/route.ts b/apps/sim/app/api/templates/[id]/route.ts index df7f32a85..2432b13ad 100644 --- a/apps/sim/app/api/templates/[id]/route.ts +++ b/apps/sim/app/api/templates/[id]/route.ts @@ -1,9 +1,11 @@ import { eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console/logger' +import { hasAdminPermission } from '@/lib/permissions/utils' import { db } from '@/db' -import { templates } from '@/db/schema' +import { templates, workflow } from '@/db/schema' const logger = createLogger('TemplateByIdAPI') @@ -62,3 +64,153 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } + +const updateTemplateSchema = z.object({ + name: z.string().min(1).max(100), + description: z.string().min(1).max(500), + author: z.string().min(1).max(100), + category: z.string().min(1), + icon: z.string().min(1), + color: z.string().regex(/^#[0-9A-F]{6}$/i), + state: z.any().optional(), // Workflow state +}) + +// PUT /api/templates/[id] - Update a template +export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const requestId = crypto.randomUUID().slice(0, 8) + const { id } = await params + + try { + const session = await getSession() + if (!session?.user?.id) { + logger.warn(`[${requestId}] Unauthorized template update attempt for ID: ${id}`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const body = await request.json() + const validationResult = updateTemplateSchema.safeParse(body) + + if (!validationResult.success) { + logger.warn(`[${requestId}] Invalid template data for update: ${id}`, validationResult.error) + return NextResponse.json( + { error: 'Invalid template data', details: validationResult.error.errors }, + { status: 400 } + ) + } + + const { name, description, author, category, icon, color, state } = validationResult.data + + // Check if template exists + const existingTemplate = await db.select().from(templates).where(eq(templates.id, id)).limit(1) + + if (existingTemplate.length === 0) { + logger.warn(`[${requestId}] Template not found for update: ${id}`) + return NextResponse.json({ error: 'Template not found' }, { status: 404 }) + } + + // Permission: template owner OR admin of the workflow's workspace (if any) + let canUpdate = existingTemplate[0].userId === session.user.id + + if (!canUpdate && existingTemplate[0].workflowId) { + const wfRows = await db + .select({ workspaceId: workflow.workspaceId }) + .from(workflow) + .where(eq(workflow.id, existingTemplate[0].workflowId)) + .limit(1) + + const workspaceId = wfRows[0]?.workspaceId as string | null | undefined + if (workspaceId) { + const hasAdmin = await hasAdminPermission(session.user.id, workspaceId) + if (hasAdmin) canUpdate = true + } + } + + if (!canUpdate) { + logger.warn(`[${requestId}] User denied permission to update template ${id}`) + return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + } + + // Update the template + const updatedTemplate = await db + .update(templates) + .set({ + name, + description, + author, + category, + icon, + color, + ...(state && { state }), + updatedAt: new Date(), + }) + .where(eq(templates.id, id)) + .returning() + + logger.info(`[${requestId}] Successfully updated template: ${id}`) + + return NextResponse.json({ + data: updatedTemplate[0], + message: 'Template updated successfully', + }) + } catch (error: any) { + logger.error(`[${requestId}] Error updating template: ${id}`, error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} + +// DELETE /api/templates/[id] - Delete a template +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const requestId = crypto.randomUUID().slice(0, 8) + const { id } = await params + + try { + const session = await getSession() + if (!session?.user?.id) { + logger.warn(`[${requestId}] Unauthorized template delete attempt for ID: ${id}`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // Fetch template + const existing = await db.select().from(templates).where(eq(templates.id, id)).limit(1) + if (existing.length === 0) { + logger.warn(`[${requestId}] Template not found for delete: ${id}`) + return NextResponse.json({ error: 'Template not found' }, { status: 404 }) + } + + const template = existing[0] + + // Permission: owner or admin of the workflow's workspace (if any) + let canDelete = template.userId === session.user.id + + if (!canDelete && template.workflowId) { + // Look up workflow to get workspaceId + const wfRows = await db + .select({ workspaceId: workflow.workspaceId }) + .from(workflow) + .where(eq(workflow.id, template.workflowId)) + .limit(1) + + const workspaceId = wfRows[0]?.workspaceId as string | null | undefined + if (workspaceId) { + const hasAdmin = await hasAdminPermission(session.user.id, workspaceId) + if (hasAdmin) canDelete = true + } + } + + if (!canDelete) { + logger.warn(`[${requestId}] User denied permission to delete template ${id}`) + return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + } + + await db.delete(templates).where(eq(templates.id, id)) + + logger.info(`[${requestId}] Deleted template: ${id}`) + return NextResponse.json({ success: true }) + } catch (error: any) { + logger.error(`[${requestId}] Error deleting template: ${id}`, error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/templates/route.ts b/apps/sim/app/api/templates/route.ts index 9e84092a8..ac2eb4fce 100644 --- a/apps/sim/app/api/templates/route.ts +++ b/apps/sim/app/api/templates/route.ts @@ -77,6 +77,7 @@ const QueryParamsSchema = z.object({ limit: z.coerce.number().optional().default(50), offset: z.coerce.number().optional().default(0), search: z.string().optional(), + workflowId: z.string().optional(), }) // GET /api/templates - Retrieve templates @@ -111,6 +112,11 @@ export async function GET(request: NextRequest) { ) } + // Apply workflow filter if provided (for getting template by workflow) + if (params.workflowId) { + conditions.push(eq(templates.workflowId, params.workflowId)) + } + // Combine conditions const whereCondition = conditions.length > 0 ? and(...conditions) : undefined diff --git a/apps/sim/app/api/workflows/[id]/route.ts b/apps/sim/app/api/workflows/[id]/route.ts index 0a782dc9b..ba51a784b 100644 --- a/apps/sim/app/api/workflows/[id]/route.ts +++ b/apps/sim/app/api/workflows/[id]/route.ts @@ -8,7 +8,7 @@ import { createLogger } from '@/lib/logs/console/logger' import { getUserEntityPermissions, hasAdminPermission } from '@/lib/permissions/utils' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers' import { db } from '@/db' -import { apiKey as apiKeyTable, workflow } from '@/db/schema' +import { apiKey as apiKeyTable, templates, workflow } from '@/db/schema' const logger = createLogger('WorkflowByIdAPI') @@ -218,6 +218,48 @@ export async function DELETE( return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } + // Check if workflow has published templates before deletion + const { searchParams } = new URL(request.url) + const checkTemplates = searchParams.get('check-templates') === 'true' + const deleteTemplatesParam = searchParams.get('deleteTemplates') + + if (checkTemplates) { + // Return template information for frontend to handle + const publishedTemplates = await db + .select() + .from(templates) + .where(eq(templates.workflowId, workflowId)) + + return NextResponse.json({ + hasPublishedTemplates: publishedTemplates.length > 0, + count: publishedTemplates.length, + publishedTemplates: publishedTemplates.map((t) => ({ + id: t.id, + name: t.name, + views: t.views, + stars: t.stars, + })), + }) + } + + // Handle template deletion based on user choice + if (deleteTemplatesParam !== null) { + const deleteTemplates = deleteTemplatesParam === 'delete' + + if (deleteTemplates) { + // Delete all templates associated with this workflow + await db.delete(templates).where(eq(templates.workflowId, workflowId)) + logger.info(`[${requestId}] Deleted templates for workflow ${workflowId}`) + } else { + // Orphan the templates (set workflowId to null) + await db + .update(templates) + .set({ workflowId: null }) + .where(eq(templates.workflowId, workflowId)) + logger.info(`[${requestId}] Orphaned templates for workflow ${workflowId}`) + } + } + await db.delete(workflow).where(eq(workflow.id, workflowId)) const elapsed = Date.now() - startTime diff --git a/apps/sim/app/api/workspaces/[id]/route.ts b/apps/sim/app/api/workspaces/[id]/route.ts index 99e993410..b5c53bf04 100644 --- a/apps/sim/app/api/workspaces/[id]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/route.ts @@ -1,4 +1,4 @@ -import { and, eq } from 'drizzle-orm' +import { and, eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console/logger' @@ -8,7 +8,7 @@ const logger = createLogger('WorkspaceByIdAPI') import { getUserEntityPermissions } from '@/lib/permissions/utils' import { db } from '@/db' -import { knowledgeBase, permissions, workspace } from '@/db/schema' +import { knowledgeBase, permissions, templates, workspace } from '@/db/schema' export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { const { id } = await params @@ -19,6 +19,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ } const workspaceId = id + const url = new URL(request.url) + const checkTemplates = url.searchParams.get('check-templates') === 'true' // Check if user has any access to this workspace const userPermission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) @@ -26,6 +28,42 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: 'Workspace not found or access denied' }, { status: 404 }) } + // If checking for published templates before deletion + if (checkTemplates) { + try { + // Get all workflows in this workspace + const workspaceWorkflows = await db + .select({ id: workflow.id }) + .from(workflow) + .where(eq(workflow.workspaceId, workspaceId)) + + if (workspaceWorkflows.length === 0) { + return NextResponse.json({ hasPublishedTemplates: false, publishedTemplates: [] }) + } + + const workflowIds = workspaceWorkflows.map((w) => w.id) + + // Check for published templates that reference these workflows + const publishedTemplates = await db + .select({ + id: templates.id, + name: templates.name, + workflowId: templates.workflowId, + }) + .from(templates) + .where(inArray(templates.workflowId, workflowIds)) + + return NextResponse.json({ + hasPublishedTemplates: publishedTemplates.length > 0, + publishedTemplates, + count: publishedTemplates.length, + }) + } catch (error) { + logger.error(`Error checking published templates for workspace ${workspaceId}:`, error) + return NextResponse.json({ error: 'Failed to check published templates' }, { status: 500 }) + } + } + // Get workspace details const workspaceDetails = await db .select() @@ -108,6 +146,8 @@ export async function DELETE( } const workspaceId = id + const body = await request.json().catch(() => ({})) + const { deleteTemplates = false } = body // User's choice: false = keep templates (recommended), true = delete templates // Check if user has admin permissions to delete workspace const userPermission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) @@ -116,10 +156,39 @@ export async function DELETE( } try { - logger.info(`Deleting workspace ${workspaceId} for user ${session.user.id}`) + logger.info( + `Deleting workspace ${workspaceId} for user ${session.user.id}, deleteTemplates: ${deleteTemplates}` + ) // Delete workspace and all related data in a transaction await db.transaction(async (tx) => { + // Get all workflows in this workspace before deletion + const workspaceWorkflows = await tx + .select({ id: workflow.id }) + .from(workflow) + .where(eq(workflow.workspaceId, workspaceId)) + + if (workspaceWorkflows.length > 0) { + const workflowIds = workspaceWorkflows.map((w) => w.id) + + // Handle templates based on user choice + if (deleteTemplates) { + // Delete published templates that reference these workflows + await tx.delete(templates).where(inArray(templates.workflowId, workflowIds)) + logger.info(`Deleted templates for workflows in workspace ${workspaceId}`) + } else { + // Set workflowId to null for templates to create "orphaned" templates + // This allows templates to remain in marketplace but without source workflows + await tx + .update(templates) + .set({ workflowId: null }) + .where(inArray(templates.workflowId, workflowIds)) + logger.info( + `Updated templates to orphaned status for workflows in workspace ${workspaceId}` + ) + } + } + // Delete all workflows in the workspace - database cascade will handle all workflow-related data // The database cascade will handle deleting related workflow_blocks, workflow_edges, workflow_subflows, // workflow_logs, workflow_execution_snapshots, workflow_execution_logs, workflow_execution_trace_spans, diff --git a/apps/sim/app/workspace/[workspaceId]/templates/templates.tsx b/apps/sim/app/workspace/[workspaceId]/templates/templates.tsx index bc01f05f4..02ae4f2a5 100644 --- a/apps/sim/app/workspace/[workspaceId]/templates/templates.tsx +++ b/apps/sim/app/workspace/[workspaceId]/templates/templates.tsx @@ -29,7 +29,7 @@ export type CategoryValue = (typeof categories)[number]['value'] // Template data structure export interface Template { id: string - workflowId: string + workflowId: string | null userId: string name: string description: string | null diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/template-modal/template-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/template-modal/template-modal.tsx index 17a4e289d..60bfaa5ef 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/template-modal/template-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/template-modal/template-modal.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState } from 'react' +import { useEffect, useState } from 'react' import { zodResolver } from '@hookform/resolvers/zod' import { Award, @@ -18,6 +18,7 @@ import { Database, DollarSign, Edit, + Eye, FileText, Folder, Globe, @@ -48,6 +49,16 @@ import { } from 'lucide-react' import { useForm } from 'react-hook-form' import { z } from 'zod' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' import { Button } from '@/components/ui/button' import { ColorPicker } from '@/components/ui/color-picker' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' @@ -68,6 +79,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' +import { Skeleton } from '@/components/ui/skeleton' import { Textarea } from '@/components/ui/textarea' import { useSession } from '@/lib/auth-client' import { createLogger } from '@/lib/logs/console/logger' @@ -100,7 +112,6 @@ interface TemplateModalProps { workflowId: string } -// Enhanced icon selection with category-relevant icons const icons = [ // Content & Documentation { value: 'FileText', label: 'File Text', component: FileText }, @@ -165,6 +176,10 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP const { data: session } = useSession() const [isSubmitting, setIsSubmitting] = useState(false) const [iconPopoverOpen, setIconPopoverOpen] = useState(false) + const [existingTemplate, setExistingTemplate] = useState(null) + const [isLoadingTemplate, setIsLoadingTemplate] = useState(false) + const [showDeleteDialog, setShowDeleteDialog] = useState(false) + const [isDeleting, setIsDeleting] = useState(false) const form = useForm({ resolver: zodResolver(templateSchema), @@ -178,6 +193,63 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP }, }) + // Watch form state to determine if all required fields are valid + const formValues = form.watch() + const isFormValid = + form.formState.isValid && + formValues.name?.trim() && + formValues.description?.trim() && + formValues.author?.trim() && + formValues.category + + // Check for existing template when modal opens + useEffect(() => { + if (open && workflowId) { + checkExistingTemplate() + } + }, [open, workflowId]) + + const checkExistingTemplate = async () => { + setIsLoadingTemplate(true) + try { + const response = await fetch(`/api/templates?workflowId=${workflowId}&limit=1`) + if (response.ok) { + const result = await response.json() + const template = result.data?.[0] || null + setExistingTemplate(template) + + // Pre-fill form with existing template data + if (template) { + form.reset({ + name: template.name, + description: template.description, + author: template.author, + category: template.category, + icon: template.icon, + color: template.color, + }) + } else { + // No existing template found + setExistingTemplate(null) + // Reset form to defaults + form.reset({ + name: '', + description: '', + author: session?.user?.name || session?.user?.email || '', + category: '', + icon: 'FileText', + color: '#3972F6', + }) + } + } + } catch (error) { + logger.error('Error checking existing template:', error) + setExistingTemplate(null) + } finally { + setIsLoadingTemplate(false) + } + } + const onSubmit = async (data: TemplateFormData) => { if (!session?.user) { logger.error('User not authenticated') @@ -201,21 +273,36 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP state: templateState, } - const response = await fetch('/api/templates', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(templateData), - }) + let response + if (existingTemplate) { + // Update existing template + response = await fetch(`/api/templates/${existingTemplate.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(templateData), + }) + } else { + // Create new template + response = await fetch('/api/templates', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(templateData), + }) + } if (!response.ok) { const errorData = await response.json() - throw new Error(errorData.error || 'Failed to create template') + throw new Error( + errorData.error || `Failed to ${existingTemplate ? 'update' : 'create'} template` + ) } const result = await response.json() - logger.info('Template created successfully:', result) + logger.info(`Template ${existingTemplate ? 'updated' : 'created'} successfully:`, result) // Reset form and close modal form.reset() @@ -241,7 +328,35 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP >
- Publish Template +
+ + {isLoadingTemplate + ? 'Loading...' + : existingTemplate + ? 'Update Template' + : 'Publish Template'} + + {existingTemplate && ( +
+ {existingTemplate.stars > 0 && ( +
+ + + {existingTemplate.stars} + +
+ )} + {existingTemplate.views > 0 && ( +
+ + + {existingTemplate.views} + +
+ )} +
+ )} +
+ + +
+
+ {icons.map((icon) => { + const IconComponent = icon.component + return ( + + ) + })} +
+
+
+ + + + )} + /> + + ( + + + Color + + + + + + + )} + /> +
+ ( - - Icon - - - - - -
-
- {icons.map((icon) => { - const IconComponent = icon.component - return ( - - ) - })} -
-
-
-
+ + Name + + + )} /> +
+ ( + + + Author + + + + + + + )} + /> + + ( + + + Category + + + + + )} + /> +
+ ( - - Color + + + Description + - @@ -325,91 +564,28 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP )} /> - - ( - - Name - - - - - - )} - /> - -
- ( - - Author - - - - - - )} - /> - - ( - - Category - - - - )} - /> -
- - ( - - Description - -