From a124ebe30cd9f4c6fbbe8e7e86b199f4afd92eb8 Mon Sep 17 00:00:00 2001 From: Jordan Simonovski Date: Tue, 23 Jun 2026 15:05:53 +1000 Subject: [PATCH 1/5] feat: v2 API for team managment --- packages/api/openapi.json | 119 ++++++++ .../external-api/__tests__/team.test.ts | 97 ++++++ .../api/src/routers/external-api/v2/index.ts | 3 + .../api/src/routers/external-api/v2/team.ts | 282 ++++++++++++++++++ 4 files changed, 501 insertions(+) create mode 100644 packages/api/src/routers/external-api/__tests__/team.test.ts create mode 100644 packages/api/src/routers/external-api/v2/team.ts diff --git a/packages/api/openapi.json b/packages/api/openapi.json index d53c4056a5..46e59785e9 100644 --- a/packages/api/openapi.json +++ b/packages/api/openapi.json @@ -5896,6 +5896,125 @@ } } }, + "/api/v2/team": { + "get": { + "summary": "Get Team", + "description": "Retrieves the authenticated team's basic settings.", + "operationId": "getTeam", + "tags": [ + "Team" + ], + "responses": { + "200": { + "description": "Successfully retrieved team" + }, + "403": { + "description": "Forbidden" + } + } + } + }, + "/api/v2/team/members": { + "get": { + "summary": "List Team Members", + "description": "Retrieves the team's members.", + "operationId": "listTeamMembers", + "tags": [ + "Team" + ], + "responses": { + "200": { + "description": "Successfully retrieved members" + } + } + } + }, + "/api/v2/team/invitation": { + "post": { + "summary": "Invite Team Member", + "description": "Creates a pending invitation for a new email address.", + "operationId": "inviteTeamMember", + "tags": [ + "Team" + ], + "responses": { + "200": { + "description": "Successfully created the invitation" + }, + "400": { + "description": "User already exists" + } + } + } + }, + "/api/v2/team/invitations": { + "get": { + "summary": "List Team Invitations", + "description": "Retrieves the team's pending invitations.", + "operationId": "listTeamInvitations", + "tags": [ + "Team" + ], + "responses": { + "200": { + "description": "Successfully retrieved invitations" + } + } + } + }, + "/api/v2/team/invitation/{id}": { + "delete": { + "summary": "Delete Team Invitation", + "description": "Deletes a pending team invitation.", + "operationId": "deleteTeamInvitation", + "tags": [ + "Team" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successfully deleted the invitation" + }, + "404": { + "description": "Invitation not found" + } + } + } + }, + "/api/v2/team/member/{id}": { + "delete": { + "summary": "Remove Team Member", + "description": "Removes a member from the team.", + "operationId": "removeTeamMember", + "tags": [ + "Team" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successfully removed the member" + } + } + } + }, "/api/v2/webhooks": { "get": { "summary": "List Webhooks", diff --git a/packages/api/src/routers/external-api/__tests__/team.test.ts b/packages/api/src/routers/external-api/__tests__/team.test.ts new file mode 100644 index 0000000000..253e356239 --- /dev/null +++ b/packages/api/src/routers/external-api/__tests__/team.test.ts @@ -0,0 +1,97 @@ +import request, { SuperAgentTest } from 'supertest'; + +import { getLoggedInAgent, getServer } from '../../../fixtures'; +import { ITeam } from '../../../models/team'; +import { IUser } from '../../../models/user'; + +const TEAM_BASE_URL = '/api/v2/team'; + +describe('External API v2 Team', () => { + const server = getServer(); + let agent: SuperAgentTest; + let team: ITeam; + let user: IUser; + + beforeAll(async () => { + await server.start(); + }); + + beforeEach(async () => { + const result = await getLoggedInAgent(server); + agent = result.agent; + team = result.team; + user = result.user; + }); + + afterEach(async () => { + await server.clearDBs(); + }); + + afterAll(async () => { + await server.stop(); + }); + + const authRequest = ( + method: 'get' | 'post' | 'put' | 'delete', + url: string, + ) => agent[method](url).set('Authorization', `Bearer ${user?.accessKey}`); + + describe('GET /api/v2/team', () => { + it('returns the team id and name', async () => { + const response = await authRequest('get', TEAM_BASE_URL).expect(200); + expect(response.body.data.id).toBe(team._id.toString()); + expect(response.body.data).toHaveProperty('name'); + }); + + it('requires authentication', async () => { + await request(server.getHttpServer()).get(TEAM_BASE_URL).expect(401); + }); + }); + + describe('GET /api/v2/team/members', () => { + it('returns the current user', async () => { + const response = await authRequest( + 'get', + `${TEAM_BASE_URL}/members`, + ).expect(200); + const me = response.body.data.find( + (m: { email: string }) => m.email === user.email, + ); + expect(me).toBeTruthy(); + expect(me.isCurrentUser).toBe(true); + }); + }); + + describe('Invitations', () => { + it('creates a pending invitation and lists then deletes it', async () => { + const inviteRes = await authRequest('post', `${TEAM_BASE_URL}/invitation`) + .send({ email: 'oss-invitee@deploysentinel.com' }) + .expect(200); + expect(inviteRes.body.data.status).toBe('pending'); + expect(inviteRes.body.data.url).toContain('/join-team?token='); + const invitationId = inviteRes.body.data.invitationId; + + const listRes = await authRequest( + 'get', + `${TEAM_BASE_URL}/invitations`, + ).expect(200); + expect( + listRes.body.data.some( + (i: { email: string }) => + i.email === 'oss-invitee@deploysentinel.com', + ), + ).toBe(true); + + await authRequest( + 'delete', + `${TEAM_BASE_URL}/invitation/${invitationId}`, + ).expect(200); + }); + + it('rejects inviting an existing user', async () => { + await authRequest('post', `${TEAM_BASE_URL}/invitation`) + .send({ email: user.email }) + .expect(400); + }); + }); +}); diff --git a/packages/api/src/routers/external-api/v2/index.ts b/packages/api/src/routers/external-api/v2/index.ts index 59fdf95dc1..bf3adbce73 100644 --- a/packages/api/src/routers/external-api/v2/index.ts +++ b/packages/api/src/routers/external-api/v2/index.ts @@ -7,6 +7,7 @@ import connectionsRouter from '@/routers/external-api/v2/connections'; import dashboardRouter from '@/routers/external-api/v2/dashboards'; import searchRouter from '@/routers/external-api/v2/search'; import sourcesRouter from '@/routers/external-api/v2/sources'; +import teamRouter from '@/routers/external-api/v2/team'; import webhooksRouter from '@/routers/external-api/v2/webhooks'; import rateLimiter, { rateLimiterKeyGenerator } from '@/utils/rateLimiter'; @@ -61,4 +62,6 @@ router.use( webhooksRouter, ); +router.use('/team', defaultRateLimiter, validateUserAccessKey, teamRouter); + export default router; diff --git a/packages/api/src/routers/external-api/v2/team.ts b/packages/api/src/routers/external-api/v2/team.ts new file mode 100644 index 0000000000..e4def633d3 --- /dev/null +++ b/packages/api/src/routers/external-api/v2/team.ts @@ -0,0 +1,282 @@ +import crypto from 'crypto'; +import express from 'express'; +import { z } from 'zod'; +import { validateRequest } from 'zod-express-middleware'; + +import * as config from '@/config'; +import { getTeam } from '@/controllers/team'; +import { + deleteTeamMember, + findUserByEmail, + findUsersByTeam, +} from '@/controllers/user'; +import TeamInvite from '@/models/teamInvite'; +import { objectIdSchema } from '@/utils/zod'; + +const router = express.Router(); + +/** + * @openapi + * /api/v2/team: + * get: + * summary: Get Team + * description: Retrieves the authenticated team's basic settings. + * operationId: getTeam + * tags: [Team] + * responses: + * '200': + * description: Successfully retrieved team + * '403': + * description: Forbidden + */ +router.get('/', async (req, res, next) => { + try { + const teamId = req.user?.team; + if (teamId == null) { + return res.sendStatus(403); + } + + const team = await getTeam(teamId.toString()); + if (team == null) { + return res.sendStatus(404); + } + + return res.json({ + data: { + id: team._id.toString(), + name: team.name, + }, + }); + } catch (e) { + next(e); + } +}); + +/** + * @openapi + * /api/v2/team/members: + * get: + * summary: List Team Members + * description: Retrieves the team's members. + * operationId: listTeamMembers + * tags: [Team] + * responses: + * '200': + * description: Successfully retrieved members + */ +router.get('/members', async (req, res, next) => { + try { + const teamId = req.user?.team; + const userId = req.user?._id; + if (teamId == null) { + return res.sendStatus(403); + } + + const teamUsers = await findUsersByTeam(teamId.toString()); + return res.json({ + data: teamUsers.map(user => ({ + id: user._id.toString(), + email: user.email, + name: user.name, + isCurrentUser: userId != null && user._id.equals(userId), + })), + }); + } catch (e) { + next(e); + } +}); + +/** + * @openapi + * /api/v2/team/invitation: + * post: + * summary: Invite Team Member + * description: Creates a pending invitation for a new email address. + * operationId: inviteTeamMember + * tags: [Team] + * responses: + * '200': + * description: Successfully created the invitation + * '400': + * description: User already exists + */ +router.post( + '/invitation', + validateRequest({ + body: z.object({ + email: z.string().email(), + name: z.string().optional(), + }), + }), + async (req, res, next) => { + try { + const teamId = req.user?.team; + if (teamId == null) { + return res.sendStatus(403); + } + + const { email, name } = req.body; + const normalizedEmail = email.toLowerCase(); + + const existingUser = await findUserByEmail(normalizedEmail); + if (existingUser) { + return res.status(400).json({ + message: + 'User already exists. Please contact HyperDX team for support', + }); + } + + let teamInvite = await TeamInvite.findOne({ + teamId: teamId.toString(), + email: normalizedEmail, + }); + + if (!teamInvite) { + teamInvite = await new TeamInvite({ + teamId: teamId.toString(), + name, + email: normalizedEmail, + token: crypto.randomBytes(32).toString('hex'), + }).save(); + } + + return res.json({ + data: { + email: normalizedEmail, + invitationId: teamInvite._id.toString(), + status: 'pending', + url: `${config.FRONTEND_URL}/join-team?token=${teamInvite.token}`, + }, + }); + } catch (e) { + next(e); + } + }, +); + +/** + * @openapi + * /api/v2/team/invitations: + * get: + * summary: List Team Invitations + * description: Retrieves the team's pending invitations. + * operationId: listTeamInvitations + * tags: [Team] + * responses: + * '200': + * description: Successfully retrieved invitations + */ +router.get('/invitations', async (req, res, next) => { + try { + const teamId = req.user?.team; + if (teamId == null) { + return res.sendStatus(403); + } + + const teamInvites = await TeamInvite.find( + { teamId: teamId.toString() }, + { createdAt: 1, email: 1, name: 1 }, + ); + + return res.json({ + data: teamInvites.map(ti => ({ + id: ti._id.toString(), + createdAt: ti.createdAt, + email: ti.email, + name: ti.name, + })), + }); + } catch (e) { + next(e); + } +}); + +/** + * @openapi + * /api/v2/team/invitation/{id}: + * delete: + * summary: Delete Team Invitation + * description: Deletes a pending team invitation. + * operationId: deleteTeamInvitation + * tags: [Team] + * parameters: + * - name: id + * in: path + * required: true + * schema: + * type: string + * responses: + * '200': + * description: Successfully deleted the invitation + * '404': + * description: Invitation not found + */ +router.delete( + '/invitation/:id', + validateRequest({ params: z.object({ id: objectIdSchema }) }), + async (req, res, next) => { + try { + const teamId = req.user?.team; + if (teamId == null) { + return res.sendStatus(403); + } + + const teamInvite = await TeamInvite.findOneAndDelete({ + _id: req.params.id, + teamId: teamId.toString(), + }); + + if (!teamInvite) { + return res.sendStatus(404); + } + + return res.json({ data: { success: true } }); + } catch (e) { + next(e); + } + }, +); + +/** + * @openapi + * /api/v2/team/member/{id}: + * delete: + * summary: Remove Team Member + * description: Removes a member from the team. + * operationId: removeTeamMember + * tags: [Team] + * parameters: + * - name: id + * in: path + * required: true + * schema: + * type: string + * responses: + * '200': + * description: Successfully removed the member + */ +router.delete( + '/member/:id', + validateRequest({ params: z.object({ id: objectIdSchema }) }), + async (req, res, next) => { + try { + const teamId = req.user?.team; + const requestingUserId = req.user?._id; + if (teamId == null || requestingUserId == null) { + return res.sendStatus(403); + } + + await deleteTeamMember( + teamId.toString(), + req.params.id, + requestingUserId, + ); + + return res.json({ data: { success: true } }); + } catch (e) { + next(e); + } + }, +); + +export default router; From f97f02d5ed3615fbf68af50f043a8ae3dad5b43e Mon Sep 17 00:00:00 2001 From: Jordan Simonovski Date: Tue, 23 Jun 2026 15:07:22 +1000 Subject: [PATCH 2/5] added changeset --- .changeset/breezy-foxes-look.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/breezy-foxes-look.md diff --git a/.changeset/breezy-foxes-look.md b/.changeset/breezy-foxes-look.md new file mode 100644 index 0000000000..267f9b44d2 --- /dev/null +++ b/.changeset/breezy-foxes-look.md @@ -0,0 +1,5 @@ +--- +"@hyperdx/api": minor +--- + +Feat: Added a V2 endpoint for team management. From e08f5963e82c01c87703f5731a075ac867ffdab6 Mon Sep 17 00:00:00 2001 From: Jordan Simonovski Date: Tue, 23 Jun 2026 16:26:47 +1000 Subject: [PATCH 3/5] fixed some bugs brought up by Greptile --- packages/api/openapi.json | 6 ++++++ .../api/src/routers/external-api/v2/team.ts | 19 +++++++++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/api/openapi.json b/packages/api/openapi.json index 46e59785e9..0e28c1aa1b 100644 --- a/packages/api/openapi.json +++ b/packages/api/openapi.json @@ -6011,6 +6011,12 @@ "responses": { "200": { "description": "Successfully removed the member" + }, + "400": { + "description": "Cannot remove yourself from the team" + }, + "404": { + "description": "Member not found" } } } diff --git a/packages/api/src/routers/external-api/v2/team.ts b/packages/api/src/routers/external-api/v2/team.ts index e4def633d3..5c8c80acfe 100644 --- a/packages/api/src/routers/external-api/v2/team.ts +++ b/packages/api/src/routers/external-api/v2/team.ts @@ -175,7 +175,7 @@ router.get('/invitations', async (req, res, next) => { const teamInvites = await TeamInvite.find( { teamId: teamId.toString() }, - { createdAt: 1, email: 1, name: 1 }, + { createdAt: 1, email: 1, name: 1, token: 1 }, ); return res.json({ @@ -184,6 +184,7 @@ router.get('/invitations', async (req, res, next) => { createdAt: ti.createdAt, email: ti.email, name: ti.name, + url: `${config.FRONTEND_URL}/join-team?token=${ti.token}`, })), }); } catch (e) { @@ -254,6 +255,10 @@ router.delete( * responses: * '200': * description: Successfully removed the member + * '400': + * description: Cannot remove yourself from the team + * '404': + * description: Member not found */ router.delete( '/member/:id', @@ -266,12 +271,22 @@ router.delete( return res.sendStatus(403); } - await deleteTeamMember( + if (req.params.id === requestingUserId.toString()) { + return res.status(400).json({ + message: 'You cannot remove yourself from the team', + }); + } + + const deletedUser = await deleteTeamMember( teamId.toString(), req.params.id, requestingUserId, ); + if (!deletedUser) { + return res.sendStatus(404); + } + return res.json({ data: { success: true } }); } catch (e) { next(e); From 845d9a97f282fcd97a62682538843379c4ddd248 Mon Sep 17 00:00:00 2001 From: Jordan Simonovski Date: Wed, 24 Jun 2026 09:08:28 +1000 Subject: [PATCH 4/5] fix: Resolve PR feedback regarding relative pathing, shared invite function, null checks --- .../src/controllers/__tests__/team.test.ts | 16 ++++++++++++- packages/api/src/controllers/team.ts | 4 ++++ packages/api/src/routers/api/team.ts | 6 ++--- .../external-api/__tests__/team.test.ts | 6 ++--- .../api/src/routers/external-api/v2/team.ts | 23 +++++++++---------- 5 files changed, 36 insertions(+), 19 deletions(-) diff --git a/packages/api/src/controllers/__tests__/team.test.ts b/packages/api/src/controllers/__tests__/team.test.ts index a97a06989c..1330ae83ba 100644 --- a/packages/api/src/controllers/__tests__/team.test.ts +++ b/packages/api/src/controllers/__tests__/team.test.ts @@ -1,6 +1,20 @@ -import { createTeam, getTeam, getTeamByApiKey } from '@/controllers/team'; +import * as config from '@/config'; +import { + createTeam, + getTeam, + getTeamByApiKey, + getTeamInviteUrl, +} from '@/controllers/team'; import { clearDBCollections, closeDB, connectDB } from '@/fixtures'; +describe('getTeamInviteUrl', () => { + it('builds the join-team URL for a token', () => { + expect(getTeamInviteUrl('abc123')).toBe( + `${config.FRONTEND_URL}/join-team?token=abc123`, + ); + }); +}); + describe('team controller', () => { beforeAll(async () => { await connectDB(); diff --git a/packages/api/src/controllers/team.ts b/packages/api/src/controllers/team.ts index be3c841a03..1fecce5e0a 100644 --- a/packages/api/src/controllers/team.ts +++ b/packages/api/src/controllers/team.ts @@ -8,6 +8,10 @@ import Dashboard from '@/models/dashboard'; import { SavedSearch } from '@/models/savedSearch'; import Team, { type ITeam, type TeamDocument } from '@/models/team'; +export function getTeamInviteUrl(token: string) { + return `${config.FRONTEND_URL}/join-team?token=${token}`; +} + const LOCAL_APP_TEAM_ID = '_local_team_'; export const LOCAL_APP_TEAM = { _id: new mongoose.Types.ObjectId(LOCAL_APP_TEAM_ID), diff --git a/packages/api/src/routers/api/team.ts b/packages/api/src/routers/api/team.ts index 941a020571..32f8e11ecd 100644 --- a/packages/api/src/routers/api/team.ts +++ b/packages/api/src/routers/api/team.ts @@ -13,10 +13,10 @@ import pick from 'lodash/pick'; import { z } from 'zod'; import { processRequest, validateRequest } from 'zod-express-middleware'; -import * as config from '@/config'; import { getTags, getTeam, + getTeamInviteUrl, rotateTeamApiKey, setTeamName, updateTeamClickhouseSettings, @@ -180,7 +180,7 @@ router.post( } res.json({ - url: `${config.FRONTEND_URL}/join-team?token=${teamInvite.token}`, + url: getTeamInviteUrl(teamInvite.token), }); } catch (e) { next(e); @@ -210,7 +210,7 @@ router.get('/invitations', async (req, res: TeamInviteExpressRes, next) => { createdAt: ti.createdAt.toISOString(), email: ti.email, name: ti.name, - url: `${config.FRONTEND_URL}/join-team?token=${ti.token}`, + url: getTeamInviteUrl(ti.token), })), }); } catch (e) { diff --git a/packages/api/src/routers/external-api/__tests__/team.test.ts b/packages/api/src/routers/external-api/__tests__/team.test.ts index 253e356239..3fdd8b19b9 100644 --- a/packages/api/src/routers/external-api/__tests__/team.test.ts +++ b/packages/api/src/routers/external-api/__tests__/team.test.ts @@ -1,8 +1,8 @@ import request, { SuperAgentTest } from 'supertest'; -import { getLoggedInAgent, getServer } from '../../../fixtures'; -import { ITeam } from '../../../models/team'; -import { IUser } from '../../../models/user'; +import { getLoggedInAgent, getServer } from '@/fixtures'; +import { ITeam } from '@/models/team'; +import { IUser } from '@/models/user'; const TEAM_BASE_URL = '/api/v2/team'; diff --git a/packages/api/src/routers/external-api/v2/team.ts b/packages/api/src/routers/external-api/v2/team.ts index 5c8c80acfe..d2faf15955 100644 --- a/packages/api/src/routers/external-api/v2/team.ts +++ b/packages/api/src/routers/external-api/v2/team.ts @@ -3,8 +3,7 @@ import express from 'express'; import { z } from 'zod'; import { validateRequest } from 'zod-express-middleware'; -import * as config from '@/config'; -import { getTeam } from '@/controllers/team'; +import { getTeam, getTeamInviteUrl } from '@/controllers/team'; import { deleteTeamMember, findUserByEmail, @@ -32,12 +31,12 @@ const router = express.Router(); router.get('/', async (req, res, next) => { try { const teamId = req.user?.team; - if (teamId == null) { + if (!teamId) { return res.sendStatus(403); } const team = await getTeam(teamId.toString()); - if (team == null) { + if (!team) { return res.sendStatus(404); } @@ -68,7 +67,7 @@ router.get('/members', async (req, res, next) => { try { const teamId = req.user?.team; const userId = req.user?._id; - if (teamId == null) { + if (!teamId) { return res.sendStatus(403); } @@ -78,7 +77,7 @@ router.get('/members', async (req, res, next) => { id: user._id.toString(), email: user.email, name: user.name, - isCurrentUser: userId != null && user._id.equals(userId), + isCurrentUser: !!userId && user._id.equals(userId), })), }); } catch (e) { @@ -111,7 +110,7 @@ router.post( async (req, res, next) => { try { const teamId = req.user?.team; - if (teamId == null) { + if (!teamId) { return res.sendStatus(403); } @@ -145,7 +144,7 @@ router.post( email: normalizedEmail, invitationId: teamInvite._id.toString(), status: 'pending', - url: `${config.FRONTEND_URL}/join-team?token=${teamInvite.token}`, + url: getTeamInviteUrl(teamInvite.token), }, }); } catch (e) { @@ -169,7 +168,7 @@ router.post( router.get('/invitations', async (req, res, next) => { try { const teamId = req.user?.team; - if (teamId == null) { + if (!teamId) { return res.sendStatus(403); } @@ -184,7 +183,7 @@ router.get('/invitations', async (req, res, next) => { createdAt: ti.createdAt, email: ti.email, name: ti.name, - url: `${config.FRONTEND_URL}/join-team?token=${ti.token}`, + url: getTeamInviteUrl(ti.token), })), }); } catch (e) { @@ -218,7 +217,7 @@ router.delete( async (req, res, next) => { try { const teamId = req.user?.team; - if (teamId == null) { + if (!teamId) { return res.sendStatus(403); } @@ -267,7 +266,7 @@ router.delete( try { const teamId = req.user?.team; const requestingUserId = req.user?._id; - if (teamId == null || requestingUserId == null) { + if (!teamId || !requestingUserId) { return res.sendStatus(403); } From 5405119c34068d7455d3522be0de404aa827365a Mon Sep 17 00:00:00 2001 From: Jordan Simonovski Date: Wed, 24 Jun 2026 09:11:08 +1000 Subject: [PATCH 5/5] fix: race condition with multiple invitations containing the same email. Added test to validate --- .../external-api/__tests__/team.test.ts | 27 +++++++++++++++++++ .../api/src/routers/external-api/v2/team.ts | 26 +++++++++++++----- 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/packages/api/src/routers/external-api/__tests__/team.test.ts b/packages/api/src/routers/external-api/__tests__/team.test.ts index 3fdd8b19b9..1d8c148829 100644 --- a/packages/api/src/routers/external-api/__tests__/team.test.ts +++ b/packages/api/src/routers/external-api/__tests__/team.test.ts @@ -93,5 +93,32 @@ describe('External API v2 Team', () => { .send({ email: user.email }) .expect(400); }); + + it('handles concurrent invites for the same email idempotently', async () => { + const email = 'race@deploysentinel.com'; + const responses = await Promise.all( + Array.from({ length: 5 }, () => + authRequest('post', `${TEAM_BASE_URL}/invitation`).send({ email }), + ), + ); + + // No request should 500 on the {teamId, email} unique-index race. + for (const res of responses) { + expect(res.status).toBe(200); + } + + // All requests resolve to the same single invitation. + const ids = new Set(responses.map(r => r.body.data.invitationId)); + expect(ids.size).toBe(1); + + const listRes = await authRequest( + 'get', + `${TEAM_BASE_URL}/invitations`, + ).expect(200); + expect( + listRes.body.data.filter((i: { email: string }) => i.email === email) + .length, + ).toBe(1); + }); }); }); diff --git a/packages/api/src/routers/external-api/v2/team.ts b/packages/api/src/routers/external-api/v2/team.ts index d2faf15955..2b5dd7584a 100644 --- a/packages/api/src/routers/external-api/v2/team.ts +++ b/packages/api/src/routers/external-api/v2/team.ts @@ -131,12 +131,26 @@ router.post( }); if (!teamInvite) { - teamInvite = await new TeamInvite({ - teamId: teamId.toString(), - name, - email: normalizedEmail, - token: crypto.randomBytes(32).toString('hex'), - }).save(); + try { + teamInvite = await new TeamInvite({ + teamId: teamId.toString(), + name, + email: normalizedEmail, + token: crypto.randomBytes(32).toString('hex'), + }).save(); + } catch (err: any) { + // ponytail: concurrent insert lost the race on the {teamId, email} + // unique index — re-fetch the winner's doc to stay idempotent. + if (err?.code === 11000) { + teamInvite = await TeamInvite.findOne({ + teamId: teamId.toString(), + email: normalizedEmail, + }); + } + if (!teamInvite) { + throw err; + } + } } return res.json({