Skip to content

Commit 260b789

Browse files
ensureActiveMember
1 parent d50d008 commit 260b789

8 files changed

Lines changed: 119 additions & 90 deletions

File tree

packages/web/src/app/(app)/settings/members/page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ type MembersSettingsPageProps = {
2323
}>
2424
}
2525

26-
export default authenticatedPage<MembersSettingsPageProps>(async ({ org, role, user }, props) => {
26+
export default authenticatedPage<MembersSettingsPageProps>(async ({ org, role, user, prisma }, props) => {
2727
const searchParams = await props.searchParams;
2828

2929
const {
@@ -47,7 +47,7 @@ export default authenticatedPage<MembersSettingsPageProps>(async ({ org, role, u
4747

4848
const currentTab = tab || "members";
4949

50-
const hasAvailability = await orgHasAvailability(org.id);
50+
const hasAvailability = await orgHasAvailability(org.id, prisma);
5151
const seatCap = getSeatCap();
5252
const scimEnabled = await isScimEnabled(org);
5353

packages/web/src/app/api/(server)/ee/scim/v2/Users/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { apiHandler } from '@/lib/apiHandler';
2-
import { addMember, setMemberActive } from '@/features/membership/membership.service';
2+
import { ensureActiveMember, setMemberActive } from '@/features/membership/membership.service';
33
import { scimError, scimJson, toScimListResponse, toScimUser } from '@/ee/features/scim/mapper';
44
import {
55
coerceActive,
@@ -90,7 +90,7 @@ export const POST = apiHandler(async (request: NextRequest) =>
9090
}
9191
httpStatus = 200;
9292
} else {
93-
const result = await addMember(org.id, user.id, {
93+
const result = await ensureActiveMember(org.id, user.id, {
9494
actor: scimActor,
9595
role: OrgRole.MEMBER,
9696
scimExternalId: payload.externalId,

packages/web/src/features/membership/actions/accountRequests.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { createAudit } from "@/ee/features/audit/audit";
44
import JoinRequestApprovedEmail from "@/emails/joinRequestApprovedEmail";
5-
import { addMember } from "@/features/membership/membership.service";
5+
import { ensureActiveMember } from "@/features/membership/membership.service";
66
import { getDefaultMemberRole } from "@/features/membership/utils";
77
import { membershipManagedByIdpError } from "@/features/membership/errors";
88
import { isScimEnabled } from "@/features/scim/utils";
@@ -171,7 +171,7 @@ export const approveAccountRequest = async (requestId: string) => sew(async () =
171171
return notFound();
172172
}
173173

174-
const addUserToOrgRes = await addMember(org.id, request.requestedById, {
174+
const addUserToOrgRes = await ensureActiveMember(org.id, request.requestedById, {
175175
actor: { id: request.requestedById, type: "user" },
176176
role: await getDefaultMemberRole(),
177177
});

packages/web/src/features/membership/actions/invites.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { createAudit } from "@/ee/features/audit/audit";
44
import InviteUserEmail from "@/emails/inviteUserEmail";
5-
import { addMember } from "@/features/membership/membership.service";
5+
import { ensureActiveMember } from "@/features/membership/membership.service";
66
import { getDefaultMemberRole, orgHasAvailability } from "@/features/membership/utils";
77
import { membershipManagedByIdpError } from "@/features/membership/errors";
88
import { isScimEnabled } from "@/features/scim/utils";
@@ -49,7 +49,7 @@ export const createInvites = async (emails: string[]): Promise<{ success: boolea
4949
});
5050
}
5151

52-
const hasAvailability = await orgHasAvailability(org.id);
52+
const hasAvailability = await orgHasAvailability(org.id, prisma);
5353
if (!hasAvailability) {
5454
await createAudit({
5555
action: "user.invite_failed",
@@ -289,7 +289,7 @@ export const joinOrganization = async (inviteLinkId?: string) => sew(async () =>
289289
}
290290
}
291291

292-
const addUserToOrgRes = await addMember(org.id, user.id, {
292+
const addUserToOrgRes = await ensureActiveMember(org.id, user.id, {
293293
actor: { id: user.id, type: "user" },
294294
role: await getDefaultMemberRole(),
295295
});
@@ -348,7 +348,7 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean
348348
});
349349
};
350350

351-
const hasAvailability = await orgHasAvailability(invite.org.id);
351+
const hasAvailability = await orgHasAvailability(invite.org.id, __unsafePrisma);
352352
if (!hasAvailability) {
353353
await failAuditCallback("Organization is at max capacity");
354354
return {
@@ -364,7 +364,7 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean
364364
return notFound();
365365
}
366366

367-
const addUserToOrgRes = await addMember(invite.orgId, user.id, {
367+
const addUserToOrgRes = await ensureActiveMember(invite.orgId, user.id, {
368368
actor: { id: user.id, type: "user" },
369369
role: await getDefaultMemberRole(),
370370
});

packages/web/src/features/membership/membership.service.test.ts

Lines changed: 33 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { beforeEach, describe, expect, test, vi } from 'vitest';
2-
import { addMember, removeMember, setMemberRole, setMemberActive } from './membership.service';
2+
import { ensureActiveMember, removeMember, setMemberRole, setMemberActive } from './membership.service';
33
import { prisma, MOCK_USER_WITH_ACCOUNTS } from '@/__mocks__/prisma';
44
import { OrgRole, type UserToOrg } from '@sourcebot/db';
55
import { ErrorCode } from '@/lib/errorCodes';
@@ -46,14 +46,14 @@ beforeEach(() => {
4646
(prisma.$transaction as any).mockImplementation(async (cb: any) => cb(prisma));
4747
});
4848

49-
describe('addMember', () => {
49+
describe('ensureActiveMember', () => {
5050
test('creates a new active membership when none exists', async () => {
5151
const created = makeMembership();
5252
prisma.user.findUnique.mockResolvedValue(mockUser);
5353
prisma.userToOrg.findUnique.mockResolvedValue(null);
5454
prisma.userToOrg.create.mockResolvedValue(created);
5555

56-
const result = await addMember(ORG_ID, USER_ID, { actor: ACTOR, role: OrgRole.MEMBER });
56+
const result = await ensureActiveMember(ORG_ID, USER_ID, { actor: ACTOR, role: OrgRole.MEMBER });
5757

5858
expect(isServiceError(result)).toBe(false);
5959
expect(result).toEqual(created);
@@ -71,7 +71,7 @@ describe('addMember', () => {
7171
prisma.userToOrg.findUnique.mockResolvedValue(null);
7272
prisma.userToOrg.create.mockResolvedValue(makeMembership({ scimExternalId: 'ext-1' }));
7373

74-
await addMember(ORG_ID, USER_ID, { actor: ACTOR, role: OrgRole.MEMBER, scimExternalId: 'ext-1' });
74+
await ensureActiveMember(ORG_ID, USER_ID, { actor: ACTOR, role: OrgRole.MEMBER, scimExternalId: 'ext-1' });
7575

7676
expect(prisma.userToOrg.create).toHaveBeenCalledWith(
7777
expect.objectContaining({ data: expect.objectContaining({ scimExternalId: 'ext-1' }) }),
@@ -83,7 +83,7 @@ describe('addMember', () => {
8383
prisma.userToOrg.findUnique.mockResolvedValue(null);
8484
prisma.userToOrg.create.mockResolvedValue(makeMembership());
8585

86-
await addMember(ORG_ID, USER_ID, { actor: ACTOR, role: OrgRole.MEMBER });
86+
await ensureActiveMember(ORG_ID, USER_ID, { actor: ACTOR, role: OrgRole.MEMBER });
8787

8888
expect(prisma.accountRequest.deleteMany).toHaveBeenCalledWith({ where: { requestedById: USER_ID, orgId: ORG_ID } });
8989
expect(prisma.invite.deleteMany).toHaveBeenCalledWith({ where: { recipientEmail: mockUser.email, orgId: ORG_ID } });
@@ -94,33 +94,38 @@ describe('addMember', () => {
9494
prisma.user.findUnique.mockResolvedValue(mockUser);
9595
prisma.userToOrg.findUnique.mockResolvedValue(existing);
9696

97-
const result = await addMember(ORG_ID, USER_ID, { actor: ACTOR, role: OrgRole.MEMBER });
97+
const result = await ensureActiveMember(ORG_ID, USER_ID, { actor: ACTOR, role: OrgRole.MEMBER });
9898

9999
expect(result).toEqual(existing);
100100
expect(prisma.userToOrg.create).not.toHaveBeenCalled();
101101
expect(mocks.createAudit).not.toHaveBeenCalled();
102102
});
103103

104-
test('is a no-op when an INACTIVE membership exists (does not reactivate)', async () => {
104+
test('reactivates an INACTIVE membership (delegates to setMemberActive)', async () => {
105105
const existing = makeMembership({ isActive: false });
106+
const reactivated = makeMembership({ isActive: true });
106107
prisma.user.findUnique.mockResolvedValue(mockUser);
107108
prisma.userToOrg.findUnique.mockResolvedValue(existing);
109+
prisma.userToOrg.update.mockResolvedValue(reactivated);
110+
mocks.orgHasAvailability.mockResolvedValue(true);
108111

109-
const result = await addMember(ORG_ID, USER_ID, { actor: ACTOR, role: OrgRole.MEMBER });
112+
const result = await ensureActiveMember(ORG_ID, USER_ID, { actor: ACTOR, role: OrgRole.MEMBER });
110113

111-
expect(result).toEqual(existing);
112114
expect(isServiceError(result)).toBe(false);
115+
expect(result).toEqual(reactivated);
113116
expect(prisma.userToOrg.create).not.toHaveBeenCalled();
114-
expect(prisma.userToOrg.update).not.toHaveBeenCalled();
115-
expect(mocks.createAudit).not.toHaveBeenCalled();
117+
expect(prisma.userToOrg.update).toHaveBeenCalledWith(
118+
expect.objectContaining({ data: expect.objectContaining({ isActive: true }) }),
119+
);
120+
expect(mocks.createAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'org.member_reactivated' }));
116121
});
117122

118123
test('errors when the org is at seat capacity', async () => {
119124
prisma.user.findUnique.mockResolvedValue(mockUser);
120125
prisma.userToOrg.findUnique.mockResolvedValue(null);
121126
mocks.orgHasAvailability.mockResolvedValue(false);
122127

123-
const result = await addMember(ORG_ID, USER_ID, { actor: ACTOR, role: OrgRole.MEMBER });
128+
const result = await ensureActiveMember(ORG_ID, USER_ID, { actor: ACTOR, role: OrgRole.MEMBER });
124129

125130
expect(isServiceError(result)).toBe(true);
126131
expect((result as ServiceError).errorCode).toBe(ErrorCode.ORG_SEAT_COUNT_REACHED);
@@ -130,7 +135,7 @@ describe('addMember', () => {
130135
test('errors when the user does not exist', async () => {
131136
prisma.user.findUnique.mockResolvedValue(null);
132137

133-
const result = await addMember(ORG_ID, USER_ID, { actor: ACTOR, role: OrgRole.MEMBER });
138+
const result = await ensureActiveMember(ORG_ID, USER_ID, { actor: ACTOR, role: OrgRole.MEMBER });
134139

135140
expect(isServiceError(result)).toBe(true);
136141
expect(prisma.userToOrg.create).not.toHaveBeenCalled();
@@ -248,11 +253,13 @@ describe('setMemberRole', () => {
248253
describe('setMemberActive', () => {
249254
describe('deactivate', () => {
250255
test('deactivates an active member and revokes access', async () => {
256+
const deactivated = makeMembership({ isActive: false });
251257
prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ isActive: true }));
258+
prisma.userToOrg.update.mockResolvedValue(deactivated);
252259

253260
const result = await setMemberActive(ORG_ID, USER_ID, false, { actor: ACTOR });
254261

255-
expect(result).toBeNull();
262+
expect(result).toEqual(deactivated);
256263
expect(prisma.user.update).toHaveBeenCalledWith({ where: { id: USER_ID }, data: { sessionVersion: { increment: 1 } } });
257264
expect(prisma.apiKey.deleteMany).toHaveBeenCalledWith({ where: { createdById: USER_ID, orgId: ORG_ID } });
258265
expect(prisma.oAuthToken.deleteMany).toHaveBeenCalled();
@@ -263,11 +270,12 @@ describe('setMemberActive', () => {
263270
});
264271

265272
test('is a no-op when already inactive', async () => {
266-
prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ isActive: false }));
273+
const existing = makeMembership({ isActive: false });
274+
prisma.userToOrg.findUnique.mockResolvedValue(existing);
267275

268276
const result = await setMemberActive(ORG_ID, USER_ID, false, { actor: ACTOR });
269277

270-
expect(result).toBeNull();
278+
expect(result).toEqual(existing);
271279
expect(prisma.userToOrg.update).not.toHaveBeenCalled();
272280
expect(prisma.user.update).not.toHaveBeenCalled();
273281
expect(mocks.createAudit).not.toHaveBeenCalled();
@@ -285,12 +293,14 @@ describe('setMemberActive', () => {
285293

286294
describe('reactivate', () => {
287295
test('reactivates an inactive member when a seat is available', async () => {
296+
const reactivated = makeMembership({ isActive: true, scimExternalId: 'ext-1' });
288297
prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ isActive: false }));
298+
prisma.userToOrg.update.mockResolvedValue(reactivated);
289299
mocks.orgHasAvailability.mockResolvedValue(true);
290300

291301
const result = await setMemberActive(ORG_ID, USER_ID, true, { actor: ACTOR, scimExternalId: 'ext-1' });
292302

293-
expect(result).toBeNull();
303+
expect(result).toEqual(reactivated);
294304
expect(prisma.userToOrg.update).toHaveBeenCalledWith(
295305
expect.objectContaining({ data: expect.objectContaining({ isActive: true, scimExternalId: 'ext-1' }) }),
296306
);
@@ -309,22 +319,25 @@ describe('setMemberActive', () => {
309319
});
310320

311321
test('is a no-op when already active (no audit, no seat check)', async () => {
312-
prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ isActive: true, scimExternalId: 'ext-1' }));
322+
const existing = makeMembership({ isActive: true, scimExternalId: 'ext-1' });
323+
prisma.userToOrg.findUnique.mockResolvedValue(existing);
313324

314325
const result = await setMemberActive(ORG_ID, USER_ID, true, { actor: ACTOR, scimExternalId: 'ext-1' });
315326

316-
expect(result).toBeNull();
327+
expect(result).toEqual(existing);
317328
expect(prisma.userToOrg.update).not.toHaveBeenCalled();
318329
expect(mocks.orgHasAvailability).not.toHaveBeenCalled();
319330
expect(mocks.createAudit).not.toHaveBeenCalled();
320331
});
321332

322333
test('refreshes externalId when already active and it changed', async () => {
334+
const refreshed = makeMembership({ isActive: true, scimExternalId: 'new' });
323335
prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ isActive: true, scimExternalId: 'old' }));
336+
prisma.userToOrg.update.mockResolvedValue(refreshed);
324337

325338
const result = await setMemberActive(ORG_ID, USER_ID, true, { actor: ACTOR, scimExternalId: 'new' });
326339

327-
expect(result).toBeNull();
340+
expect(result).toEqual(refreshed);
328341
expect(prisma.userToOrg.update).toHaveBeenCalledWith(
329342
expect.objectContaining({ data: { scimExternalId: 'new' } }),
330343
);

0 commit comments

Comments
 (0)