Skip to content

Commit fdf3c05

Browse files
committed
fix(webapp): only email newly-created invites, report re-invites accurately
inviteMembers now creates one invite per email and returns ONLY the invites it actually created (per-email create, P2002 = already invited → skipped), instead of createMany + a broad findMany that returned pre-existing rows too. Callers email exactly what they created, so re-inviting an already-pending address no longer re-sends the invite email (re-sending is the dedicated resend flow). The management API now reports { invited, alreadyInvited } and returns 201 only when something was created (200 otherwise), and caps the emails array at 50.
1 parent 9ce880b commit fdf3c05

3 files changed

Lines changed: 86 additions & 57 deletions

File tree

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

Lines changed: 37 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -100,42 +100,44 @@ export async function inviteMembers({
100100
throw new Error("User does not have access to this organization");
101101
}
102102

103-
const invites = [...new Set(emails)].map(
104-
(email) =>
105-
({
106-
email,
107-
token: tokenGenerator(),
108-
organizationId: org.id,
109-
inviterId: userId,
110-
role: "MEMBER",
111-
rbacRoleId: rbacRoleId ?? null,
112-
}) satisfies Prisma.OrgMemberInviteCreateManyInput
113-
);
114-
115-
// Skip already-invited emails (unique org+email) so re-inviting one address
116-
// doesn't P2002 the whole batch.
117-
await prisma.orgMemberInvite.createMany({
118-
data: invites,
119-
skipDuplicates: true,
120-
});
103+
// Create one invite per unique email and return ONLY the invites actually
104+
// created by this call. A P2002 means the email is already invited to this org
105+
// (unique org+email) — skip it so one duplicate can't fail the batch, and
106+
// don't return it: callers email exactly what they created, and re-sending an
107+
// already-pending invite is the dedicated resend flow's job (its own cooldown).
108+
const created: Prisma.OrgMemberInviteGetPayload<{
109+
include: { organization: true; inviter: true };
110+
}>[] = [];
111+
112+
for (const email of new Set(emails)) {
113+
try {
114+
const invite = await prisma.orgMemberInvite.create({
115+
data: {
116+
email,
117+
token: tokenGenerator(),
118+
organizationId: org.id,
119+
inviterId: userId,
120+
role: "MEMBER",
121+
rbacRoleId: rbacRoleId ?? null,
122+
},
123+
include: {
124+
organization: true,
125+
inviter: true,
126+
},
127+
});
128+
created.push(invite);
129+
} catch (error) {
130+
if (
131+
error instanceof PrismaNamespace.PrismaClientKnownRequestError &&
132+
error.code === "P2002"
133+
) {
134+
continue;
135+
}
136+
throw error;
137+
}
138+
}
121139

122-
// Return the invite for each submitted email scoped to the org, NOT to the
123-
// current inviter: with skipDuplicates an email already invited by someone
124-
// else is skipped, and filtering by inviterId here would drop it from the
125-
// result — leaving the caller with a misleading empty list for an email that
126-
// is in fact invited.
127-
return await prisma.orgMemberInvite.findMany({
128-
where: {
129-
organizationId: org.id,
130-
email: {
131-
in: emails,
132-
},
133-
},
134-
include: {
135-
organization: true,
136-
inviter: true,
137-
},
138-
});
140+
return created;
139141
}
140142

141143
export async function getInviteFromToken({ token }: { token: string }) {

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

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,12 @@ const ParamsSchema = z.object({
1515
});
1616

1717
const InviteRequestBody = z.object({
18-
emails: z.string().email().array().nonempty("At least one email is required"),
18+
emails: z
19+
.string()
20+
.email()
21+
.array()
22+
.nonempty("At least one email is required")
23+
.max(50, "At most 50 emails per request"),
1924
});
2025

2126
export const action = createActionPATApiRoute(
@@ -52,16 +57,17 @@ export const action = createActionPATApiRoute(
5257
return json({ error: "Membership is managed by Directory Sync" }, { status: 403 });
5358
}
5459

55-
const invites = await inviteMembers({
60+
// Returns only the invites created by this call; already-invited emails are
61+
// skipped (re-sending is the dashboard's dedicated resend flow, not this).
62+
const created = await inviteMembers({
5663
slug: organization.slug,
5764
emails: body.emails,
5865
userId: authentication.userId,
5966
});
6067

61-
// Send invite emails the same way the dashboard invite action does. A
62-
// failed send must not fail the invite (the row already exists); in local
63-
// dev with no SMTP config, scheduleEmail's transport logs instead.
64-
for (const invite of invites) {
68+
// Email only the newly-created invites. A failed send must not fail the
69+
// request (the row exists); locally scheduleEmail's transport just logs.
70+
for (const invite of created) {
6571
try {
6672
await scheduleEmail({
6773
email: "invite",
@@ -76,11 +82,20 @@ export const action = createActionPATApiRoute(
7682
}
7783
}
7884

85+
// Report per-email outcome so callers aren't misled by an empty list on
86+
// re-invite. 201 when something was created, 200 when everything already
87+
// existed.
88+
const createdEmails = new Set(created.map((invite) => invite.email));
89+
const alreadyInvited = [...new Set(body.emails)].filter(
90+
(email) => !createdEmails.has(email)
91+
);
92+
7993
return json(
8094
{
81-
invites: invites.map((invite) => ({ id: invite.id, email: invite.email })),
95+
invited: created.map((invite) => ({ id: invite.id, email: invite.email })),
96+
alreadyInvited,
8297
},
83-
{ status: 201 }
98+
{ status: created.length > 0 ? 201 : 200 }
8499
);
85100
}
86101
);

apps/webapp/test/auth-api.e2e.full.test.ts

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3051,36 +3051,44 @@ describe("API", () => {
30513051
});
30523052
});
30533053

3054-
// Invites are idempotent per email: createMany uses skipDuplicates, so
3055-
// re-inviting an already-invited address returns success rather than a
3056-
// P2002-driven 500.
3054+
// Re-invite is idempotent: an already-invited email is skipped (not created,
3055+
// not re-emailed) and reported as alreadyInvited, so a repeat call neither
3056+
// 500s (P2002) nor sends a duplicate invite email.
30573057
describe("Member invites — re-invite is idempotent", () => {
3058-
it("inviting the same email twice does not 500", async () => {
3058+
it("inviting the same email twice: 201 then 200, second reports alreadyInvited", async () => {
30593059
const server = getTestServer();
30603060
const { organization, pat } = await seedTestUserProject(server.prisma);
30613061
const path = `/api/v1/orgs/${organization.id}/invites`;
3062+
const email = "dup-invite@example.com";
30623063
const invite = () =>
30633064
server.webapp.fetch(path, {
30643065
method: "POST",
30653066
headers: { Authorization: `Bearer ${pat.token}`, "Content-Type": "application/json" },
3066-
body: JSON.stringify({ emails: ["dup-invite@example.com"] }),
3067+
body: JSON.stringify({ emails: [email] }),
30673068
});
30683069

30693070
const first = await invite();
30703071
expect(first.status).toBe(201);
3072+
const firstBody = (await first.json()) as { invited: { email: string }[] };
3073+
expect(firstBody.invited.map((i) => i.email)).toContain(email);
30713074

30723075
const second = await invite();
3073-
expect(second.status).toBe(201);
3074-
expect(second.status).not.toBe(500);
3076+
expect(second.status).toBe(200);
3077+
const secondBody = (await second.json()) as {
3078+
invited: { email: string }[];
3079+
alreadyInvited: string[];
3080+
};
3081+
expect(secondBody.invited).toHaveLength(0);
3082+
expect(secondBody.alreadyInvited).toContain(email);
30753083
});
30763084

3077-
it("re-inviting an email another org member already invited still returns it", async () => {
3085+
it("re-inviting an email another org member already invited reports alreadyInvited", async () => {
30783086
const server = getTestServer();
30793087
const { organization, pat } = await seedTestUserProject(server.prisma);
30803088

3081-
// A different user invited this email first; skipDuplicates will skip the
3082-
// re-insert, so the response must still surface the existing invite rather
3083-
// than an empty list scoped to the current caller.
3089+
// A different user invited this email first: the create hits P2002, so it's
3090+
// skipped (not re-created, not re-emailed) and surfaced as alreadyInvited
3091+
// rather than being reported as a fresh invite.
30843092
const otherUser = await server.prisma.user.create({
30853093
data: {
30863094
email: `other_${organization.id}@example.com`,
@@ -3103,9 +3111,13 @@ describe("API", () => {
31033111
headers: { Authorization: `Bearer ${pat.token}`, "Content-Type": "application/json" },
31043112
body: JSON.stringify({ emails: [sharedEmail] }),
31053113
});
3106-
expect(res.status).toBe(201);
3107-
const body = (await res.json()) as { invites: { email: string }[] };
3108-
expect(body.invites.map((i) => i.email)).toContain(sharedEmail);
3114+
expect(res.status).toBe(200);
3115+
const body = (await res.json()) as {
3116+
invited: { email: string }[];
3117+
alreadyInvited: string[];
3118+
};
3119+
expect(body.invited).toHaveLength(0);
3120+
expect(body.alreadyInvited).toContain(sharedEmail);
31093121
});
31103122
});
31113123

0 commit comments

Comments
 (0)