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)} />
- onRemove(c.email)}>
+ onRemove(c.portfolioUserId)}>
Remove
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)}
+ />
-
- Invite
-
+
+ {inviteUserMutation.isPending ? "Inviting..." : "Invite"}
+
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) {