Skip to content

Commit 2e9adcd

Browse files
committed
fix(people): keep a suspended manager link on edit and never fail the org layout on the People lookup
1 parent 3e23d39 commit 2e9adcd

6 files changed

Lines changed: 110 additions & 26 deletions

File tree

‎apps/sim/app/o/[organizationId]/layout.tsx‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
23
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
34
import { cookies } from 'next/headers'
45
import { redirect } from 'next/navigation'
@@ -65,8 +66,20 @@ export default async function OrganizationLayout({
6566
if (!context) {
6667
return <OrganizationAccessDenied />
6768
}
68-
const peopleEnabled = await canBrowsePeople(organizationId, context.viewer.role)
69-
if (!context.mothershipAvailable && !context.searchAccess.memberScoped && !peopleEnabled)
69+
const peopleEnabledLookup = canBrowsePeople(organizationId, context.viewer.role).catch(
70+
(error) => {
71+
logger.warn('Could not resolve People navigation access', {
72+
organizationId,
73+
error: getErrorMessage(error),
74+
})
75+
return false
76+
}
77+
)
78+
if (
79+
!context.mothershipAvailable &&
80+
!context.searchAccess.memberScoped &&
81+
!(await peopleEnabledLookup)
82+
)
7083
redirect(WORKSPACE_SETTINGS_PATH)
7184

7285
const [
@@ -79,6 +92,7 @@ export default async function OrganizationLayout({
7992
changelogEnabled,
8093
projectsEnabled,
8194
simLinksEnabled,
95+
peopleEnabled,
8296
] = await Promise.all([
8397
prefetchOrganizationSidebar(
8498
queryClient,
@@ -97,6 +111,7 @@ export default async function OrganizationLayout({
97111
isChangelogEnabled(organizationId),
98112
isProjectsEnabled(),
99113
isSimLinksEnabled(organizationId),
114+
peopleEnabledLookup,
100115
])
101116
const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1'
102117

‎apps/sim/app/o/[organizationId]/people/[memberId]/person-modal.tsx‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,14 @@ export function PersonModal({ organizationId, person, onClose }: PersonModalProp
4343
department: person.department,
4444
location: person.location,
4545
startDate: person.startDate,
46-
managerMemberId: person.manager?.memberId ?? null,
46+
managerMemberId: person.managerMemberId,
4747
})
4848
const [errors, setErrors] = useState<Partial<Record<keyof PersonBody, string>>>({})
4949
const [managerSearch, setManagerSearch] = useState('')
50-
/** The chosen manager's name, kept with the choice: the search results may no longer include them. */
50+
/**
51+
* The chosen manager's name, kept with the choice: the search results may no longer include them.
52+
* Only a stored manager who is suspended has an id and no name.
53+
*/
5154
const [managerName, setManagerName] = useState(person.manager?.name ?? null)
5255
const candidates = usePeople(
5356
organizationId,
@@ -64,7 +67,7 @@ export function PersonModal({ organizationId, person, onClose }: PersonModalProp
6467
label: `${candidate.name} (${candidate.email})`,
6568
})),
6669
]
67-
const managerLabel = (draft.managerMemberId && managerName) || 'No manager'
70+
const managerLabel = draft.managerMemberId ? (managerName ?? 'Suspended member') : 'No manager'
6871

6972
const change = <K extends keyof PersonBody>(field: K, value: PersonBody[K]) => {
7073
setDraft((current) => ({ ...current, [field]: value }))

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@ const personSchema = personSummarySchema.extend({
2424
location: z.string().nullable(),
2525
/** `YYYY-MM-DD`. */
2626
startDate: z.string().nullable(),
27+
/** The manager as shown; `null` when there is none or they are suspended. */
2728
manager: personSummarySchema.nullable(),
29+
/** The stored manager link, kept while that manager is suspended. */
30+
managerMemberId: z.string().nullable(),
2831
joinedAt: z.iso.datetime(),
2932
})
3033
export type Person = z.output<typeof personSchema>

‎apps/sim/lib/people/application/use-cases.ts‎

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,9 @@ export const getPerson = defineAuthorizedOrganizationUseCase({
7979
})
8080

8181
/**
82-
* Replaces one member's profile. The manager must be another active member of the same
83-
* organization who doesn't already report, directly or not, to this person.
82+
* Replaces one member's profile. A new manager must be another active member of the same
83+
* organization who doesn't already report, directly or not, to this person. The stored manager,
84+
* sent back unchanged, is kept even while suspended, as an import that leaves it out keeps it.
8485
*/
8586
export const updatePerson = defineAuthorizedOrganizationUseCase({
8687
operation: peopleOperations.update,
@@ -93,20 +94,19 @@ export const updatePerson = defineAuthorizedOrganizationUseCase({
9394
}
9495
return db.transaction(async (tx) => {
9596
await repository.lockProfiles(tx, organizationId)
96-
const active = await repository.findActiveMembers(
97-
tx,
98-
organizationId,
99-
managerMemberId ? [memberId, managerMemberId] : [memberId]
100-
)
101-
if (!active.has(memberId)) throw new OrchestrationError('not_found', 'Person not found')
102-
if (managerMemberId && !active.has(managerMemberId)) {
103-
throw new OrchestrationError(
104-
'validation',
105-
'The manager must be a member of this organization'
106-
)
107-
}
108-
if (managerMemberId && (await repository.isInReportingChain(tx, memberId, managerMemberId))) {
109-
throw new OrchestrationError('validation', 'That manager reports to this person')
97+
const current = await repository.getPerson(tx, organizationId, memberId)
98+
if (!current) throw new OrchestrationError('not_found', 'Person not found')
99+
if (managerMemberId && managerMemberId !== current.managerMemberId) {
100+
const active = await repository.findActiveMembers(tx, organizationId, [managerMemberId])
101+
if (!active.has(managerMemberId)) {
102+
throw new OrchestrationError(
103+
'validation',
104+
'The manager must be a member of this organization'
105+
)
106+
}
107+
if (await repository.isInReportingChain(tx, memberId, managerMemberId)) {
108+
throw new OrchestrationError('validation', 'That manager reports to this person')
109+
}
110110
}
111111
await repository.upsertProfiles(tx, [{ memberId, ...fields }], context.userId)
112112
const person = await repository.getPerson(tx, organizationId, memberId)

‎apps/sim/lib/people/repository.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export interface PersonRecord extends PersonSummary {
2727
location: string | null
2828
startDate: string | null
2929
manager: PersonSummary | null
30+
managerMemberId: string | null
3031
joinedAt: Date
3132
}
3233

@@ -96,17 +97,20 @@ async function summariesById(
9697
return new Map(rows.map((row) => [row.memberId, row]))
9798
}
9899

99-
/** Joins each person to their manager, read once for the whole page. */
100+
/**
101+
* Joins each person to their manager, read once for the whole page. A suspended manager stays
102+
* linked by `managerMemberId` but has no summary, since no read lists suspended people.
103+
*/
100104
async function withManagers(
101105
executor: DbOrTx,
102106
organizationId: string,
103107
rows: readonly PersonRow[]
104108
): Promise<PersonRecord[]> {
105109
const managerIds = [...new Set(rows.flatMap((row) => row.managerMemberId ?? []))]
106110
const managers = await summariesById(executor, organizationId, managerIds)
107-
return rows.map(({ sortName, managerMemberId, ...row }) => ({
111+
return rows.map(({ sortName, ...row }) => ({
108112
...row,
109-
manager: managerMemberId ? (managers.get(managerMemberId) ?? null) : null,
113+
manager: row.managerMemberId ? (managers.get(row.managerMemberId) ?? null) : null,
110114
}))
111115
}
112116

‎apps/sim/scripts/test-people-e2e.ts‎

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,10 @@ async function person(member: Member, memberId: string) {
8686
const result = await api(member, `/${memberId}`, {})
8787
assert(isRecordLike(result.person), 'Expected a person')
8888
return result as {
89-
person: Record<string, unknown> & { manager: { memberId: string } | null }
89+
person: Record<string, unknown> & {
90+
manager: { memberId: string } | null
91+
managerMemberId: string | null
92+
}
9093
chain: { memberId: string }[]
9194
reports: { memberId: string }[]
9295
}
@@ -297,6 +300,62 @@ async function run() {
297300
assert.equal(row?.manager_member_id, owner.memberId)
298301
})
299302

303+
await check(
304+
"an edit keeps a suspended manager it doesn't change, and refuses one as a new manager",
305+
async () => {
306+
const margaret = await seedMember(orgId, 'member', 'Margaret')
307+
const edit = (memberId: string, body: Record<string, unknown>, expected = 200) =>
308+
api(owner, `/${memberId}`, { method: 'PUT', body, expected })
309+
await edit(ada.memberId, {
310+
title: 'Engineer',
311+
department: 'Platform',
312+
location: 'Berlin, Germany',
313+
startDate: '2021-03-04',
314+
managerMemberId: margaret.memberId,
315+
})
316+
await sql`update "user" set suspended_at = now() where id = ${margaret.userId}`
317+
318+
const { person: shown } = await person(owner, ada.memberId)
319+
assert.equal(shown.manager, null, 'A suspended manager is not shown')
320+
assert.equal(shown.managerMemberId, margaret.memberId, 'The stored link is still reported')
321+
const saved = await edit(ada.memberId, {
322+
title: 'Staff Engineer',
323+
department: shown.department,
324+
location: shown.location,
325+
startDate: shown.startDate,
326+
managerMemberId: shown.managerMemberId,
327+
})
328+
assert(isRecordLike(saved.person), 'Expected a person')
329+
assert.equal(saved.person.managerMemberId, margaret.memberId)
330+
const row = await profileRow(ada.memberId)
331+
assert.equal(row?.title, 'Staff Engineer')
332+
assert.equal(row?.manager_member_id, margaret.memberId)
333+
334+
const refused = await edit(
335+
linus.memberId,
336+
{
337+
title: 'Principal Engineer',
338+
department: null,
339+
location: null,
340+
startDate: '2019-07-01',
341+
managerMemberId: margaret.memberId,
342+
},
343+
400
344+
)
345+
assert.equal(refused.error, 'The manager must be a member of this organization')
346+
assert.equal((await profileRow(linus.memberId))?.manager_member_id, owner.memberId)
347+
348+
await edit(ada.memberId, {
349+
title: 'Staff Engineer',
350+
department: shown.department,
351+
location: shown.location,
352+
startDate: shown.startDate,
353+
managerMemberId: owner.memberId,
354+
})
355+
assert.equal((await profileRow(ada.memberId))?.manager_member_id, owner.memberId)
356+
}
357+
)
358+
300359
await check(
301360
'leaving the organization removes the profile and clears reporting lines',
302361
async () => {
@@ -308,7 +367,7 @@ async function run() {
308367
)
309368

310369
await check('imports and edits are audited', async () => {
311-
const expected = { 'org_people.imported': 3, 'org_member.profile_updated': 1 }
370+
const expected = { 'org_people.imported': 3, 'org_member.profile_updated': 4 }
312371
let counts: Record<string, number> = {}
313372
for (let attempt = 0; attempt < 20; attempt++) {
314373
const rows = await sql<

0 commit comments

Comments
 (0)