Skip to content

Commit 5bb042c

Browse files
committed
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
1 parent ea88578 commit 5bb042c

37 files changed

Lines changed: 927 additions & 637 deletions

File tree

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

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ 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, {

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

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import { parseRequest } from '@/lib/api/server'
3131
import { OrchestrationError } 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

‎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: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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,
Lines changed: 51 additions & 37 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 } 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,
@@ -22,42 +26,52 @@ export function ProjectIssueRestrictions({
2226
}
2327
const selected = new Set(value)
2428
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-
{projects.error && (
32-
<p className='text-[var(--text-error)] text-small'>{projects.error.message}</p>
33-
)}
34-
{projects.data?.pages
35-
.flatMap((page) => page.projects)
36-
.map((project) => (
37-
<label
38-
htmlFor={`project-issues-${project.id}`}
39-
key={project.id}
40-
className='flex items-center gap-2'
29+
<SettingsSection
30+
label='Restrict Issues for partial-access teammates'
31+
headerAccessory={
32+
<Info side='top' className='shrink-0'>
33+
For selected Projects, teammates governed by this group need access to every active
34+
environment to use Issues.
35+
</Info>
36+
}
37+
action={
38+
projects.hasNextPage ? (
39+
<Chip
40+
disabled={projects.isFetchingNextPage}
41+
onClick={() => void projects.fetchNextPage()}
4142
>
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>
55-
))}
56-
{projects.hasNextPage && (
57-
<Chip disabled={projects.isFetchingNextPage} onClick={() => void projects.fetchNextPage()}>
58-
Load more
59-
</Chip>
43+
{projects.isFetchingNextPage ? 'Loading…' : 'Load more'}
44+
</Chip>
45+
) : undefined
46+
}
47+
>
48+
{projects.error && (
49+
<p className='pl-2 text-[var(--text-error)] text-caption'>{projects.error.message}</p>
6050
)}
61-
</div>
51+
<div className='flex flex-col gap-0.5'>
52+
{projects.data?.pages
53+
.flatMap((page) => page.projects)
54+
.map((project) => (
55+
<label
56+
htmlFor={`project-issues-${project.id}`}
57+
key={project.id}
58+
className='flex cursor-pointer items-center gap-2 rounded-md py-[5px] pl-2 transition-colors hover-hover:bg-[var(--surface-active)]'
59+
>
60+
<Checkbox
61+
id={`project-issues-${project.id}`}
62+
checked={selected.has(project.id)}
63+
onCheckedChange={(checked) =>
64+
onChange(
65+
checked === true
66+
? [...new Set([...value, project.id])]
67+
: value.filter((id) => id !== project.id)
68+
)
69+
}
70+
/>
71+
<span className='text-sm'>{project.name}</span>
72+
</label>
73+
))}
74+
</div>
75+
</SettingsSection>
6276
)
6377
}

‎apps/sim/ee/workspace-forking/lib/create-fork.ts‎

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { db } from '@sim/db'
2-
import { permissions, projectWorkspace, workflow, workspace } from '@sim/db/schema'
2+
import { permissions, projectWorkspace, workspace } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import type { PermissionType } from '@sim/platform-authz/workspace'
55
import { getErrorMessage } from '@sim/utils/errors'
@@ -9,7 +9,7 @@ import type { Workspace } from '@/lib/api/contracts/workspaces'
99
import { enqueueOutboxEvent } from '@/lib/core/outbox/service'
1010
import { requireForkProject } from '@/lib/projects/membership'
1111
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
12-
import { buildNewWorkflowRow } from '@/lib/workflows/persistence/new-workflow-row'
12+
import { insertNewWorkflowRow } from '@/lib/workflows/persistence/new-workflow-row'
1313
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
1414
import {
1515
collectReferencedDocumentIds,
@@ -511,17 +511,15 @@ export async function createFork(params: CreateForkParams): Promise<CreateForkRe
511511
// starter "New workspace" creates. Any copied resources still land alongside it.
512512
if (workflowsCopied === 0) {
513513
const defaultWorkflowId = generateId()
514-
await tx.insert(workflow).values(
515-
await buildNewWorkflowRow(tx, {
516-
id: defaultWorkflowId,
517-
userId,
518-
workspaceId: childWorkspaceId,
519-
folderId: null,
520-
name: 'default-agent',
521-
description: 'Your first workflow - start building here!',
522-
now,
523-
})
524-
)
514+
await insertNewWorkflowRow(tx, {
515+
id: defaultWorkflowId,
516+
userId,
517+
workspaceId: childWorkspaceId,
518+
folderId: null,
519+
name: 'default-agent',
520+
description: 'Your first workflow - start building here!',
521+
now,
522+
})
525523
const { workflowState } = buildDefaultWorkflowArtifacts()
526524
await saveWorkflowToNormalizedTables(
527525
defaultWorkflowId,

‎apps/sim/ee/workspace-forking/lib/lineage/unlink.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ export interface UnlinkForkResult {
2929
/**
3030
* Permanently dissolve a fork edge: null the child's `forkedFromWorkspaceId` (the
3131
* edge's single source of truth) and purge the edge's fork state — resource map,
32-
* block map, dependent values, and promote-run undo points. Both workspaces remain; the detached subtree moves into its own Project.
32+
* block map, dependent values, and promote-run undo points. Both workspaces remain;
33+
* the detached subtree moves into its own Project.
3334
*
3435
* Runs in one transaction under the lineage and edge advisory locks. Every promote and
3536
* rollback on the edge holds the edge lock, so an in-flight sync either finishes before

‎apps/sim/hooks/queries/projects.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,11 @@ export function useProjects(organizationId: string) {
2222
getNextPageParam: (page) => page.nextCursor,
2323
staleTime: PROJECT_LIST_STALE_TIME,
2424
retry: (failureCount, error) =>
25-
!(isApiClientError(error) && error.status === 503) && failureCount < 3,
25+
failureCount < 1 &&
26+
(!isApiClientError(error) ||
27+
error.status === 408 ||
28+
error.status === 429 ||
29+
(error.status >= 500 && error.status !== 503)),
2630
enabled: Boolean(organizationId),
2731
})
2832
}

‎apps/sim/lib/billing/organizations/membership.ts‎

Lines changed: 13 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import {
1515
organization,
1616
permissionGroupMember,
1717
permissions,
18-
project,
1918
subscription as subscriptionTable,
2019
user,
2120
userStats,
@@ -61,7 +60,7 @@ import {
6160
revokePersonalApiKeysTx,
6261
revokeUserSessionsTx,
6362
} from '@/lib/organizations/members/revocation'
64-
import { lockProjectBackfillWrites, tryLockProject } from '@/lib/projects/membership'
63+
import { reassignOrganizationProjects } from '@/lib/projects/membership'
6564
import { removeWorkspaceSkillMembershipsTx } from '@/lib/skills/access'
6665
import {
6766
reassignWorkflowOwnershipForWorkspaceMemberRemovalTx,
@@ -557,25 +556,12 @@ async function reassignOwnedOrganizationResourcesTx({
557556
const ownerId = ownerMembership?.userId
558557
if (!ownerId || ownerId === userId) return 0
559558

560-
await lockProjectBackfillWrites(tx, workspaceIds)
561-
const ownedProjects = await tx
562-
.select({ id: project.id })
563-
.from(project)
564-
.where(and(eq(project.organizationId, organizationId), eq(project.ownerId, userId)))
565-
.orderBy(project.id)
566-
for (const row of ownedProjects) {
567-
await tryLockProject(tx, row.id)
568-
await tx
569-
.update(project)
570-
.set({ ownerId, updatedAt: new Date() })
571-
.where(
572-
and(
573-
eq(project.id, row.id),
574-
eq(project.ownerId, userId),
575-
eq(project.organizationId, organizationId)
576-
)
577-
)
578-
}
559+
await reassignOrganizationProjects(tx, {
560+
organizationId,
561+
fromUserId: userId,
562+
toUserId: ownerId,
563+
workspaceIds,
564+
})
579565

580566
/** Creator attribution must survive account deletion without changing document ACLs. */
581567
await tx
@@ -1850,6 +1836,12 @@ export async function transferOrganizationOwnership(
18501836
.returning({ id: workspace.id })
18511837

18521838
result.workspacesReassigned = ownerUpdate.length
1839+
await reassignOrganizationProjects(tx, {
1840+
organizationId,
1841+
fromUserId: currentOwnerUserId,
1842+
toUserId: newOwnerUserId,
1843+
workspaceIds: ownerUpdate.map((workspaceRow) => workspaceRow.id),
1844+
})
18531845

18541846
const reassignedWorkspaceIds = Array.from(
18551847
new Set([...billedWorkspaceIds, ...ownerUpdate.map((workspaceRow) => workspaceRow.id)])

‎apps/sim/lib/core/config/env.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -599,6 +599,7 @@ export const env = createEnv({
599599
AGENTMAIL_DOMAIN: z.string().optional(), // Custom domain for AgentMail inboxes (default: agentmail.to)
600600
MSHIP_PLAN_MODE: z.boolean().optional(),
601601
DASHBOARDS: z.boolean().optional(),
602+
PROJECT_API_ENABLED: z.boolean().optional(), // Fallback for the `projects` feature flag off AppConfig
602603
MSHIP_MODEL_SELECTOR: z.boolean().optional(),
603604
INBOX_ENABLED: z.boolean().optional(), // Enable inbox (Sim Mailer) on self-hosted (bypasses hosted requirements)
604605
SANDBOXES_ENABLED: z.boolean().optional(), // Enable custom sandboxes on self-hosted (bypasses hosted requirements)
@@ -669,7 +670,6 @@ export const env = createEnv({
669670

670671
// SSO Configuration (for script-based registration)
671672
SSO_ENABLED: z.boolean().optional(), // Enable SSO functionality
672-
PROJECT_API_ENABLED: z.boolean().optional(), // Expose Projects after backfill and contract enforcement
673673
SCIM_ENABLED: z.boolean().optional(), // Enable SCIM directory provisioning
674674
USAGE_MONITORING_ENABLED: z.boolean().optional(), // Enable organization usage monitoring on self-hosted (bypasses hosted requirements)
675675
SSO_PROVIDER_TYPE: z.enum(['oidc', 'saml']).optional(), // [REQUIRED] SSO provider type

0 commit comments

Comments
 (0)