From c3ae5d3cf5db32f4f5377376de30817cd91d3f71 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Mon, 8 Sep 2025 11:39:37 +0000 Subject: [PATCH 1/5] added users permission card --- .../settings/PortfolioSettings.tsx | 2 + .../settings/UsersPermissionsCard.tsx | 132 ++++++++++++++++++ .../[slug]/(portfolio)/settings/roles.tsx | 44 ++++++ 3 files changed, 178 insertions(+) create mode 100644 src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx create mode 100644 src/app/portfolio/[slug]/(portfolio)/settings/roles.tsx diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx index 014a40a..048bdd3 100644 --- a/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx @@ -34,6 +34,7 @@ import { PortfolioStatus as PortfolioStatusOptions } from "@/app/db/schema/portf import { PortfolioGoal as PortfolioGoalOptions } from "@/app/db/schema/portfolio"; import { useSession } from "next-auth/react"; import PortfolioPlanTable from "@/app/components/portfolio/measures/PlanTable"; +import { UsersPermissionsCard } from "./UsersPermissionsCard"; // dropdown selection component for both goal and status @@ -459,6 +460,7 @@ export default function PortfolioSettings({ +
Danger Zone: diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx new file mode 100644 index 0000000..7cd2f29 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx @@ -0,0 +1,132 @@ +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/app/shadcn_components/ui/table"; +import { Input } from "@/app/shadcn_components/ui/input"; +import { Button } from "@/app/shadcn_components/ui/button"; + +import { useState } from "react"; +import { Role, RoleDropdown, Collaborator } from "@/app/portfolio/[slug]/(portfolio)/settings/roles"; + +export function UsersPermissionsCard({ +}: { +}) { + const [inviteEmail, setInviteEmail] = useState(""); + const [inviteRole, setInviteRole] = useState("read"); + const collaborators: Collaborator[] = [ + { name: "Name 1", email: "name1@example.com", role: "read" }, + { name: "Name 2", email: "name2@example.com", role: "read" }, + ]; + + function handleInvite() { + console.log("handle invite") + } + + function onChangeRole(email:string, role:Role) { + console.log(`on change role ${email} ${role}`) + } + + function onRemove(email:string) { + console.log(`remove user ${email}`) + } + + return ( +
+
+ + + + Users Permission: +

Add users and manage roles

+
+
+ + {/* Invite row */} + + + Add a user +

+ Invite by email and choose a role +

+
+ + setInviteEmail(e.target.value)} + /> +
+ +
+
+ + + +
+ + {/* Current collaborators list */} + + + Current users +

+ Update roles or remove access +

+
+ +
+
+ + + Name + Email + Role + Actions + + + + {collaborators.length === 0 ? ( + + + No users yet. + + + ) : ( + collaborators.map((c) => ( + + {c.name || "—"} + {c.email} + + onChangeRole(c.email, r)} + /> + + + + + + )) + )} + +
+
+ + + + + + ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/roles.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/roles.tsx new file mode 100644 index 0000000..97ecf3e --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/settings/roles.tsx @@ -0,0 +1,44 @@ +import { + Select, + SelectTrigger, + SelectValue, + SelectContent, + SelectGroup, + SelectItem, +} from "@/app/shadcn_components/ui/select"; + +// Roles you support in your app (adjust as needed) +const ROLE_OPTIONS = ["read", "write"] as const; +export type Role = typeof ROLE_OPTIONS[number]; + +export type Collaborator = { + name?: string | null; + email: string; + role: Role; +}; + +// Small role dropdown using shadcn Select +export function RoleDropdown({ + value, + onChange, +}: { + value: Role; + onChange: (role: Role) => void; +}) { + return ( + + ); +} \ No newline at end of file From 4044665c9654aabdaea884eada4d1a2d2575b82a Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Mon, 8 Sep 2025 12:41:40 +0000 Subject: [PATCH 2/5] live data shows from database now make buttons work@ --- src/app/api/portfolio/[portfolioId]/route.ts | 41 +++++++- .../settings/PortfolioSettings.tsx | 2 +- .../settings/UsersPermissionsCard.tsx | 99 +++++++++++++------ .../[slug]/(portfolio)/settings/roles.tsx | 1 + 4 files changed, 112 insertions(+), 31 deletions(-) diff --git a/src/app/api/portfolio/[portfolioId]/route.ts b/src/app/api/portfolio/[portfolioId]/route.ts index a5286e3..9572f89 100644 --- a/src/app/api/portfolio/[portfolioId]/route.ts +++ b/src/app/api/portfolio/[portfolioId]/route.ts @@ -1,6 +1,7 @@ import { db } from "@/app/db/db"; import { NextRequest, NextResponse } from "next/server"; -import { portfolio, portfolioUsers } from "@/app/db/schema/portfolio"; +import { portfolio, portfolioUsers} from "@/app/db/schema/portfolio"; +import { user } from "@/app/db/schema/users"; import { recommendation, recommendationMaterials, @@ -161,3 +162,41 @@ export async function DELETE(request: NextRequest, props: { params: Promise<{ po ); } } + + +// Get colloborators (users) that have access to the portfolio +export async function GET( + _req: NextRequest, + props: { params: Promise<{ portfolioId: string }> } +) { + const { portfolioId } = await props.params; + + try { + const rows = await db + .select({ + userId: portfolioUsers.userId, + role: portfolioUsers.role, + name: user.firstName, + email: user.email, + }) + .from(portfolioUsers) + .leftJoin(user, eq(user.id, portfolioUsers.userId)) + .where(eq(portfolioUsers.portfolioId, BigInt(portfolioId))); + + // Explicitly normalize BigInts to strings + const collaborators = rows.map((r) => ({ + userId: r.userId ? r.userId.toString() : null, + role: r.role, + name: r.name ?? null, + email: r.email ?? "", + })); + + return NextResponse.json({ users: collaborators }, { status: 200 }); + } catch (err) { + console.error("GET /users error:", err); + return NextResponse.json( + { error: "Failed to fetch users" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx index 048bdd3..8993094 100644 --- a/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx @@ -460,7 +460,7 @@ export default function PortfolioSettings({ - +
Danger Zone: diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx index 7cd2f29..8b00d8e 100644 --- a/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx @@ -1,3 +1,5 @@ +"use client"; + import { Table, TableBody, @@ -9,29 +11,68 @@ import { import { Input } from "@/app/shadcn_components/ui/input"; import { Button } from "@/app/shadcn_components/ui/button"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { Role, RoleDropdown, Collaborator } from "@/app/portfolio/[slug]/(portfolio)/settings/roles"; +import { useQuery } from "@tanstack/react-query"; -export function UsersPermissionsCard({ -}: { -}) { + +async function getPortfolioUsers(portfolioId: string): Promise { + const res = await fetch(`/api/portfolio/${portfolioId}`, { + method: "GET", + headers: { "Content-Type": "application/json" }, + }); + if (!res.ok) throw new Error("Failed to fetch users"); + const json = await res.json(); + const users = Array.isArray(json) ? json : json.users; // support both shapes + + // Guard + shape to Collaborator[] + return Array.isArray(users) + ? users + .filter((u: any) => u.role !== "creator") // 👈 filter out creator + .map((u: any) => ({ + userId: String(u.userId), + name: u.name ?? null, + email: u.email ?? "", + role: u.role, + })) + : []; +} + +export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) { const [inviteEmail, setInviteEmail] = useState(""); const [inviteRole, setInviteRole] = useState("read"); - const collaborators: Collaborator[] = [ - { name: "Name 1", email: "name1@example.com", role: "read" }, - { name: "Name 2", email: "name2@example.com", role: "read" }, - ]; + + const { + data: collaborators = [], + isLoading, + isFetching, + refetch, + } = useQuery({ + queryKey: ["portfolioUsers", portfolioId], + queryFn: () => getPortfolioUsers(portfolioId), + enabled: !!portfolioId, // only run when id is present + refetchOnWindowFocus: false, // optional: avoid surprise refetch logs + onSuccess: (data) => { + console.log("Fetched users for portfolio:", data); + }, + onError: (err) => { + console.error("Error fetching users:", err); + }, + }); function handleInvite() { - console.log("handle invite") - } - - function onChangeRole(email:string, role:Role) { - console.log(`on change role ${email} ${role}`) + console.log("handle invite"); + // TODO: POST invite -> then refetch() } - function onRemove(email:string) { - console.log(`remove user ${email}`) + function onChangeRole(email: string, role: Role) { + console.log(`on change role ${email} ${role}`); + // TODO: PATCH role -> then refetch() + } + + function onRemove(email: string) { + console.log(`remove user ${email}`); + // TODO: DELETE user -> then refetch() } return ( @@ -43,6 +84,11 @@ export function UsersPermissionsCard({ Users Permission:

Add users and manage roles

+ + + {/* Invite row */} @@ -75,9 +121,7 @@ export function UsersPermissionsCard({ Current users -

- Update roles or remove access -

+

Update roles or remove access

@@ -91,7 +135,11 @@ export function UsersPermissionsCard({ - {collaborators.length === 0 ? ( + {isLoading ? ( + + Loading… + + ) : collaborators.length === 0 ? ( No users yet. @@ -103,17 +151,10 @@ export function UsersPermissionsCard({ {c.name || "—"} {c.email} - onChangeRole(c.email, r)} - /> + onChangeRole(c.email, r)} /> - @@ -129,4 +170,4 @@ export function UsersPermissionsCard({
); -} +} \ No newline at end of file diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/roles.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/roles.tsx index 97ecf3e..bd74b6c 100644 --- a/src/app/portfolio/[slug]/(portfolio)/settings/roles.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/settings/roles.tsx @@ -12,6 +12,7 @@ const ROLE_OPTIONS = ["read", "write"] as const; export type Role = typeof ROLE_OPTIONS[number]; export type Collaborator = { + userId: string; name?: string | null; email: string; role: Role; From c60f09e302a12de8e061a9cb8d3608065c8ded9d Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Mon, 8 Sep 2025 13:27:35 +0000 Subject: [PATCH 3/5] allow roles to be updated --- .../[portfolioId]/colloborators/route.ts | 100 ++++++++++++++++++ src/app/api/portfolio/[portfolioId]/route.ts | 36 ------- .../settings/UsersPermissionsCard.tsx | 80 ++++++++++++-- .../[slug]/(portfolio)/settings/roles.tsx | 3 +- 4 files changed, 172 insertions(+), 47 deletions(-) create mode 100644 src/app/api/portfolio/[portfolioId]/colloborators/route.ts diff --git a/src/app/api/portfolio/[portfolioId]/colloborators/route.ts b/src/app/api/portfolio/[portfolioId]/colloborators/route.ts new file mode 100644 index 0000000..3e7d6a7 --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/colloborators/route.ts @@ -0,0 +1,100 @@ +import { db } from "@/app/db/db"; +import { NextRequest, NextResponse } from "next/server"; +import { portfolio, portfolioUsers} from "@/app/db/schema/portfolio"; +import { user } from "@/app/db/schema/users"; +import { + recommendation, + recommendationMaterials, + planRecommendations, + plan, + scenario, +} from "@/app/db/schema/recommendations"; +import { + propertyTargets, + propertyDetailsEpc, + property, +} from "@/app/db/schema/property"; +import { eq, inArray, Name } from "drizzle-orm"; +import { z } from "zod"; +import { ROLE_OPTIONS } from "@/app/portfolio/[slug]/(portfolio)/settings/roles"; + + +// Get colloborators (users) that have access to the portfolio +export async function GET( + _req: NextRequest, + props: { params: Promise<{ portfolioId: string }> } +) { + const { portfolioId } = await props.params; + + try { + const rows = await db + .select({ + portfolioUserId: portfolioUsers.id, + userId: portfolioUsers.userId, + role: portfolioUsers.role, + name: user.firstName, + email: user.email, + }) + .from(portfolioUsers) + .leftJoin(user, eq(user.id, portfolioUsers.userId)) + .where(eq(portfolioUsers.portfolioId, BigInt(portfolioId))); + + // Explicitly normalize BigInts to strings + const collaborators = rows.map((r) => ({ + portfolioUserId: r.portfolioUserId ? r.portfolioUserId.toString(): null, + userId: r.userId ? r.userId.toString() : null, + role: r.role, + name: r.name ?? null, + email: r.email ?? "", + })); + + return NextResponse.json({ users: collaborators }, { status: 200 }); + } catch (err) { + console.error("GET /users error:", err); + return NextResponse.json( + { error: "Failed to fetch users" }, + { status: 500 } + ); + } +} + + +// PUT: update a collaborator’s role +export async function PUT( + req: NextRequest, + props: { params: Promise<{ portfolioId: string }> } +) { + const { portfolioId } = await props.params; + + // Validate request body + const bodySchema = z.object({ + portfolioUserId: z.string(), + role: z.enum(ROLE_OPTIONS), // adjust to your Role union + }); + + let body: z.infer; + try { + body = bodySchema.parse(await req.json()); + } catch (err) { + return NextResponse.json({ error: "Invalid body" }, { status: 400 }); + } + + try { + // Update role for this portfolioUserId + await db + .update(portfolioUsers) + .set({ role: body.role }) + .where(eq(portfolioUsers.id, BigInt(body.portfolioUserId))); + + return NextResponse.json( + { success: true, portfolioUserId: body.portfolioUserId, role: body.role }, + { status: 200 } + ); + } catch (err) { + console.error("PUT /colloborators error:", err); + return NextResponse.json( + { error: "Failed to update role" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/app/api/portfolio/[portfolioId]/route.ts b/src/app/api/portfolio/[portfolioId]/route.ts index 9572f89..749c7bd 100644 --- a/src/app/api/portfolio/[portfolioId]/route.ts +++ b/src/app/api/portfolio/[portfolioId]/route.ts @@ -164,39 +164,3 @@ export async function DELETE(request: NextRequest, props: { params: Promise<{ po } -// Get colloborators (users) that have access to the portfolio -export async function GET( - _req: NextRequest, - props: { params: Promise<{ portfolioId: string }> } -) { - const { portfolioId } = await props.params; - - try { - const rows = await db - .select({ - userId: portfolioUsers.userId, - role: portfolioUsers.role, - name: user.firstName, - email: user.email, - }) - .from(portfolioUsers) - .leftJoin(user, eq(user.id, portfolioUsers.userId)) - .where(eq(portfolioUsers.portfolioId, BigInt(portfolioId))); - - // Explicitly normalize BigInts to strings - const collaborators = rows.map((r) => ({ - userId: r.userId ? r.userId.toString() : null, - role: r.role, - name: r.name ?? null, - email: r.email ?? "", - })); - - return NextResponse.json({ users: collaborators }, { status: 200 }); - } catch (err) { - console.error("GET /users error:", err); - return NextResponse.json( - { error: "Failed to fetch users" }, - { status: 500 } - ); - } -} \ No newline at end of file diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx index 8b00d8e..387bf1f 100644 --- a/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx @@ -13,23 +13,24 @@ import { Button } from "@/app/shadcn_components/ui/button"; import { useState, useEffect } from "react"; import { Role, RoleDropdown, Collaborator } from "@/app/portfolio/[slug]/(portfolio)/settings/roles"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; + async function getPortfolioUsers(portfolioId: string): Promise { - const res = await fetch(`/api/portfolio/${portfolioId}`, { + const res = await fetch(`/api/portfolio/${portfolioId}/colloborators`, { method: "GET", headers: { "Content-Type": "application/json" }, }); if (!res.ok) throw new Error("Failed to fetch users"); const json = await res.json(); const users = Array.isArray(json) ? json : json.users; // support both shapes - // Guard + shape to Collaborator[] return Array.isArray(users) ? users .filter((u: any) => u.role !== "creator") // 👈 filter out creator .map((u: any) => ({ + portfolioUserId: String(u.portfolioUserId), userId: String(u.userId), name: u.name ?? null, email: u.email ?? "", @@ -38,10 +39,28 @@ async function getPortfolioUsers(portfolioId: string): Promise { : []; } +async function updatePortfolioUserRole( + portfolioId: string, + portfolioUserId: string, + role: Role +): Promise { + const res = await fetch(`/api/portfolio/${portfolioId}/colloborators`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ portfolioUserId, role }), + }); + if (!res.ok) { + const msg = await res.text().catch(() => ""); + throw new Error(msg || "Failed to update role"); + } +} + export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) { const [inviteEmail, setInviteEmail] = useState(""); const [inviteRole, setInviteRole] = useState("read"); + const queryClient = useQueryClient(); + const { data: collaborators = [], isLoading, @@ -60,18 +79,59 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) { }, }); + const changeRoleMutation = useMutation({ + mutationFn: ({ portfolioUserId, role }: { portfolioUserId: string; role: Role }) => + updatePortfolioUserRole(portfolioId, portfolioUserId, role), + + // Optimistic update + onMutate: async ({ portfolioUserId, role }) => { + await queryClient.cancelQueries({ queryKey: ["portfolioUsers", portfolioId] }); + const previous = queryClient.getQueryData(["portfolioUsers", portfolioId]); + + // Optimistically update cache + queryClient.setQueryData( + ["portfolioUsers", portfolioId], + (old) => + (old ?? []).map((c) => + c.portfolioUserId === portfolioUserId ? { ...c, role } : c + ) + ); + + // Return context to rollback on error + return { previous }; + }, + + // Rollback on error + onError: (err, _vars, context) => { + if (context?.previous) { + queryClient.setQueryData(["portfolioUsers", portfolioId], context.previous); + } + console.error("Failed to update role:", err); + }, + + // Always revalidate after success/error + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ["portfolioUsers", portfolioId] }); + }, + }); + + function handleInvite() { - console.log("handle invite"); + console.log("Inivte email", inviteEmail); + console.log("Inivte Role", inviteRole); // TODO: POST invite -> then refetch() } - function onChangeRole(email: string, role: Role) { - console.log(`on change role ${email} ${role}`); + function onChangeRole(portfolioUserId: string, role: Role) { + console.log(`Change portfolioUserId ${portfolioUserId} to ${role}`); + changeRoleMutation.mutate({ portfolioUserId, role }); + // TODO: PATCH role -> then refetch() } - function onRemove(email: string) { - console.log(`remove user ${email}`); + function onRemove(portfolioUserId: string) { + console.log(`This button will delete the row portoflioUserId ${portfolioUserId}`); + console.log("This was not implemented as Jun-te wanted to avoid Delete via drizzle before Database integrirty") // TODO: DELETE user -> then refetch() } @@ -151,10 +211,10 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) { {c.name || "—"} {c.email} - onChangeRole(c.email, r)} /> + onChangeRole(c.portfolioUserId, r)} /> - diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/roles.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/roles.tsx index bd74b6c..feeb223 100644 --- a/src/app/portfolio/[slug]/(portfolio)/settings/roles.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/settings/roles.tsx @@ -8,10 +8,11 @@ import { } from "@/app/shadcn_components/ui/select"; // Roles you support in your app (adjust as needed) -const ROLE_OPTIONS = ["read", "write"] as const; +export const ROLE_OPTIONS = ["read", "write"] as const; export type Role = typeof ROLE_OPTIONS[number]; export type Collaborator = { + portfolioUserId: string; userId: string; name?: string | null; email: string; From 29c57b64faf7a3e6233854c704f8a9c2c35a06cc Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Mon, 8 Sep 2025 13:56:33 +0000 Subject: [PATCH 4/5] new user gets added woo hoo --- .../[portfolioId]/colloborators/route.ts | 105 ++++++++++++++++++ src/app/db/schema/users.ts | 2 +- .../settings/UsersPermissionsCard.tsx | 56 ++++++++-- 3 files changed, 154 insertions(+), 9 deletions(-) diff --git a/src/app/api/portfolio/[portfolioId]/colloborators/route.ts b/src/app/api/portfolio/[portfolioId]/colloborators/route.ts index 3e7d6a7..7a90192 100644 --- a/src/app/api/portfolio/[portfolioId]/colloborators/route.ts +++ b/src/app/api/portfolio/[portfolioId]/colloborators/route.ts @@ -97,4 +97,109 @@ export async function PUT( { status: 500 } ); } +} + +// POST: invite a user by email (find-or-create user, then add to portfolio with role) +export async function POST( + req: NextRequest, + props: { params: Promise<{ portfolioId: string }> } +) { + const { portfolioId } = await props.params; + + // 1) Validate payload + const bodySchema = z.object({ + email: z.string().email(), + role: z.enum(ROLE_OPTIONS), + name: z.string() + }); + + let body: z.infer; + try { + body = bodySchema.parse(await req.json()); + } catch { + return NextResponse.json({ error: "Invalid body" }, { status: 400 }); + } + + try { + const pId = BigInt(portfolioId); + + // 2) Find or create the user by email + // Try to find existing user + let existing = await db + .select({ id: user.id, firstName: user.firstName, email: user.email }) + .from(user) + .where(eq(user.email, body.email)) + .limit(1); + + let createdUserId: bigint | null = existing[0]?.id ?? null; + + // If not found, create. Prefer Postgres upsert to avoid race. + if (!createdUserId) { + // If you’re on Postgres, this is ideal: + const inserted = await db + .insert(user) + .values({ email: body.email, firstName: body.name, oauthProvider: "credentials" }) + .onConflictDoNothing() // relies on a UNIQUE(email) constraint + .returning({ id: user.id }); + + if (inserted.length > 0) { + createdUserId = inserted[0].id; + } else { + // Someone else created the user concurrently; fetch it + const fetched = await db + .select({ id: user.id }) + .from(user) + .where(eq(user.email, body.email)) + .limit(1); + if (!fetched[0]) { + return NextResponse.json( + { error: "Failed to create or fetch user" }, + { status: 500 } + ); + } + createdUserId = fetched[0].id; + } + } + + // 3) Link user to portfolio with role (upsert) + // Assumes a UNIQUE index on (portfolioId, userId) in portfolioUsers. + const linkResult = await db + .insert(portfolioUsers) + .values({ + portfolioId: pId, + userId: createdUserId!, + role: body.role, + }) + .returning({ + portfolioUserId: portfolioUsers.id, + userId: portfolioUsers.userId, + role: portfolioUsers.role, + }); + + const row = linkResult[0]; + if (!row) { + return NextResponse.json( + { error: "Failed to create portfolio user" }, + { status: 500 } + ); + } + + const collaborator = { + portfolioUserId: row.portfolioUserId?.toString() ?? null, + userId: row.userId?.toString() ?? null, + role: row.role, + name: body.name ?? null, + email: body.email, + }; + + // 201 if it was a new link, 200 if it was an update — we can’t easily + // tell from .onConflictDoUpdate return, so just use 200 OK. + return NextResponse.json({ user: collaborator }, { status: 200 }); + } catch (err) { + console.error("POST /colloborators error:", err); + return NextResponse.json( + { error: "Failed to invite user" }, + { status: 500 } + ); + } } \ No newline at end of file diff --git a/src/app/db/schema/users.ts b/src/app/db/schema/users.ts index dbc96d7..372a678 100644 --- a/src/app/db/schema/users.ts +++ b/src/app/db/schema/users.ts @@ -7,7 +7,7 @@ export const user = pgTable("user", { // At the moment, Drizzle doesn't support unique constraints email: text("email").notNull(), oauthId: text("oauth_id"), - oauthProvider: text("oauth_provider").$type<"google">(), + oauthProvider: text("oauth_provider").$type<"google" | "credentials">(), // role: text("role").$type<"admin" | "write" | "read">(), createdAt: timestamp("created_at", { precision: 6, diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx index 387bf1f..2e282fa 100644 --- a/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx @@ -55,9 +55,28 @@ async function updatePortfolioUserRole( } } +async function invitePortfolioUser( + portfolioId: string, + email: string, + role: Role, + name: string +): Promise { + const res = await fetch(`/api/portfolio/${portfolioId}/colloborators`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, role, name }), + }); + if (!res.ok) { + const msg = await res.text().catch(() => ""); + throw new Error(msg || "Failed to invite user"); + } +} + + export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) { const [inviteEmail, setInviteEmail] = useState(""); const [inviteRole, setInviteRole] = useState("read"); + const [inviteName, setInviteName] = useState(""); const queryClient = useQueryClient(); @@ -115,18 +134,29 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) { }, }); + // ADD: mutation for inviting a user + const inviteUserMutation = useMutation({ + mutationFn: ({ email, role, name }: { email: string; role: Role; name: string | null }) => + invitePortfolioUser(portfolioId, email, role, name), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["portfolioUsers", portfolioId] }); + setInviteEmail(""); + setInviteName(""); // clear name after success + // setInviteRole("read"); + }, + onError: (err) => { + console.error("Invite failed:", err); + }, + }); + function handleInvite() { - console.log("Inivte email", inviteEmail); - console.log("Inivte Role", inviteRole); - // TODO: POST invite -> then refetch() + inviteUserMutation.mutate({ email: inviteEmail, role: inviteRole, name: inviteName || null }); } function onChangeRole(portfolioUserId: string, role: Role) { console.log(`Change portfolioUserId ${portfolioUserId} to ${role}`); changeRoleMutation.mutate({ portfolioUserId, role }); - - // TODO: PATCH role -> then refetch() } function onRemove(portfolioUserId: string) { @@ -160,6 +190,12 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) {

+ setInviteName(e.target.value)} + /> - + From 072a01a35954afa8c30366beee5dfaba3e039fa0 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Mon, 8 Sep 2025 14:16:03 +0000 Subject: [PATCH 5/5] fix run build --- .../[slug]/(portfolio)/settings/UsersPermissionsCard.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx index 2e282fa..4695ca5 100644 --- a/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx @@ -136,7 +136,7 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) { // ADD: mutation for inviting a user const inviteUserMutation = useMutation({ - mutationFn: ({ email, role, name }: { email: string; role: Role; name: string | null }) => + mutationFn: ({ email, role, name }: { email: string; role: Role; name: string }) => invitePortfolioUser(portfolioId, email, role, name), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["portfolioUsers", portfolioId] }); @@ -151,7 +151,7 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) { function handleInvite() { - inviteUserMutation.mutate({ email: inviteEmail, role: inviteRole, name: inviteName || null }); + inviteUserMutation.mutate({ email: inviteEmail, role: inviteRole, name: inviteName }); } function onChangeRole(portfolioUserId: string, role: Role) {