Skip to content

Commit 7050250

Browse files
authored
fix(projects): restore workspace deletion and tighten Project lifecycle (#8631)
* fix(projects): restore workspace deletion and tighten Project lifecycle - Archive a Project with its last active environment instead of refusing the workspace delete; account deletion follows the same rule, and the implicit archive is audited - Gate Project APIs on a `projects` AppConfig flag (PROJECT_API_ENABLED fallback) - Run Project reads in a read-only snapshot without locks; list Projects from the caller's grants with batched authorization - Batch workflow archival, Project transfer and owner reassignment; move Project ownership on organization ownership transfer - Reuse shared advisory-lock and text-array helpers; narrow admin-move conflict mapping to ProjectConflictError * improvement(projects): unify environment archive and align with shared patterns - Archive a workspace's workflows atomically with it through one archiveEnvironmentInTransaction shared by workspace delete and Project archive; the workspace row is locked before the sweep so concurrent creates are covered - Scope the Project lock timeout to lock acquisition and map lock timeouts and deadlocks to a retryable conflict; backfill and multi-Project locks use the shared advisory-lock helpers in code-unit order - Shared orchestrationFailureResponse for raw routes; contracts use the ID primitives and export only what is consumed; audit enums and mock in sync - Project restrictions section matches its sibling settings rows - Batch account-deletion Project loads/locks; skip inconsistent Projects in lists - Harden the foundation integration suite (user-keyed cleanup, poll helper, pid-scoped waits, precise assertions) * fix(projects): trim the requested organization id before validating it * fix(projects): address review on Project locking, archive notifications and list policy cost * fix(projects): keep archive retries from re-stamping MCP servers and isolate post-commit notifications
1 parent 21c8a07 commit 7050250

52 files changed

Lines changed: 1414 additions & 989 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/sim/app/api/files/uploads/utils.ts‎

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import {
44
type InternalFileUploadSession,
55
internalFileUploadSessionSchema,
66
} from '@/lib/api/contracts/upload-sessions'
7+
import { orchestrationFailureResponse } from '@/lib/api/server/orchestration-response'
78
import { getSession } from '@/lib/auth'
8-
import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types'
99
import type { UploadSessionRecord } from '@/lib/uploads/upload-session/service'
1010
import type { UploadActor, UploadPurposeResult } from '@/app/api/files/uploads/finalizers'
1111

@@ -32,13 +32,7 @@ export async function requireUploadUser(): Promise<AuthenticatedUploadActor | Ne
3232
}
3333

3434
export function uploadSessionErrorResponse(error: unknown): NextResponse | null {
35-
const classified = asOrchestrationError(error)
36-
return classified
37-
? NextResponse.json(
38-
{ error: classified.message },
39-
{ status: statusForOrchestrationError(classified.code) }
40-
)
41-
: null
35+
return orchestrationFailureResponse(error)
4236
}
4337

4438
export function toInternalUploadSession(

‎apps/sim/app/api/superuser/import-workflow/route.ts‎

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,13 @@ import { type NextRequest, NextResponse } from 'next/server'
77
import { importWorkflowAsSuperuserContract } from '@/lib/api/contracts/workflows'
88
import { parseRequest } from '@/lib/api/server'
99
import { getSession } from '@/lib/auth'
10-
import { OrchestrationError } from '@/lib/core/orchestration/types'
10+
import { asOrchestrationError } from '@/lib/core/orchestration/types'
1111
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1212
import { loadCopilotChatMessages } from '@/lib/mothership/chat/lifecycle'
1313
import { appendCopilotChatMessages } from '@/lib/mothership/chat/messages-store'
1414
import { verifyEffectiveSuperUser } from '@/lib/permissions/super-user'
1515
import { parseWorkflowJson } from '@/lib/workflows/operations/import-export'
16-
import { buildNewWorkflowRow } from '@/lib/workflows/persistence/new-workflow-row'
16+
import { insertNewWorkflowRow } from '@/lib/workflows/persistence/new-workflow-row'
1717
import {
1818
loadWorkflowFromNormalizedTables,
1919
saveWorkflowToNormalizedTables,
@@ -137,19 +137,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
137137
null
138138
)
139139

140-
await db.transaction(async (tx) => {
141-
await tx.insert(workflow).values(
142-
await buildNewWorkflowRow(tx, {
143-
id: newWorkflowId,
144-
userId: session.user.id,
145-
workspaceId: targetWorkspaceId,
146-
folderId: null,
147-
name: dedupedName,
148-
description: sourceWorkflow.description,
149-
variables: sourceWorkflow.variables || {},
150-
})
151-
)
152-
})
140+
await db.transaction((tx) =>
141+
insertNewWorkflowRow(tx, {
142+
id: newWorkflowId,
143+
userId: session.user.id,
144+
workspaceId: targetWorkspaceId,
145+
folderId: null,
146+
name: dedupedName,
147+
description: sourceWorkflow.description,
148+
variables: sourceWorkflow.variables || {},
149+
})
150+
)
153151

154152
// Save using existing persistence logic
155153
const saveResult = await saveWorkflowToNormalizedTables(newWorkflowId, importedData, {
@@ -228,7 +226,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
228226
copilotChatsImported,
229227
})
230228
} catch (error) {
231-
if (error instanceof OrchestrationError && error.code === 'not_found') {
229+
if (asOrchestrationError(error)?.code === 'not_found') {
232230
return NextResponse.json({ error: 'Target workspace not found' }, { status: 404 })
233231
}
234232
logger.error('Error importing workflow', error)

‎apps/sim/app/api/table/utils.ts‎

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import { createLogger } from '@sim/logger'
22
import { permissionSatisfies } from '@sim/platform-authz/workspace'
33
import { toError } from '@sim/utils/errors'
44
import { NextResponse } from 'next/server'
5+
import { orchestrationFailureResponse } from '@/lib/api/server/orchestration-response'
56
import {
6-
asOrchestrationError,
77
messageForOrchestrationError,
88
type OrchestrationErrorCode,
99
statusForOrchestrationError,
@@ -137,13 +137,7 @@ export function orchestrationErrorResponse(error: unknown): NextResponse | null
137137
const lockResponse = tableLockErrorResponse(error)
138138
if (lockResponse) return lockResponse
139139

140-
const classified = asOrchestrationError(error)
141-
if (!classified) return null
142-
143-
return NextResponse.json(
144-
{ error: classified.message },
145-
{ status: statusForOrchestrationError(classified.code) }
146-
)
140+
return orchestrationFailureResponse(error)
147141
}
148142

149143
/**

‎apps/sim/app/api/v1/admin/workflows/import/route.ts‎

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,10 @@ import { and, eq, isNull } from 'drizzle-orm'
2828
import { NextResponse } from 'next/server'
2929
import { adminV1ImportWorkflowContract } from '@/lib/api/contracts/v1/admin'
3030
import { parseRequest } from '@/lib/api/server'
31-
import { OrchestrationError } from '@/lib/core/orchestration/types'
31+
import { asOrchestrationError } from '@/lib/core/orchestration/types'
3232
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
3333
import { parseWorkflowJson } from '@/lib/workflows/operations/import-export'
34-
import { buildNewWorkflowRow } from '@/lib/workflows/persistence/new-workflow-row'
34+
import { insertNewWorkflowRow } from '@/lib/workflows/persistence/new-workflow-row'
3535
import { prepareWorkflowStateForPersistence } from '@/lib/workflows/persistence/prepare-state'
3636
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
3737
import { deduplicateWorkflowName } from '@/lib/workflows/utils'
@@ -116,18 +116,16 @@ export const POST = withRouteHandler(
116116
const workflowId = generateId()
117117
const dedupedName = await deduplicateWorkflowName(workflowName, workspaceId, folderId || null)
118118

119-
await db.transaction(async (tx) => {
120-
await tx.insert(workflow).values(
121-
await buildNewWorkflowRow(tx, {
122-
id: workflowId,
123-
userId: workspaceData.ownerId,
124-
workspaceId,
125-
folderId: folderId || null,
126-
name: dedupedName,
127-
description: workflowDescription,
128-
})
129-
)
130-
})
119+
await db.transaction((tx) =>
120+
insertNewWorkflowRow(tx, {
121+
id: workflowId,
122+
userId: workspaceData.ownerId,
123+
workspaceId,
124+
folderId: folderId || null,
125+
name: dedupedName,
126+
description: workflowDescription,
127+
})
128+
)
131129

132130
/**
133131
* Same normalization the editor and the v1 import API run, via the one
@@ -183,7 +181,7 @@ export const POST = withRouteHandler(
183181

184182
return NextResponse.json(response)
185183
} catch (error) {
186-
if (error instanceof OrchestrationError && error.code === 'not_found') {
184+
if (asOrchestrationError(error)?.code === 'not_found') {
187185
return notFoundResponse('Workspace')
188186
}
189187
if (error instanceof FolderNotFoundError) {

‎apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts‎

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ import {
4545
extractWorkflowsFromZip,
4646
parseWorkflowJson,
4747
} from '@/lib/workflows/operations/import-export'
48-
import { buildNewWorkflowRow } from '@/lib/workflows/persistence/new-workflow-row'
48+
import { insertNewWorkflowRow } from '@/lib/workflows/persistence/new-workflow-row'
4949
import { prepareWorkflowStateForPersistence } from '@/lib/workflows/persistence/prepare-state'
5050
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
5151
import { deduplicateWorkflowName } from '@/lib/workflows/utils'
@@ -350,18 +350,16 @@ async function importSingleWorkflow(
350350
const workflowId = generateId()
351351
const dedupedName = await deduplicateWorkflowName(workflowName, workspaceId, targetFolderId)
352352

353-
await db.transaction(async (tx) => {
354-
await tx.insert(workflow).values(
355-
await buildNewWorkflowRow(tx, {
356-
id: workflowId,
357-
userId: ownerId,
358-
workspaceId,
359-
folderId: targetFolderId,
360-
name: dedupedName,
361-
description: workflowData.metadata?.description || 'Imported via Admin API',
362-
})
363-
)
364-
})
353+
await db.transaction((tx) =>
354+
insertNewWorkflowRow(tx, {
355+
id: workflowId,
356+
userId: ownerId,
357+
workspaceId,
358+
folderId: targetFolderId,
359+
name: dedupedName,
360+
description: workflowData.metadata?.description || 'Imported via Admin API',
361+
})
362+
)
365363

366364
/**
367365
* Same normalization the editor, the v1 import API and the single-workflow

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

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ import { and, eq, isNull } from 'drizzle-orm'
55
import { type NextRequest, NextResponse } from 'next/server'
66
import { deleteWorkspaceBodySchema, updateWorkspaceContract } from '@/lib/api/contracts'
77
import { parseRequest, validationErrorResponse } from '@/lib/api/server'
8+
import { orchestrationFailureResponse } from '@/lib/api/server/orchestration-response'
89
import { getSession } from '@/lib/auth'
910
import { changeWorkspaceStoragePayerInTx } from '@/lib/billing/storage/payer-transfer'
10-
import { OrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types'
1111
import { captureServerEvent } from '@/lib/posthog/server'
1212
import { archiveWorkspace } from '@/lib/workspaces/lifecycle'
1313

@@ -307,6 +307,20 @@ export const DELETE = withRouteHandler(
307307
},
308308
request,
309309
})
310+
if (archiveResult.archivedProject) {
311+
recordAudit({
312+
workspaceId,
313+
actorId: session.user.id,
314+
actorName: session.user.name,
315+
actorEmail: session.user.email,
316+
action: AuditAction.PROJECT_ARCHIVED,
317+
resourceType: AuditResourceType.PROJECT,
318+
resourceId: archiveResult.archivedProject.id,
319+
resourceName: archiveResult.archivedProject.name,
320+
description: `Archived Project "${archiveResult.archivedProject.name}" with its last active environment`,
321+
request,
322+
})
323+
}
310324

311325
captureServerEvent(
312326
session.user.id,
@@ -317,12 +331,8 @@ export const DELETE = withRouteHandler(
317331

318332
return NextResponse.json({ success: true })
319333
} catch (error) {
320-
if (error instanceof OrchestrationError) {
321-
return NextResponse.json(
322-
{ error: error.message },
323-
{ status: statusForOrchestrationError(error.code) }
324-
)
325-
}
334+
const failure = orchestrationFailureResponse(error, 'Failed to delete workspace')
335+
if (failure) return failure
326336
logger.error(`Error deleting workspace ${workspaceId}:`, error)
327337
return NextResponse.json({ error: 'Failed to delete workspace' }, { status: 500 })
328338
}

‎apps/sim/ee/access-control/components/group-detail.tsx‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1784,16 +1784,6 @@ export function GroupDetail({
17841784

17851785
{configTab === 'platform' && (
17861786
<div className='flex flex-col gap-7'>
1787-
<ProjectIssueRestrictions
1788-
organizationId={organizationId}
1789-
value={editingConfig.deniedPartialAccessProjectIssues}
1790-
onChange={(value) =>
1791-
setEditingConfig((previous) => ({
1792-
...previous,
1793-
deniedPartialAccessProjectIssues: value,
1794-
}))
1795-
}
1796-
/>
17971787
<div className='flex items-center gap-2'>
17981788
<ChipInput
17991789
icon={Search}
@@ -1871,6 +1861,16 @@ export function GroupDetail({
18711861
</div>
18721862
</SettingsSection>
18731863
))}
1864+
<ProjectIssueRestrictions
1865+
organizationId={organizationId}
1866+
value={editingConfig.deniedPartialAccessProjectIssues}
1867+
onChange={(value) =>
1868+
setEditingConfig((previous) => ({
1869+
...previous,
1870+
deniedPartialAccessProjectIssues: value,
1871+
}))
1872+
}
1873+
/>
18741874
</div>
18751875
)}
18761876
</SettingsPanel>
Lines changed: 52 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
'use client'
22

3-
import { Checkbox, Chip } from '@sim/emcn'
3+
import { Checkbox, Chip, Info, OverflowText } from '@sim/emcn'
44
import { isApiClientError } from '@/lib/api/client/errors'
5+
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
56
import { useProjects } from '@/hooks/queries/projects'
67

78
interface ProjectIssueRestrictionsProps {
@@ -10,7 +11,10 @@ interface ProjectIssueRestrictionsProps {
1011
onChange: (value: string[]) => void
1112
}
1213

13-
/** Project choices use the authorized inventory; policy remains enforced at the application boundary. */
14+
/**
15+
* Project choices use the authorized inventory; policy remains enforced at the
16+
* application boundary.
17+
*/
1418
export function ProjectIssueRestrictions({
1519
organizationId,
1620
value,
@@ -20,44 +24,58 @@ export function ProjectIssueRestrictions({
2024
if (projects.isPending || (isApiClientError(projects.error) && projects.error.status === 503)) {
2125
return null
2226
}
27+
const choices = projects.data?.pages.flatMap((page) => page.projects) ?? []
28+
if (!projects.error && choices.length === 0) return null
2329
const selected = new Set(value)
2430
return (
25-
<div className='flex flex-col gap-2'>
26-
<p className='text-small'>Restrict Issues for partial-access teammates</p>
27-
<p className='text-[var(--text-muted)] text-small'>
28-
For selected Projects, teammates governed by this group need access to every active
29-
environment to use Issues.
30-
</p>
31+
<SettingsSection
32+
label='Project Issues'
33+
headerAccessory={
34+
<Info side='top'>
35+
For selected Projects, teammates governed by this group need access to every active
36+
environment to use Issues.
37+
</Info>
38+
}
39+
action={
40+
projects.hasNextPage ? (
41+
<Chip
42+
disabled={projects.isFetchingNextPage}
43+
onClick={() => void projects.fetchNextPage()}
44+
>
45+
{projects.isFetchingNextPage ? 'Loading…' : 'Load more'}
46+
</Chip>
47+
) : undefined
48+
}
49+
>
3150
{projects.error && (
32-
<p className='text-[var(--text-error)] text-small'>{projects.error.message}</p>
51+
<p className='pl-2 text-[var(--text-error)] text-caption'>{projects.error.message}</p>
3352
)}
34-
{projects.data?.pages
35-
.flatMap((page) => page.projects)
36-
.map((project) => (
37-
<label
38-
htmlFor={`project-issues-${project.id}`}
53+
<div className='flex flex-col gap-0.5'>
54+
{choices.map((project) => (
55+
<div
3956
key={project.id}
40-
className='flex items-center gap-2'
57+
className='flex items-center gap-1.5 rounded-md pr-2 transition-colors hover-hover:bg-[var(--surface-active)]'
4158
>
42-
<Checkbox
43-
id={`project-issues-${project.id}`}
44-
checked={selected.has(project.id)}
45-
onCheckedChange={(checked) =>
46-
onChange(
47-
checked === true
48-
? [...new Set([...value, project.id])]
49-
: value.filter((id) => id !== project.id)
50-
)
51-
}
52-
/>
53-
<span className='text-small'>{project.name}</span>
54-
</label>
59+
<label
60+
htmlFor={`project-issues-${project.id}`}
61+
className='flex min-w-0 flex-1 cursor-pointer items-center gap-2 py-[5px] pl-2'
62+
>
63+
<Checkbox
64+
id={`project-issues-${project.id}`}
65+
checked={selected.has(project.id)}
66+
onCheckedChange={(checked) =>
67+
onChange(
68+
checked === true
69+
? [...new Set([...value, project.id])]
70+
: value.filter((id) => id !== project.id)
71+
)
72+
}
73+
/>
74+
<OverflowText label={project.name} className='text-sm' />
75+
</label>
76+
</div>
5577
))}
56-
{projects.hasNextPage && (
57-
<Chip disabled={projects.isFetchingNextPage} onClick={() => void projects.fetchNextPage()}>
58-
Load more
59-
</Chip>
60-
)}
61-
</div>
78+
</div>
79+
</SettingsSection>
6280
)
6381
}

0 commit comments

Comments
 (0)