From 618a92a06d8dda176e50fa5ba9afe4ac85b0f681 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 28 May 2026 11:13:59 +0000 Subject: [PATCH] Drop the unused invitee name field from the invite flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Full name input on the user-access card was never persisted (no column on portfolio_invitations), never used in the invitation email (only the inviter's name appears there), and never displayed in the pending-invitations table — purely dead weight on the form. Removing the input, the request-body field, and the response-payload reference. Extracts the POST body schema to a named inviteRequestSchema export so the contract change is locked in by a unit test rather than left implicit. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../[portfolioId]/collaborators/route.test.ts | 46 +++++++++++++++++++ .../[portfolioId]/collaborators/route.ts | 21 +++++---- .../settings/UsersPermissionsCard.tsx | 22 ++------- 3 files changed, 60 insertions(+), 29 deletions(-) create mode 100644 src/app/api/portfolio/[portfolioId]/collaborators/route.test.ts diff --git a/src/app/api/portfolio/[portfolioId]/collaborators/route.test.ts b/src/app/api/portfolio/[portfolioId]/collaborators/route.test.ts new file mode 100644 index 0000000..7b855f3 --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/collaborators/route.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; + +import { inviteRequestSchema } from "./route"; + +describe("inviteRequestSchema", () => { + it("accepts an invite request with just email and role (no name)", () => { + const result = inviteRequestSchema.parse({ + email: "alice@example.com", + role: "read", + }); + + expect(result).toEqual({ + email: "alice@example.com", + role: "read", + }); + }); + + it("silently drops an unknown name field (in-flight clients still parse)", () => { + const result = inviteRequestSchema.parse({ + email: "alice@example.com", + role: "write", + name: "Alice", + }); + + expect(result).toEqual({ + email: "alice@example.com", + role: "write", + }); + expect(result).not.toHaveProperty("name"); + }); + + it("rejects an invite request with a malformed email", () => { + expect(() => + inviteRequestSchema.parse({ email: "not-an-email", role: "read" }), + ).toThrow(); + }); + + it("rejects an invite request with a role outside the enum", () => { + expect(() => + inviteRequestSchema.parse({ + email: "alice@example.com", + role: "superuser", + }), + ).toThrow(); + }); +}); diff --git a/src/app/api/portfolio/[portfolioId]/collaborators/route.ts b/src/app/api/portfolio/[portfolioId]/collaborators/route.ts index 41c82c2..8fa8ca8 100644 --- a/src/app/api/portfolio/[portfolioId]/collaborators/route.ts +++ b/src/app/api/portfolio/[portfolioId]/collaborators/route.ts @@ -192,6 +192,13 @@ export async function PUT( } } +// Body schema for the invite endpoint. Exported so the contract can be +// unit-tested directly — see route.test.ts. +export const inviteRequestSchema = z.object({ + email: z.string().email(), + role: z.enum(ROLE_OPTIONS), +}); + // POST: invite a user by email. // // Unified flow: in nearly every case we write a pending portfolio_invitations @@ -204,15 +211,9 @@ export async function POST( ) { const { portfolioId } = await props.params; - const bodySchema = z.object({ - email: z.string().email(), - role: z.enum(ROLE_OPTIONS), - name: z.string(), - }); - - let body: z.infer; + let body: z.infer; try { - body = bodySchema.parse(await req.json()); + body = inviteRequestSchema.parse(await req.json()); } catch { return NextResponse.json({ error: "Invalid body" }, { status: 400 }); } @@ -283,7 +284,7 @@ export async function POST( portfolioUserId: existingMembership.id.toString(), userId: existingUser.id.toString(), role: body.role, - name: existingUser.firstName ?? body.name ?? null, + name: existingUser.firstName ?? null, email, kind: "member" as const, }, @@ -337,7 +338,7 @@ export async function POST( userId: null, invitationId: invitation.id.toString(), role: invitation.role, - name: body.name ?? null, + name: null, email, kind: "invitation" as const, }, diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx index bf049b6..d397370 100644 --- a/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx @@ -77,12 +77,11 @@ async function invitePortfolioUser( portfolioId: string, email: string, role: Role, - name: string, ): Promise { const res = await fetch(`/api/portfolio/${portfolioId}/collaborators`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email, role, name }), + body: JSON.stringify({ email, role }), }); if (!res.ok) { const msg = await res.text().catch(() => ""); @@ -123,7 +122,6 @@ async function revokePortfolioInvitation( export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) { const [inviteEmail, setInviteEmail] = useState(""); const [inviteRole, setInviteRole] = useState("read"); - const [inviteName, setInviteName] = useState(""); const [pendingRemoval, setPendingRemoval] = useState<{ portfolioUserId: string; email: string } | null>(null); const [pendingRevoke, setPendingRevoke] = @@ -210,16 +208,13 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) { mutationFn: ({ email, role, - name, }: { email: string; role: Role; - name: string; - }) => invitePortfolioUser(portfolioId, email, role, name), + }) => invitePortfolioUser(portfolioId, email, role), onSuccess: (_data, vars) => { invalidateBoth(); setInviteEmail(""); - setInviteName(""); toast({ title: "Invitation sent", description: `We've emailed ${vars.email} an invitation to this portfolio.`, @@ -324,7 +319,6 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) { inviteUserMutation.mutate({ email: inviteEmail, role: inviteRole, - name: inviteName, }); } @@ -380,12 +374,6 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) {

- setInviteName(e.target.value)} - /> {inviteUserMutation.isPending ? "Inviting..." : "Invite"}