diff --git a/server/src/__tests__/invite-summary-route.test.ts b/server/src/__tests__/invite-summary-route.test.ts index 25e3b80ad25..6fd7172af69 100644 --- a/server/src/__tests__/invite-summary-route.test.ts +++ b/server/src/__tests__/invite-summary-route.test.ts @@ -15,11 +15,17 @@ import { errorHandler } from "../middleware/index.js"; function createSelectChain(rows: unknown[]) { const query = { + then(resolve: (value: unknown[]) => unknown) { + return Promise.resolve(rows).then(resolve); + }, leftJoin() { return query; }, + orderBy() { + return query; + }, where() { - return Promise.resolve(rows); + return query; }, }; return { @@ -40,10 +46,13 @@ function createDbStub(...selectResponses: unknown[][]) { }; } -function createApp(db: Record) { +function createApp( + db: Record, + actor: Record = { type: "anon" }, +) { const app = express(); app.use((req, _res, next) => { - (req as any).actor = { type: "anon" }; + (req as any).actor = actor; next(); }); app.use( @@ -202,4 +211,60 @@ describe("GET /invites/:token", () => { expect(res.body.joinRequestType).toBe("human"); expect(res.body.companyName).toBe("Acme Robotics"); }); + + it("falls back to a reusable human join request when the accepted invite reused an existing queue entry", async () => { + const invite = { + id: "invite-2", + companyId: "company-1", + inviteType: "company_join", + allowedJoinTypes: "human", + tokenHash: "hash", + defaultsPayload: null, + expiresAt: new Date("2027-03-07T00:10:00.000Z"), + invitedByUserId: null, + revokedAt: null, + acceptedAt: new Date("2026-03-07T00:05:00.000Z"), + createdAt: new Date("2026-03-07T00:00:00.000Z"), + updatedAt: new Date("2026-03-07T00:05:00.000Z"), + }; + const app = createApp( + createDbStub( + [invite], + [], + [{ email: "jane@example.com" }], + [ + { + id: "join-1", + requestType: "human", + status: "pending_approval", + requestingUserId: "user-1", + requestEmailSnapshot: "jane@example.com", + }, + ], + [ + { + name: "Acme Robotics", + brandColor: "#114488", + logoAssetId: "logo-1", + }, + ], + [ + { + companyId: "company-1", + objectKey: "company-1/assets/companies/logo-1", + contentType: "image/png", + byteSize: 3, + originalFilename: "logo.png", + }, + ], + ), + { type: "board", userId: "user-1", source: "session" }, + ); + + const res = await request(app).get("/api/invites/pcp_invite_test"); + + expect(res.status).toBe(200); + expect(res.body.joinRequestStatus).toBe("pending_approval"); + expect(res.body.joinRequestType).toBe("human"); + }); }); diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index 80586897a5c..7c60eb3480a 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -1788,6 +1788,57 @@ async function resolveActorEmail(db: Db, req: Request): Promise { return user?.email ?? null; } +async function resolveAcceptedInviteJoinRequest( + db: Db, + req: Request, + invite: typeof invites.$inferSelect | null, +) { + if (!invite?.acceptedAt) return null; + + const directJoinRequest = await db + .select({ + requestType: joinRequests.requestType, + status: joinRequests.status, + requestingUserId: joinRequests.requestingUserId, + requestEmailSnapshot: joinRequests.requestEmailSnapshot, + }) + .from(joinRequests) + .where(eq(joinRequests.inviteId, invite.id)) + .then((rows) => rows[0] ?? null); + if (directJoinRequest) return directJoinRequest; + + if (!invite.companyId) return null; + + const actorRequestingUserId = isLocalImplicit(req) + ? "local-board" + : req.actor.userId ?? null; + const actorEmail = await resolveActorEmail(db, req); + if (!actorRequestingUserId && !actorEmail) return null; + + return findReusableHumanJoinRequest( + await db + .select({ + id: joinRequests.id, + requestType: joinRequests.requestType, + status: joinRequests.status, + requestingUserId: joinRequests.requestingUserId, + requestEmailSnapshot: joinRequests.requestEmailSnapshot, + }) + .from(joinRequests) + .where( + and( + eq(joinRequests.companyId, invite.companyId), + eq(joinRequests.requestType, "human"), + ), + ) + .orderBy(desc(joinRequests.createdAt)), + { + requestingUserId: actorRequestingUserId, + requestEmailSnapshot: actorEmail, + }, + ); +} + function grantsFromDefaults( defaultsPayload: Record | null | undefined, key: "human" | "agent" @@ -2529,16 +2580,7 @@ export function accessRoutes( .from(invites) .where(eq(invites.tokenHash, hashToken(token))) .then((rows) => rows[0] ?? null); - const inviteJoinRequest = invite?.acceptedAt - ? await db - .select({ - requestType: joinRequests.requestType, - status: joinRequests.status, - }) - .from(joinRequests) - .where(eq(joinRequests.inviteId, invite.id)) - .then((rows) => rows[0] ?? null) - : null; + const inviteJoinRequest = await resolveAcceptedInviteJoinRequest(db, req, invite); if ( !invite || invite.revokedAt || @@ -2570,7 +2612,13 @@ export function accessRoutes( .from(invites) .where(eq(invites.tokenHash, hashToken(token))) .then((rows) => rows[0] ?? null); - if (!invite || invite.revokedAt || invite.acceptedAt || inviteExpired(invite)) { + const inviteJoinRequest = await resolveAcceptedInviteJoinRequest(db, req, invite); + if ( + !invite || + invite.revokedAt || + inviteExpired(invite) || + (invite.acceptedAt && !inviteJoinRequest) + ) { throw notFound("Invite not found"); }