Skip to content

Commit 90086de

Browse files
fix(projects): return a Project's environments in fork order
The Project API listed environments by workspace id, so every client had to rebuild the fork tree to show them. They now come back roots first, each fork right after the environment it was forked from, siblings by name; a fork whose parent the viewer cannot see is a root, as before. The contract documents the order, and the org project view reads it as is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lrd1NLD9qx2KBLVnw32c3E
1 parent 82c3dc0 commit 90086de

4 files changed

Lines changed: 96 additions & 32 deletions

File tree

‎apps/sim/app/o/[organizationId]/p/hooks/use-projects.ts‎

Lines changed: 6 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export interface Project {
1414
id: string
1515
name: string
1616
organizationId: string | null
17-
/** Roots first, each fork after its parent, siblings by name. */
17+
/** In the API's order: roots first, each fork after its parent, siblings by name. */
1818
environments: ProjectEnvironment[]
1919
canAdminister: boolean
2020
}
@@ -30,30 +30,15 @@ function byName(a: { name: string }, b: { name: string }): number {
3030
}
3131

3232
function toProject(record: ProjectRecord): Project {
33-
const visible = new Set(record.environments.map((environment) => environment.id))
34-
const children = new Map<string | null, ProjectEnvironment[]>()
35-
for (const environment of record.environments) {
36-
const parent = environment.forkedFromWorkspaceId
37-
const parentId = parent && visible.has(parent) ? parent : null
38-
const siblings = children.get(parentId) ?? []
39-
siblings.push({ workspaceId: environment.id, name: environment.name, parentId })
40-
children.set(parentId, siblings)
41-
}
42-
const environments: ProjectEnvironment[] = []
43-
const visit = (parentId: string | null) => {
44-
for (const environment of [...(children.get(parentId) ?? [])].sort(byName)) {
45-
environments.push(environment)
46-
visit(environment.workspaceId)
47-
}
48-
}
49-
visit(null)
50-
if (environments.length !== record.environments.length)
51-
throw new Error(`Project ${record.id} has a fork cycle among its environments`)
5233
return {
5334
id: record.id,
5435
name: record.name,
5536
organizationId: record.organizationId,
56-
environments,
37+
environments: record.environments.map((environment) => ({
38+
workspaceId: environment.id,
39+
name: environment.name,
40+
parentId: environment.forkedFromWorkspaceId,
41+
})),
5742
canAdminister: record.capabilities.administer,
5843
}
5944
}

‎apps/sim/lib/api/contracts/projects.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const projectSchema = z.object({
2020
archivedAt: z.coerce.date().nullable(),
2121
createdAt: z.coerce.date(),
2222
updatedAt: z.coerce.date(),
23+
/** Roots first, each fork right after the environment it was forked from, siblings by name. */
2324
environments: z.array(projectEnvironmentSchema),
2425
capabilities: z.object({ administer: z.boolean(), issues: z.boolean() }),
2526
})

‎apps/sim/lib/projects/__integration__/foundation.integration.ts‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1035,6 +1035,57 @@ describe('Project foundation at the database and application boundary', () => {
10351035
}
10361036
)
10371037

1038+
check(
1039+
'orders environments roots first, each fork after its parent, siblings by name',
1040+
async () => {
1041+
const f = await fixture(true, 1)
1042+
const [root] = f.ids
1043+
/** Ascending ids, assigned so id order disagrees with fork order. */
1044+
const [staging, sandbox, dev] = Array.from({ length: 3 }, () => generateId()).sort()
1045+
await db.transaction(async (tx) => {
1046+
for (const [id, name, parent] of [
1047+
[staging, 'Staging', root],
1048+
[dev, 'Dev', root],
1049+
[sandbox, 'Sandbox', dev],
1050+
] as const) {
1051+
await tx.insert(workspace).values({
1052+
id,
1053+
projectId: f.projectId,
1054+
name,
1055+
ownerId: f.ownerId,
1056+
billedAccountUserId: f.ownerId,
1057+
organizationId: f.organizationId,
1058+
workspaceMode: 'organization',
1059+
forkedFromWorkspaceId: parent,
1060+
})
1061+
await tx.insert(permissions).values({
1062+
id: generateId(),
1063+
entityType: 'workspace',
1064+
entityId: id,
1065+
userId: f.ownerId,
1066+
permissionType: 'admin',
1067+
})
1068+
}
1069+
})
1070+
const result = await getProject.execute({
1071+
principal: f.owner,
1072+
input: { projectId: f.projectId },
1073+
request,
1074+
})
1075+
expect(
1076+
result.project.environments.map(({ id, forkedFromWorkspaceId }) => [
1077+
id,
1078+
forkedFromWorkspaceId,
1079+
])
1080+
).toEqual([
1081+
[root, null],
1082+
[dev, root],
1083+
[sandbox, dev],
1084+
[staging, root],
1085+
])
1086+
}
1087+
)
1088+
10381089
check(
10391090
'hides inaccessible environments and denies mismatched scopes and workspace keys',
10401091
async () => {

‎apps/sim/lib/projects/application/authorization.ts‎

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { Principal, SessionPrincipal } from '@sim/auth/principal'
22
import { member, permissionGroup, permissions, project } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { isOrgAdminRole } from '@sim/platform-authz/workspace'
5+
import { compareStrings } from '@sim/utils/string'
56
import { and, asc, eq, inArray, sql } from 'drizzle-orm'
67
import { PrincipalKindAuthorizationError } from '@/lib/core/application/workspace-authorization'
78
import { OrchestrationError } from '@/lib/core/orchestration/types'
@@ -147,6 +148,42 @@ async function loadProjectAccess(
147148
}
148149

149150
/** Applies the access rules to one loaded Project; hidden environments never enter the result. */
151+
interface ProjectEnvironment {
152+
id: string
153+
name: string
154+
forkedFromWorkspaceId: string | null
155+
}
156+
157+
/**
158+
* Roots first, each fork right after the environment it was forked from, siblings by name. A
159+
* fork whose parent the viewer cannot see is presented as a root.
160+
*/
161+
function orderEnvironments(
162+
rows: readonly { id: string; name: string; parentId: string | null }[]
163+
): ProjectEnvironment[] {
164+
const ids = new Set(rows.map((row) => row.id))
165+
const children = new Map<string | null, ProjectEnvironment[]>()
166+
for (const row of rows) {
167+
const parent = row.parentId && ids.has(row.parentId) ? row.parentId : null
168+
const siblings = children.get(parent) ?? []
169+
siblings.push({ id: row.id, name: row.name, forkedFromWorkspaceId: parent })
170+
children.set(parent, siblings)
171+
}
172+
const ordered: ProjectEnvironment[] = []
173+
const visit = (parent: string | null) => {
174+
const siblings = (children.get(parent) ?? []).sort(
175+
(a, b) => compareStrings(a.name, b.name) || compareStrings(a.id, b.id)
176+
)
177+
for (const environment of siblings) {
178+
ordered.push(environment)
179+
visit(environment.id)
180+
}
181+
}
182+
visit(null)
183+
if (ordered.length !== rows.length) throw new Error('Project environments contain a fork cycle')
184+
return ordered
185+
}
186+
150187
async function evaluateProjectAccess(
151188
tx: DbTransaction,
152189
principal: SessionPrincipal,
@@ -200,17 +237,7 @@ async function evaluateProjectAccess(
200237
if (record.archivedAt) throw new OrchestrationError('conflict', 'Project is archived')
201238
refuseCapability('project_issues.use')
202239
}
203-
const visibleIds = new Set(visible.map((row) => row.id))
204-
return {
205-
record,
206-
canAdminister,
207-
canUseIssues,
208-
environments: visible.map((row) => ({
209-
id: row.id,
210-
name: row.name,
211-
forkedFromWorkspaceId: row.parentId && visibleIds.has(row.parentId) ? row.parentId : null,
212-
})),
213-
}
240+
return { record, canAdminister, canUseIssues, environments: orderEnvironments(visible) }
214241
}
215242

216243
export type AuthorizedProject = Awaited<ReturnType<typeof evaluateProjectAccess>>

0 commit comments

Comments
 (0)