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..7a90192 --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/colloborators/route.ts @@ -0,0 +1,205 @@ +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 } + ); + } +} + +// 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/api/portfolio/[portfolioId]/route.ts b/src/app/api/portfolio/[portfolioId]/route.ts index a5286e3..749c7bd 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,5 @@ export async function DELETE(request: NextRequest, props: { params: Promise<{ po ); } } + + 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/PortfolioSettings.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx index 014a40a..8993094 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..4695ca5 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx @@ -0,0 +1,273 @@ +"use client"; + +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, useEffect } from "react"; +import { Role, RoleDropdown, Collaborator } from "@/app/portfolio/[slug]/(portfolio)/settings/roles"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; + + + +async function getPortfolioUsers(portfolioId: string): Promise { + 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 ?? "", + role: u.role, + })) + : []; +} + +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"); + } +} + +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(); + + 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); + }, + }); + + 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] }); + }, + }); + + // ADD: mutation for inviting a user + const inviteUserMutation = useMutation({ + mutationFn: ({ email, role, name }: { email: string; role: Role; name: string }) => + 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() { + inviteUserMutation.mutate({ email: inviteEmail, role: inviteRole, name: inviteName }); + } + + function onChangeRole(portfolioUserId: string, role: Role) { + console.log(`Change portfolioUserId ${portfolioUserId} to ${role}`); + changeRoleMutation.mutate({ portfolioUserId, role }); + } + + 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() + } + + return ( +
+
+ + + + Users Permission: +

Add users and manage roles

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

+ Invite by email and choose a role +

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

Update roles or remove access

+
+ +
+
+ + + Name + Email + Role + Actions + + + + {isLoading ? ( + + Loading… + + ) : collaborators.length === 0 ? ( + + + No users yet. + + + ) : ( + collaborators.map((c) => ( + + {c.name || "—"} + {c.email} + + onChangeRole(c.portfolioUserId, r)} /> + + + + + + )) + )} + +
+
+ + + + + + ); +} \ 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 new file mode 100644 index 0000000..feeb223 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/settings/roles.tsx @@ -0,0 +1,46 @@ +import { + Select, + SelectTrigger, + SelectValue, + SelectContent, + SelectGroup, + SelectItem, +} from "@/app/shadcn_components/ui/select"; + +// Roles you support in your app (adjust as needed) +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; + 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