allow roles to be updated

This commit is contained in:
Jun-te Kim 2025-09-08 13:27:35 +00:00
parent 4044665c96
commit c60f09e302
4 changed files with 172 additions and 47 deletions

View file

@ -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 collaborators 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<typeof bodySchema>;
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 }
);
}
}

View file

@ -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 }
);
}
}

View file

@ -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<Collaborator[]> {
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<Collaborator[]> {
: [];
}
async function updatePortfolioUserRole(
portfolioId: string,
portfolioUserId: string,
role: Role
): Promise<void> {
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<Role>("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<Collaborator[]>(["portfolioUsers", portfolioId]);
// Optimistically update cache
queryClient.setQueryData<Collaborator[]>(
["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 }) {
<TableCell>{c.name || "—"}</TableCell>
<TableCell>{c.email}</TableCell>
<TableCell className="min-w-40">
<RoleDropdown value={c.role} onChange={(r) => onChangeRole(c.email, r)} />
<RoleDropdown value={c.role} onChange={(r) => onChangeRole(c.portfolioUserId, r)} />
</TableCell>
<TableCell className="text-right">
<Button variant="destructive" className="bg-red-700" onClick={() => onRemove(c.email)}>
<Button variant="destructive" className="bg-red-700" onClick={() => onRemove(c.portfolioUserId)}>
Remove
</Button>
</TableCell>

View file

@ -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;