Skip to content

Commit 53ee9f9

Browse files
authored
fix(templates): added option to delete/keep templates when deleting workspace, updated template modal, sidebar code cleanup (#1086)
* feat(templates): added in the ability to keep/remove templates when deleting workspace * code cleanup in sidebar * add the ability to edit existing templates * updated template modal * fix build * revert bun.lock * add template logic to workflow deletion as well * add ability to delete templates * add owner/admin enforcemnet to modify or delete templates
1 parent 0f2a125 commit 53ee9f9

File tree

19 files changed

+7281
-499
lines changed

19 files changed

+7281
-499
lines changed

apps/sim/app/api/templates/[id]/route.ts

Lines changed: 153 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { eq, sql } from 'drizzle-orm'
22
import { type NextRequest, NextResponse } from 'next/server'
3+
import { z } from 'zod'
34
import { getSession } from '@/lib/auth'
45
import { createLogger } from '@/lib/logs/console/logger'
6+
import { hasAdminPermission } from '@/lib/permissions/utils'
57
import { db } from '@/db'
6-
import { templates } from '@/db/schema'
8+
import { templates, workflow } from '@/db/schema'
79

810
const logger = createLogger('TemplateByIdAPI')
911

@@ -62,3 +64,153 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
6264
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
6365
}
6466
}
67+
68+
const updateTemplateSchema = z.object({
69+
name: z.string().min(1).max(100),
70+
description: z.string().min(1).max(500),
71+
author: z.string().min(1).max(100),
72+
category: z.string().min(1),
73+
icon: z.string().min(1),
74+
color: z.string().regex(/^#[0-9A-F]{6}$/i),
75+
state: z.any().optional(), // Workflow state
76+
})
77+
78+
// PUT /api/templates/[id] - Update a template
79+
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
80+
const requestId = crypto.randomUUID().slice(0, 8)
81+
const { id } = await params
82+
83+
try {
84+
const session = await getSession()
85+
if (!session?.user?.id) {
86+
logger.warn(`[${requestId}] Unauthorized template update attempt for ID: ${id}`)
87+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
88+
}
89+
90+
const body = await request.json()
91+
const validationResult = updateTemplateSchema.safeParse(body)
92+
93+
if (!validationResult.success) {
94+
logger.warn(`[${requestId}] Invalid template data for update: ${id}`, validationResult.error)
95+
return NextResponse.json(
96+
{ error: 'Invalid template data', details: validationResult.error.errors },
97+
{ status: 400 }
98+
)
99+
}
100+
101+
const { name, description, author, category, icon, color, state } = validationResult.data
102+
103+
// Check if template exists
104+
const existingTemplate = await db.select().from(templates).where(eq(templates.id, id)).limit(1)
105+
106+
if (existingTemplate.length === 0) {
107+
logger.warn(`[${requestId}] Template not found for update: ${id}`)
108+
return NextResponse.json({ error: 'Template not found' }, { status: 404 })
109+
}
110+
111+
// Permission: template owner OR admin of the workflow's workspace (if any)
112+
let canUpdate = existingTemplate[0].userId === session.user.id
113+
114+
if (!canUpdate && existingTemplate[0].workflowId) {
115+
const wfRows = await db
116+
.select({ workspaceId: workflow.workspaceId })
117+
.from(workflow)
118+
.where(eq(workflow.id, existingTemplate[0].workflowId))
119+
.limit(1)
120+
121+
const workspaceId = wfRows[0]?.workspaceId as string | null | undefined
122+
if (workspaceId) {
123+
const hasAdmin = await hasAdminPermission(session.user.id, workspaceId)
124+
if (hasAdmin) canUpdate = true
125+
}
126+
}
127+
128+
if (!canUpdate) {
129+
logger.warn(`[${requestId}] User denied permission to update template ${id}`)
130+
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
131+
}
132+
133+
// Update the template
134+
const updatedTemplate = await db
135+
.update(templates)
136+
.set({
137+
name,
138+
description,
139+
author,
140+
category,
141+
icon,
142+
color,
143+
...(state && { state }),
144+
updatedAt: new Date(),
145+
})
146+
.where(eq(templates.id, id))
147+
.returning()
148+
149+
logger.info(`[${requestId}] Successfully updated template: ${id}`)
150+
151+
return NextResponse.json({
152+
data: updatedTemplate[0],
153+
message: 'Template updated successfully',
154+
})
155+
} catch (error: any) {
156+
logger.error(`[${requestId}] Error updating template: ${id}`, error)
157+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
158+
}
159+
}
160+
161+
// DELETE /api/templates/[id] - Delete a template
162+
export async function DELETE(
163+
request: NextRequest,
164+
{ params }: { params: Promise<{ id: string }> }
165+
) {
166+
const requestId = crypto.randomUUID().slice(0, 8)
167+
const { id } = await params
168+
169+
try {
170+
const session = await getSession()
171+
if (!session?.user?.id) {
172+
logger.warn(`[${requestId}] Unauthorized template delete attempt for ID: ${id}`)
173+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
174+
}
175+
176+
// Fetch template
177+
const existing = await db.select().from(templates).where(eq(templates.id, id)).limit(1)
178+
if (existing.length === 0) {
179+
logger.warn(`[${requestId}] Template not found for delete: ${id}`)
180+
return NextResponse.json({ error: 'Template not found' }, { status: 404 })
181+
}
182+
183+
const template = existing[0]
184+
185+
// Permission: owner or admin of the workflow's workspace (if any)
186+
let canDelete = template.userId === session.user.id
187+
188+
if (!canDelete && template.workflowId) {
189+
// Look up workflow to get workspaceId
190+
const wfRows = await db
191+
.select({ workspaceId: workflow.workspaceId })
192+
.from(workflow)
193+
.where(eq(workflow.id, template.workflowId))
194+
.limit(1)
195+
196+
const workspaceId = wfRows[0]?.workspaceId as string | null | undefined
197+
if (workspaceId) {
198+
const hasAdmin = await hasAdminPermission(session.user.id, workspaceId)
199+
if (hasAdmin) canDelete = true
200+
}
201+
}
202+
203+
if (!canDelete) {
204+
logger.warn(`[${requestId}] User denied permission to delete template ${id}`)
205+
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
206+
}
207+
208+
await db.delete(templates).where(eq(templates.id, id))
209+
210+
logger.info(`[${requestId}] Deleted template: ${id}`)
211+
return NextResponse.json({ success: true })
212+
} catch (error: any) {
213+
logger.error(`[${requestId}] Error deleting template: ${id}`, error)
214+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
215+
}
216+
}

apps/sim/app/api/templates/route.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ const QueryParamsSchema = z.object({
7777
limit: z.coerce.number().optional().default(50),
7878
offset: z.coerce.number().optional().default(0),
7979
search: z.string().optional(),
80+
workflowId: z.string().optional(),
8081
})
8182

8283
// GET /api/templates - Retrieve templates
@@ -111,6 +112,11 @@ export async function GET(request: NextRequest) {
111112
)
112113
}
113114

115+
// Apply workflow filter if provided (for getting template by workflow)
116+
if (params.workflowId) {
117+
conditions.push(eq(templates.workflowId, params.workflowId))
118+
}
119+
114120
// Combine conditions
115121
const whereCondition = conditions.length > 0 ? and(...conditions) : undefined
116122

apps/sim/app/api/workflows/[id]/route.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { createLogger } from '@/lib/logs/console/logger'
88
import { getUserEntityPermissions, hasAdminPermission } from '@/lib/permissions/utils'
99
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers'
1010
import { db } from '@/db'
11-
import { apiKey as apiKeyTable, workflow } from '@/db/schema'
11+
import { apiKey as apiKeyTable, templates, workflow } from '@/db/schema'
1212

1313
const logger = createLogger('WorkflowByIdAPI')
1414

@@ -218,6 +218,48 @@ export async function DELETE(
218218
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
219219
}
220220

221+
// Check if workflow has published templates before deletion
222+
const { searchParams } = new URL(request.url)
223+
const checkTemplates = searchParams.get('check-templates') === 'true'
224+
const deleteTemplatesParam = searchParams.get('deleteTemplates')
225+
226+
if (checkTemplates) {
227+
// Return template information for frontend to handle
228+
const publishedTemplates = await db
229+
.select()
230+
.from(templates)
231+
.where(eq(templates.workflowId, workflowId))
232+
233+
return NextResponse.json({
234+
hasPublishedTemplates: publishedTemplates.length > 0,
235+
count: publishedTemplates.length,
236+
publishedTemplates: publishedTemplates.map((t) => ({
237+
id: t.id,
238+
name: t.name,
239+
views: t.views,
240+
stars: t.stars,
241+
})),
242+
})
243+
}
244+
245+
// Handle template deletion based on user choice
246+
if (deleteTemplatesParam !== null) {
247+
const deleteTemplates = deleteTemplatesParam === 'delete'
248+
249+
if (deleteTemplates) {
250+
// Delete all templates associated with this workflow
251+
await db.delete(templates).where(eq(templates.workflowId, workflowId))
252+
logger.info(`[${requestId}] Deleted templates for workflow ${workflowId}`)
253+
} else {
254+
// Orphan the templates (set workflowId to null)
255+
await db
256+
.update(templates)
257+
.set({ workflowId: null })
258+
.where(eq(templates.workflowId, workflowId))
259+
logger.info(`[${requestId}] Orphaned templates for workflow ${workflowId}`)
260+
}
261+
}
262+
221263
await db.delete(workflow).where(eq(workflow.id, workflowId))
222264

223265
const elapsed = Date.now() - startTime

apps/sim/app/api/workspaces/[id]/route.ts

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { and, eq } from 'drizzle-orm'
1+
import { and, eq, inArray } from 'drizzle-orm'
22
import { type NextRequest, NextResponse } from 'next/server'
33
import { getSession } from '@/lib/auth'
44
import { createLogger } from '@/lib/logs/console/logger'
@@ -8,7 +8,7 @@ const logger = createLogger('WorkspaceByIdAPI')
88

99
import { getUserEntityPermissions } from '@/lib/permissions/utils'
1010
import { db } from '@/db'
11-
import { knowledgeBase, permissions, workspace } from '@/db/schema'
11+
import { knowledgeBase, permissions, templates, workspace } from '@/db/schema'
1212

1313
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
1414
const { id } = await params
@@ -19,13 +19,51 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
1919
}
2020

2121
const workspaceId = id
22+
const url = new URL(request.url)
23+
const checkTemplates = url.searchParams.get('check-templates') === 'true'
2224

2325
// Check if user has any access to this workspace
2426
const userPermission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
2527
if (!userPermission) {
2628
return NextResponse.json({ error: 'Workspace not found or access denied' }, { status: 404 })
2729
}
2830

31+
// If checking for published templates before deletion
32+
if (checkTemplates) {
33+
try {
34+
// Get all workflows in this workspace
35+
const workspaceWorkflows = await db
36+
.select({ id: workflow.id })
37+
.from(workflow)
38+
.where(eq(workflow.workspaceId, workspaceId))
39+
40+
if (workspaceWorkflows.length === 0) {
41+
return NextResponse.json({ hasPublishedTemplates: false, publishedTemplates: [] })
42+
}
43+
44+
const workflowIds = workspaceWorkflows.map((w) => w.id)
45+
46+
// Check for published templates that reference these workflows
47+
const publishedTemplates = await db
48+
.select({
49+
id: templates.id,
50+
name: templates.name,
51+
workflowId: templates.workflowId,
52+
})
53+
.from(templates)
54+
.where(inArray(templates.workflowId, workflowIds))
55+
56+
return NextResponse.json({
57+
hasPublishedTemplates: publishedTemplates.length > 0,
58+
publishedTemplates,
59+
count: publishedTemplates.length,
60+
})
61+
} catch (error) {
62+
logger.error(`Error checking published templates for workspace ${workspaceId}:`, error)
63+
return NextResponse.json({ error: 'Failed to check published templates' }, { status: 500 })
64+
}
65+
}
66+
2967
// Get workspace details
3068
const workspaceDetails = await db
3169
.select()
@@ -108,6 +146,8 @@ export async function DELETE(
108146
}
109147

110148
const workspaceId = id
149+
const body = await request.json().catch(() => ({}))
150+
const { deleteTemplates = false } = body // User's choice: false = keep templates (recommended), true = delete templates
111151

112152
// Check if user has admin permissions to delete workspace
113153
const userPermission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
@@ -116,10 +156,39 @@ export async function DELETE(
116156
}
117157

118158
try {
119-
logger.info(`Deleting workspace ${workspaceId} for user ${session.user.id}`)
159+
logger.info(
160+
`Deleting workspace ${workspaceId} for user ${session.user.id}, deleteTemplates: ${deleteTemplates}`
161+
)
120162

121163
// Delete workspace and all related data in a transaction
122164
await db.transaction(async (tx) => {
165+
// Get all workflows in this workspace before deletion
166+
const workspaceWorkflows = await tx
167+
.select({ id: workflow.id })
168+
.from(workflow)
169+
.where(eq(workflow.workspaceId, workspaceId))
170+
171+
if (workspaceWorkflows.length > 0) {
172+
const workflowIds = workspaceWorkflows.map((w) => w.id)
173+
174+
// Handle templates based on user choice
175+
if (deleteTemplates) {
176+
// Delete published templates that reference these workflows
177+
await tx.delete(templates).where(inArray(templates.workflowId, workflowIds))
178+
logger.info(`Deleted templates for workflows in workspace ${workspaceId}`)
179+
} else {
180+
// Set workflowId to null for templates to create "orphaned" templates
181+
// This allows templates to remain in marketplace but without source workflows
182+
await tx
183+
.update(templates)
184+
.set({ workflowId: null })
185+
.where(inArray(templates.workflowId, workflowIds))
186+
logger.info(
187+
`Updated templates to orphaned status for workflows in workspace ${workspaceId}`
188+
)
189+
}
190+
}
191+
123192
// Delete all workflows in the workspace - database cascade will handle all workflow-related data
124193
// The database cascade will handle deleting related workflow_blocks, workflow_edges, workflow_subflows,
125194
// workflow_logs, workflow_execution_snapshots, workflow_execution_logs, workflow_execution_trace_spans,

apps/sim/app/workspace/[workspaceId]/templates/templates.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export type CategoryValue = (typeof categories)[number]['value']
2929
// Template data structure
3030
export interface Template {
3131
id: string
32-
workflowId: string
32+
workflowId: string | null
3333
userId: string
3434
name: string
3535
description: string | null

0 commit comments

Comments
 (0)