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"}