Deduplicate human join requests by requester

This commit is contained in:
Dotta committed 2026-04-11 21:19:53 -05:00
1 parent 683896abce
commit 50c20eebb0
5 files changed
+301 -56

No files matched your search

@@ -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"]);
});
});
+88
View File
@@ -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<string>();
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;
});
}
+89 -47
View File
@@ -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
}
});
+19 -8
View File
@@ -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 =