diff --git a/server/src/__tests__/join-request-dedupe.test.ts b/server/src/__tests__/join-request-dedupe.test.ts new file mode 100644 index 00000000000..133d6ae9a61 --- /dev/null +++ b/server/src/__tests__/join-request-dedupe.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; +import { + collapseDuplicatePendingHumanJoinRequests, + findReusableHumanJoinRequest, +} from "../lib/join-request-dedupe.js"; + +describe("findReusableHumanJoinRequest", () => { + it("reuses the newest pending request for the same user", () => { + const rows = [ + { + id: "pending-new", + requestType: "human", + status: "pending_approval", + requestingUserId: "user-1", + requestEmailSnapshot: "person@example.com", + }, + { + id: "pending-old", + requestType: "human", + status: "pending_approval", + requestingUserId: "user-1", + requestEmailSnapshot: "person@example.com", + }, + { + id: "other-user", + requestType: "human", + status: "pending_approval", + requestingUserId: "user-2", + requestEmailSnapshot: "other@example.com", + }, + ] as const; + + expect( + findReusableHumanJoinRequest(rows, { + requestingUserId: "user-1", + requestEmailSnapshot: "person@example.com", + })?.id + ).toBe("pending-new"); + }); + + it("falls back to email matching when the user id is unavailable", () => { + const rows = [ + { + id: "approved-existing", + requestType: "human", + status: "approved", + requestingUserId: null, + requestEmailSnapshot: "Person@Example.com", + }, + { + id: "agent-request", + requestType: "agent", + status: "pending_approval", + requestingUserId: null, + requestEmailSnapshot: null, + }, + ] as const; + + expect( + findReusableHumanJoinRequest(rows, { + requestingUserId: null, + requestEmailSnapshot: "person@example.com", + })?.id + ).toBe("approved-existing"); + }); +}); + +describe("collapseDuplicatePendingHumanJoinRequests", () => { + it("keeps only the newest pending human row per requester", () => { + const rows = [ + { + id: "human-new", + requestType: "human", + status: "pending_approval", + requestingUserId: "user-1", + requestEmailSnapshot: "person@example.com", + }, + { + id: "human-old", + requestType: "human", + status: "pending_approval", + requestingUserId: "user-1", + requestEmailSnapshot: "person@example.com", + }, + { + id: "approved-history", + requestType: "human", + status: "approved", + requestingUserId: "user-1", + requestEmailSnapshot: "person@example.com", + }, + { + id: "agent-pending", + requestType: "agent", + status: "pending_approval", + requestingUserId: null, + requestEmailSnapshot: null, + }, + ] as const; + + expect(collapseDuplicatePendingHumanJoinRequests(rows).map((row) => row.id)) + .toEqual(["human-new", "approved-history", "agent-pending"]); + }); +}); diff --git a/server/src/lib/join-request-dedupe.ts b/server/src/lib/join-request-dedupe.ts new file mode 100644 index 00000000000..249ef11f657 --- /dev/null +++ b/server/src/lib/join-request-dedupe.ts @@ -0,0 +1,88 @@ +import { joinRequests } from "@paperclipai/db"; + +type JoinRequestLike = Pick< + typeof joinRequests.$inferSelect, + | "id" + | "requestType" + | "status" + | "requestingUserId" + | "requestEmailSnapshot" + | "createdAt" + | "updatedAt" +>; + +function nonEmptyTrimmed(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + return trimmed ? trimmed : null; +} + +export function normalizeJoinRequestEmail( + email: string | null | undefined +): string | null { + const trimmed = nonEmptyTrimmed(email); + return trimmed ? trimmed.toLowerCase() : null; +} + +export function humanJoinRequestIdentity( + row: Pick< + JoinRequestLike, + "requestType" | "requestingUserId" | "requestEmailSnapshot" + > +): string | null { + if (row.requestType !== "human") return null; + const requestingUserId = nonEmptyTrimmed(row.requestingUserId); + if (requestingUserId) return `user:${requestingUserId}`; + const email = normalizeJoinRequestEmail(row.requestEmailSnapshot); + return email ? `email:${email}` : null; +} + +export function findReusableHumanJoinRequest< + T extends Pick< + JoinRequestLike, + "id" | "requestType" | "status" | "requestingUserId" | "requestEmailSnapshot" + >, +>( + rows: T[], + actor: { requestingUserId?: string | null; requestEmailSnapshot?: string | null } +): T | null { + const actorUserId = nonEmptyTrimmed(actor.requestingUserId); + if (actorUserId) { + const sameUser = rows.find( + (row) => + row.requestType === "human" && + (row.status === "pending_approval" || row.status === "approved") && + row.requestingUserId === actorUserId + ); + if (sameUser) return sameUser; + } + + const actorEmail = normalizeJoinRequestEmail(actor.requestEmailSnapshot); + if (!actorEmail) return null; + return ( + rows.find( + (row) => + row.requestType === "human" && + (row.status === "pending_approval" || row.status === "approved") && + normalizeJoinRequestEmail(row.requestEmailSnapshot) === actorEmail + ) ?? null + ); +} + +export function collapseDuplicatePendingHumanJoinRequests< + T extends Pick< + JoinRequestLike, + "id" | "requestType" | "status" | "requestingUserId" | "requestEmailSnapshot" + >, +>(rows: T[]): T[] { + const seen = new Set(); + return rows.filter((row) => { + if (row.requestType !== "human" || row.status !== "pending_approval") { + return true; + } + const identity = humanJoinRequestIdentity(row); + if (!identity) return true; + if (seen.has(identity)) return false; + seen.add(identity); + return true; + }); +} diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index 9effc3f46ca..444ab905c90 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -60,6 +60,10 @@ import { normalizeHumanRole, resolveHumanInviteRole, } from "../services/company-member-roles.js"; +import { + collapseDuplicatePendingHumanJoinRequests, + findReusableHumanJoinRequest, +} from "../lib/join-request-dedupe.js"; import { assertCompanyAccess } from "./authz.js"; import { claimBoardOwnership, @@ -1060,11 +1064,13 @@ async function loadCompanyInviteRecords(db: Db, companyId: string) { } async function loadJoinRequestRecords(db: Db, companyId: string) { - const rows = await db - .select() - .from(joinRequests) - .where(eq(joinRequests.companyId, companyId)) - .orderBy(desc(joinRequests.createdAt)); + const rows = collapseDuplicatePendingHumanJoinRequests( + await db + .select() + .from(joinRequests) + .where(eq(joinRequests.companyId, companyId)) + .orderBy(desc(joinRequests.createdAt)) + ); const inviteIds = [...new Set(rows.map((row) => row.inviteId))]; const inviteRows = inviteIds.length ? await db @@ -2783,48 +2789,82 @@ export function accessRoutes( const actorEmail = requestType === "human" ? await resolveActorEmail(db, req) : null; - const created = !inviteAlreadyAccepted - ? await db.transaction(async (tx) => { - await tx - .update(invites) - .set({ acceptedAt: new Date(), updatedAt: new Date() }) - .where( - and( - eq(invites.id, invite.id), - isNull(invites.acceptedAt), - isNull(invites.revokedAt) + const existingHumanJoinRequest = + requestType === "human" + ? findReusableHumanJoinRequest( + await db + .select() + .from(joinRequests) + .where( + and( + eq(joinRequests.companyId, companyId), + eq(joinRequests.requestType, "human") + ) ) - ); + .orderBy(desc(joinRequests.createdAt)), + { + requestingUserId: req.actor.userId ?? "local-board", + requestEmailSnapshot: actorEmail + } + ) + : null; + const created = !inviteAlreadyAccepted + ? existingHumanJoinRequest + ? await db.transaction(async (tx) => { + await tx + .update(invites) + .set({ acceptedAt: new Date(), updatedAt: new Date() }) + .where( + and( + eq(invites.id, invite.id), + isNull(invites.acceptedAt), + isNull(invites.revokedAt) + ) + ); + return existingHumanJoinRequest; + }) + : await db.transaction(async (tx) => { + await tx + .update(invites) + .set({ acceptedAt: new Date(), updatedAt: new Date() }) + .where( + and( + eq(invites.id, invite.id), + isNull(invites.acceptedAt), + isNull(invites.revokedAt) + ) + ); - const row = await tx - .insert(joinRequests) - .values({ - inviteId: invite.id, - companyId, - requestType, - status: "pending_approval", - requestIp: requestIp(req), - requestingUserId: - requestType === "human" - ? req.actor.userId ?? "local-board" - : null, - requestEmailSnapshot: - requestType === "human" ? actorEmail : null, - agentName: requestType === "agent" ? req.body.agentName : null, - adapterType: requestType === "agent" ? adapterType : null, - capabilities: - requestType === "agent" - ? req.body.capabilities ?? null - : null, - agentDefaultsPayload: - requestType === "agent" ? joinDefaults.normalized : null, - claimSecretHash, - claimSecretExpiresAt - }) - .returning() - .then((rows) => rows[0]); - return row; - }) + const row = await tx + .insert(joinRequests) + .values({ + inviteId: invite.id, + companyId, + requestType, + status: "pending_approval", + requestIp: requestIp(req), + requestingUserId: + requestType === "human" + ? req.actor.userId ?? "local-board" + : null, + requestEmailSnapshot: + requestType === "human" ? actorEmail : null, + agentName: + requestType === "agent" ? req.body.agentName : null, + adapterType: requestType === "agent" ? adapterType : null, + capabilities: + requestType === "agent" + ? req.body.capabilities ?? null + : null, + agentDefaultsPayload: + requestType === "agent" ? joinDefaults.normalized : null, + claimSecretHash, + claimSecretExpiresAt + }) + .returning() + .then((rows) => rows[0]); + return row; + }) : await db .update(joinRequests) .set({ @@ -2970,8 +3010,10 @@ export function accessRoutes( entityId: created.id, details: { requestType, - requestIp: created.requestIp, - inviteReplay: inviteAlreadyAccepted + requestIp: requestIp(req), + inviteReplay: inviteAlreadyAccepted, + reusedExistingJoinRequest: + Boolean(existingHumanJoinRequest) && !inviteAlreadyAccepted } }); diff --git a/server/src/routes/sidebar-badges.ts b/server/src/routes/sidebar-badges.ts index 505b7704753..8b4d1f22852 100644 --- a/server/src/routes/sidebar-badges.ts +++ b/server/src/routes/sidebar-badges.ts @@ -5,6 +5,7 @@ import { inboxDismissals, joinRequests } from "@paperclipai/db"; import { sidebarBadgeService } from "../services/sidebar-badges.js"; import { accessService } from "../services/access.js"; import { dashboardService } from "../services/dashboard.js"; +import { collapseDuplicatePendingHumanJoinRequests } from "../lib/join-request-dedupe.js"; import { assertCompanyAccess } from "./authz.js"; function buildDismissedAtByKey( @@ -35,14 +36,24 @@ export function sidebarBadgeRoutes(db: Db) { } const visibleJoinRequests = canApproveJoins - ? await db - .select({ - id: joinRequests.id, - updatedAt: joinRequests.updatedAt, - createdAt: joinRequests.createdAt, - }) - .from(joinRequests) - .where(and(eq(joinRequests.companyId, companyId), eq(joinRequests.status, "pending_approval"))) + ? collapseDuplicatePendingHumanJoinRequests( + await db + .select({ + id: joinRequests.id, + requestType: joinRequests.requestType, + status: joinRequests.status, + requestingUserId: joinRequests.requestingUserId, + requestEmailSnapshot: joinRequests.requestEmailSnapshot, + updatedAt: joinRequests.updatedAt, + createdAt: joinRequests.createdAt, + }) + .from(joinRequests) + .where(and(eq(joinRequests.companyId, companyId), eq(joinRequests.status, "pending_approval"))) + ).map(({ id, updatedAt, createdAt }) => ({ + id, + updatedAt, + createdAt, + })) : []; const dismissedAtByKey = diff --git a/ui/src/pages/CompanyInvites.tsx b/ui/src/pages/CompanyInvites.tsx index 36cecffc4af..16ebc87e741 100644 --- a/ui/src/pages/CompanyInvites.tsx +++ b/ui/src/pages/CompanyInvites.tsx @@ -204,7 +204,7 @@ export function CompanyInvites() {
- Each invite link is single-use. The first successful use creates a join request and consumes the link, even before approval. + Each invite link is single-use. The first successful use consumes the link and creates or reuses the matching join request before approval.