Skip to content

Commit a794057

Browse files
committed
fix(webapp): fold case when detecting existing members and pending invites
Canonicalising submitted addresses to lowercase meant inviteMembers' exact comparisons stopped matching a stored member or invite whose address differs only by case, so an admin re-entering that address got a redundant invite emailed to someone already in the org. Fold both sides of the member lookup, and check for a pending invite the same way rather than leaning on the case-sensitive org+email constraint alone. Also normalise addresses in the PAT invite API so both entry points agree.
1 parent 8f9d82a commit a794057

3 files changed

Lines changed: 96 additions & 3 deletions

File tree

‎apps/webapp/app/models/member.server.ts‎

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -135,11 +135,25 @@ export async function inviteMembers({
135135
const existingMembers = await prisma.orgMember.findMany({
136136
where: {
137137
organizationId: org.id,
138-
user: { email: { in: boundedIn([...uniqueEmails]) } },
138+
user: { email: { in: boundedIn([...uniqueEmails]), mode: "insensitive" } },
139139
},
140140
select: { user: { select: { email: true } } },
141141
});
142-
const existingMemberEmails = new Set(existingMembers.map((member) => member.user.email));
142+
// Compare folded: stored account emails and invite rows can both carry
143+
// casing the caller didn't type, and the unique org+email constraint the
144+
// P2002 below relies on is itself case-sensitive.
145+
const existingMemberEmails = new Set(
146+
existingMembers.map((member) => member.user.email.toLowerCase())
147+
);
148+
149+
const pendingInvites = await prisma.orgMemberInvite.findMany({
150+
where: {
151+
organizationId: org.id,
152+
email: { in: boundedIn([...uniqueEmails]), mode: "insensitive" },
153+
},
154+
select: { email: true },
155+
});
156+
const pendingInviteEmails = new Set(pendingInvites.map((invite) => invite.email.toLowerCase()));
143157

144158
// Create one invite per unique email and return ONLY the invites actually
145159
// created by this call. A P2002 means the email is already invited to this org
@@ -153,11 +167,18 @@ export async function inviteMembers({
153167
const alreadyInvited: string[] = [];
154168

155169
for (const email of uniqueEmails) {
156-
if (existingMemberEmails.has(email)) {
170+
const folded = email.toLowerCase();
171+
172+
if (existingMemberEmails.has(folded)) {
157173
alreadyMembers.push(email);
158174
continue;
159175
}
160176

177+
if (pendingInviteEmails.has(folded)) {
178+
alreadyInvited.push(email);
179+
continue;
180+
}
181+
161182
try {
162183
const invite = await prisma.orgMemberInvite.create({
163184
data: {

‎apps/webapp/app/routes/api.v1.orgs.$orgParam.invites.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ const ParamsSchema = z.object({
1818
const InviteRequestBody = z.object({
1919
emails: z
2020
.string()
21+
.trim()
22+
.toLowerCase()
2123
.email()
2224
.array()
2325
.nonempty("At least one email is required")

‎apps/webapp/test/inviteEmailCase.test.ts‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,4 +167,74 @@ describe("invite email casing", () => {
167167
expect(stillThere).not.toBeNull();
168168
}
169169
);
170+
postgresTest(
171+
"inviteMembers does not re-invite a member whose account email differs only by case",
172+
{ timeout: 60_000 },
173+
async ({ prisma }) => {
174+
prismaHolder.client = prisma;
175+
const { inviteMembers } = await import("../app/models/member.server");
176+
177+
const suffix = randomHex(8);
178+
const owner = await prisma.user.create({
179+
data: { email: `owner-${suffix}@example.test`, authenticationMethod: "MAGIC_LINK" },
180+
});
181+
const member = await prisma.user.create({
182+
data: { email: `Member-${suffix}@Example.test`, authenticationMethod: "MAGIC_LINK" },
183+
});
184+
const organization = await prisma.organization.create({
185+
data: {
186+
title: `invite-dupe-org-${suffix}`,
187+
slug: `invite-dupe-org-${suffix}`,
188+
isActivated: true,
189+
members: {
190+
create: [
191+
{ userId: owner.id, role: "ADMIN" },
192+
{ userId: member.id, role: "MEMBER" },
193+
],
194+
},
195+
},
196+
});
197+
198+
// Both entry points fold the address before it reaches inviteMembers, so
199+
// this is what an admin typing the member's own address actually sends.
200+
const result = await inviteMembers({
201+
slug: organization.slug,
202+
emails: [`member-${suffix}@example.test`],
203+
userId: owner.id,
204+
});
205+
206+
expect(result.created).toHaveLength(0);
207+
expect(result.alreadyMembers).toHaveLength(1);
208+
}
209+
);
210+
211+
postgresTest(
212+
"inviteMembers does not duplicate a pending invite that differs only by case",
213+
{ timeout: 60_000 },
214+
async ({ prisma }) => {
215+
prismaHolder.client = prisma;
216+
const { inviteMembers } = await import("../app/models/member.server");
217+
218+
const { organization, inviter, invitee, accountEmail } = await seedMixedCaseInvite(prisma);
219+
void invitee;
220+
221+
const before = await prisma.orgMemberInvite.count({
222+
where: { organizationId: organization.id },
223+
});
224+
225+
const result = await inviteMembers({
226+
slug: organization.slug,
227+
emails: [accountEmail],
228+
userId: inviter.id,
229+
});
230+
231+
expect(result.created).toHaveLength(0);
232+
expect(result.alreadyInvited).toHaveLength(1);
233+
234+
const after = await prisma.orgMemberInvite.count({
235+
where: { organizationId: organization.id },
236+
});
237+
expect(after).toBe(before);
238+
}
239+
);
170240
});

0 commit comments

Comments
 (0)