mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
Deduplicate human join requests by requester
This commit is contained in:
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"]);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -204,7 +204,7 @@ export function CompanyInvites() {
|
||||
</fieldset>
|
||||
|
||||
<div className="rounded-lg border border-border px-4 py-3 text-sm text-muted-foreground">
|
||||
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.
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
|
||||
Reference in new issue
Block a user