Merge pull request #26 from Hestia-Homes/settings-api

adding the basline for the setting api
This commit is contained in:
KhalimCK 2024-11-18 11:15:16 +00:00 committed by GitHub
commit 5974fad886
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 585 additions and 159 deletions

View file

@ -0,0 +1,80 @@
import { db } from "@/app/db/db";
import { NextRequest, NextResponse } from "next/server";
import { portfolioUsers } from "@/app/db/schema/portfolio";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
const PermissionsBodySchema = z.object({
userId: z.string(),
action: z.enum(["delete", "update"]),
});
export async function POST(
request: NextRequest,
{ params }: { params: { portfolioId: string } }
) {
// This endpoint lives at portfolio/{portfolioId}/permissions and will return the permissions level for a given portfolio
// Call this endpoint with a) userId, b) portfolioId, c) an action and this api will tell you if that person can do that thing
const body = await request.json();
let validatedBody;
try {
validatedBody = PermissionsBodySchema.parse(body);
} catch (error) {
console.error("Invalid input: ", error);
return new NextResponse(JSON.stringify({ msg: "Invalid input" }), {
status: 400,
});
}
const action = validatedBody.action;
const userId = validatedBody.userId;
const portfolioId = params.portfolioId;
const existingPortfolioPermissions = await db.query.portfolioUsers.findFirst({
where: and(
eq(portfolioUsers.portfolioId, BigInt(portfolioId)),
eq(portfolioUsers.userId, BigInt(userId))
),
});
if (!existingPortfolioPermissions) {
return new NextResponse(
JSON.stringify({ error: "Portfolio not found or unauthorized" }),
{ status: 404 }
);
}
const role = existingPortfolioPermissions.role;
let permitted;
// If the action is a delete, we only allow an admin or creator to delete
if (action === "delete") {
if (role === "admin" || role === "creator") {
permitted = true;
} else {
permitted = false;
}
}
// If the action is an update, we allow an individual admin, creator or write role to update
if (action === "update") {
if (role === "admin" || role === "creator" || role === "write") {
permitted = true;
} else {
permitted = false;
}
}
// Returning a successful response
return new NextResponse(
JSON.stringify({
permitted: permitted,
}),
{
status: 200,
}
);
}

View file

@ -0,0 +1,167 @@
import { db } from "@/app/db/db";
import { NextRequest, NextResponse } from "next/server";
import { portfolio, portfolioUsers } from "@/app/db/schema/portfolio";
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";
const UpdateBodySchema = z.object({
name: z.optional(z.string()),
budget: z.optional(z.number()),
goal: z.optional(z.string()),
status: z.optional(z.string()),
});
export async function PUT(
request: NextRequest,
{ params }: { params: { portfolioId: string } }
) {
const body = await request.json();
let validatedBody;
try {
validatedBody = UpdateBodySchema.parse(body);
} catch (error) {
console.error("Invalid input: ", error);
return new NextResponse(JSON.stringify({ msg: "Invalid input" }), {
status: 400,
});
}
const portfolioId = params.portfolioId;
const name = validatedBody.name;
const budget = validatedBody.budget;
const goal = validatedBody.goal;
const status = validatedBody.status;
await db
.update(portfolio)
.set(body)
.where(eq(portfolio.id, BigInt(portfolioId)));
// Returning a successful response
return new NextResponse(JSON.stringify({}), {
status: 200,
});
}
export async function DELETE(
request: NextRequest,
{ params }: { params: { portfolioId: string } }
) {
try {
const portfolioId = params.portfolioId;
// 1) Fetch the portfolio ids
// 2) Fetch the recommendation ids
// 3) Delete all entries from RecommendationMaterials for these recommendationsP
// 4) Delete all entries from PlanRecommendations that reference plans in the portfolio
// 5) Delete all Plans associated with the portfolio
// 6) Delete all Scenarios associated with the portfolio
// 7) Delete all Recommendations associated with the properties
// 8) Delete PropertyTargetsModel, PropertyDetailsEpcModel, and PropertyModel
// 9) Delete portfolioUsers
// 10) Then, we finally delete the portfolio!!!
// Step 1) Fetch the property ids for the portfolio
const propertyIdsResponse = await db.query.property.findMany({
columns: { id: true },
where: eq(property.portfolioId, BigInt(portfolioId)),
});
const propertyIds = propertyIdsResponse.map((property) => property.id);
// Step 2) Fetch the recommendation ids - filter the recommendation table, where propertyId is in the propertyIds
// if there are no prpoperty Ids, we make recommendationIds an empty array
let recommendationIds: bigint[] = [];
if (propertyIds.length) {
const recommendations = await db.query.recommendation.findMany({
columns: { id: true },
where: inArray(recommendation.propertyId, propertyIds),
});
recommendationIds = recommendations.map(
(recommendation) => recommendation.id
);
}
// Step 3) Delete all entries from RecommendationMaterials for these recommendations
if (recommendationIds.length) {
await db
.delete(recommendationMaterials)
.where(
inArray(recommendationMaterials.recommendationId, recommendationIds)
);
}
// Step 4) Delete all entries from PlanRecommendations that reference plans in the portfolio
// Get the plan ids
const plans = await db.query.plan.findMany({
columns: { id: true },
where: eq(plan.portfolioId, BigInt(portfolioId)),
});
const planIds = plans.map((plan) => plan.id);
if (planIds.length) {
await db
.delete(planRecommendations)
.where(inArray(planRecommendations.planId, planIds));
}
// Step 5) Delete all Plans associated with the portfolio
await db.delete(plan).where(eq(plan.portfolioId, BigInt(portfolioId)));
// Step 6) Delete all Scenarios associated with the portfolio
await db
.delete(scenario)
.where(eq(scenario.portfolioId, BigInt(portfolioId)));
// Step 7) Delete all Recommendations associated with the properties
if (propertyIds.length) {
await db
.delete(recommendation)
.where(inArray(recommendation.propertyId, propertyIds));
}
// Step 8) Delete PropertyTargets, PropertyDetailsEpc, and Property
await db
.delete(propertyTargets)
.where(eq(propertyTargets.portfolioId, BigInt(portfolioId)));
await db
.delete(propertyDetailsEpc)
.where(eq(propertyDetailsEpc.portfolioId, BigInt(portfolioId)));
await db
.delete(property)
.where(eq(property.portfolioId, BigInt(portfolioId)));
await db
.delete(portfolioUsers)
.where(eq(portfolioUsers.portfolioId, BigInt(portfolioId)));
await db.delete(portfolio).where(eq(portfolio.id, BigInt(portfolioId)));
// Return success response
return new NextResponse(
JSON.stringify({ message: "Portfolio successfully deleted" }),
{ status: 200 }
);
} catch (error) {
console.error("Error deleting portfolio:", error);
return new NextResponse(
JSON.stringify({ error: "Your API has Failed to delete the portfolio" }),
{ status: 500 }
);
}
}

View file

@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { PortfolioSettingsType } from "../../utils";
import { Button } from "@/app/shadcn_components/ui/button";
import { Input } from "@/app/shadcn_components/ui/input";
@ -21,8 +22,18 @@ import {
DialogTitle,
DialogFooter,
} from "@/app/shadcn_components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/app/shadcn_components/ui/table";
import { PortfolioStatus as PortfolioStatusOptions } from "@/app/db/schema/portfolio";
import { PortfolioGoal as PortfolioGoalOptions } from "@/app/db/schema/portfolio";
import { useSession } from "next-auth/react";
import PortfolioPlanTable from "@/app/components/portfolio/plan/PlanTable";
// dropdown selection component for both goal and status
@ -30,10 +41,12 @@ export function SettingsDropdown({
startingValue,
options,
setOption,
className,
}: {
startingValue: string;
options: string[];
setOption: (option: string) => void;
className?: string;
}) {
function handleValueChange(newValue: string) {
setOption(newValue);
@ -41,7 +54,7 @@ export function SettingsDropdown({
return (
<Select onValueChange={(newValue) => handleValueChange(newValue)}>
<SelectTrigger className="w-[180px]">
<SelectTrigger className="w-full">
<SelectValue placeholder={startingValue} />
</SelectTrigger>
<SelectContent>
@ -57,6 +70,149 @@ export function SettingsDropdown({
);
}
type updateSettingsArgs = {
userId: bigint;
portfolioId: string;
name: string | null;
budget: number | string | undefined | null;
goal: (typeof PortfolioGoalOptions)[number] | null;
status: (typeof PortfolioStatusOptions)[number] | null;
};
type bodyType = {
name?: string;
budget?: number | string;
goal?: string;
status?: string;
};
const updateSettings = async ({
userId,
portfolioId,
name,
budget,
goal,
status,
}: updateSettingsArgs) => {
const permissionsReponse = await fetch(
`/api/portfolio/${portfolioId}/permissions`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
userId: userId.toString(),
action: "update",
}),
}
);
const permissionsData = await permissionsReponse.json();
const permitted = permissionsData.permitted;
console.log("USER IS PERMITTED TO DO THIS!!!!")
// If the user is not permitted to delete the portfolio, we'll throw an error
if (!permitted) {
throw new Error("User is not permitted to update this portfolio");
}
// We convert the the bigint to a string since big ints are not serialisable and we don't want to loose precision
// We will create a js object with the starting values
// We will then update the values that are not null
const body: bodyType = {}
if (name) {
body.name = name;
}
if (budget) {
body.budget = budget;
}
if (goal) {
body.goal = goal;
}
if (status) {
body.status = status;
}
const requestBody = JSON.stringify(body);
const response = await fetch(`/api/portfolio/${portfolioId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: requestBody,
});
if (!response.ok) {
throw new Error("Network response was not ok");
}
return response.json();
};
async function deletePortfolio({
userId,
portfolioId,
}: {
userId: bigint;
portfolioId: string;
}) {
try {
console.log("Attempting to DELETE portfolio by calling API:", {
userId,
portfolioId,
});
// We'll check if the user is authorized to delete this portfolio
const permissionsReponse = await fetch(
`/api/portfolio/${portfolioId}/permissions`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
userId: userId.toString(),
action: "delete",
}),
}
);
const permissionsData = await permissionsReponse.json();
const permitted = permissionsData.permitted;
// If the user is not permitted to delete the portfolio, we'll throw an error
if (!permitted) {
throw new Error("User is not permitted to delete this portfolio");
}
const response = await fetch(`/api/portfolio/${portfolioId}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error(
"deletePortfolio has been called into action but utterly failed to do the API handoff"
);
}
return await response.json();
} catch (error) {
console.error("Error after failing to the try to get a response:", error);
throw error;
}
}
export default function PortfolioSettings({
portfolioId,
portfolioSettingsData,
@ -64,12 +220,33 @@ export default function PortfolioSettings({
portfolioId: string;
portfolioSettingsData: PortfolioSettingsType;
}) {
// Running in the client
// This is a client component so we can access the session directly
const session = useSession();
const router = useRouter();
// Set up state for portfolioName, portfolioBudget, portfolioGoal and portfolioStatus
const { mutate, isLoading } = useMutation(updateSettings, {
onSuccess: () => {
router.refresh();
},
onError: (error) => {
// handle error
console.log(error);
},
});
const { mutate: mutateDelete } = useMutation(deletePortfolio, {
onSuccess: () => {
setIsDeleteModalOpen(false);
router.push("/home");
},
onError: (error) => {
console.error(
"Because the API hand off failed, we're right back here at the mutation station",
error
);
},
});
// Syntax const [variable, function whos only job is to update the value of variable] = useState(initial value)
const [portfolioName, setPortfolioName] = useState(
portfolioSettingsData.name
);
@ -92,22 +269,27 @@ export default function PortfolioSettings({
const [deleteConfirmationByName, setDeleteConfirmationByName] = useState("");
if (!session.data) {
// The user is not logged in, redirect them to sign in
router.push("/");
return null;
}
const userId = session.data.user.dbId;
function handleOpenDeleteModal() {
setDeleteConfirmationByName("");
setIsDeleteModalOpen(true);
}
function handleDeleteConfirmation() {
console.log("we be deletin stuff");
// if (deleteConfirmationByName === portfolioName) {
// //apiDeletePortfolio(portfolioId)
// router.refresh();
// setIsDeleteModalOpen(false);
// } else {
// // Error if the names don't match
// console.log("Portfolio name does not match");
// }
router.push("/home");
if (deleteConfirmationByName === portfolioSettingsData.name) {
mutateDelete({
userId,
portfolioId,
});
console.log("succesfully called the mututate function");
}
}
// RENAMING FUNCTIONS
@ -120,10 +302,15 @@ export default function PortfolioSettings({
// The onClick function called to update the NAME in the DB
function handleRenameDb() {
// apiRanameFunction(portfolioSettingsData.name)
// Update portfolioName
router.refresh();
function handleRename() {
mutate({
userId,
portfolioId,
name: portfolioName,
budget: null,
goal: null,
status: null,
});
}
// BUDGET CHANGING FUNCTIONS
@ -136,148 +323,156 @@ export default function PortfolioSettings({
// The onClick function called to update the BUDGET in the DB
function handleBudgetUpdateDb() {
// apiBudgetChangeFunction(portfolioSettingsData.budget)
// Update portfolioBudget
router.refresh();
function handleBudgetUpdate() {
mutate({
userId,
portfolioId,
name: null,
budget: portfolioBudget,
goal: null,
status: null,
});
}
// CHANGING GOAL AND STATUS FUNCTIONALITY
// The onClick function called to update the GOAL in the DB
function handleGoalUpdateDb() {
// apiGoalChangeFunction(portfolioSettingsData.goal)
// Update portfolioGoal
router.refresh();
function handleGoalUpdate() {
mutate({
userId,
portfolioId,
name: null,
budget: null,
goal: portfolioGoal,
status: null,
});
}
// The onClick function called to update the BUDGET in the DB
function handleStatusUpdateDb() {
// apiStatusChangeFunction(portfolioSettingsData.status)
// Update portfolioStatus
router.refresh();
function handleStatusUpdate() {
mutate({
userId,
portfolioId,
name: null,
budget: null,
goal: null,
status: portfolioStatus,
});
}
// HTML to render the page
// TODO: 1) Set up the useMutate hook
// 2) Set up the api functions
// 3) add the call to mutate() so that when we submit the form, the data is updated in the DB
// 4) Create the API
return (
<>
<div className="p-4 mt-5 flex justify-center max-w-8xl w-8xl pt-5 bg-gray-50 rounded-lg text-brandblue">
<div className="grid grid-cols-[max-content_1fr_min-content] gap-x-[5px] gap-y-4">
{/* Row 1: Name */}
<div className="flex items-center">
<span>Name:</span>
</div>
<div className="flex items-center">
<Input value={portfolioName} onChange={handlePortfolioNameChange} />
</div>
<div className="flex items-center">
<Button className="w-full" onClick={handleRenameDb}>
Rename
</Button>
</div>
{/* Row 2: Budget */}
<div className="flex items-center">
<span>Budget:</span>
</div>
<div className="flex items-center max-w-8xl">
<Input
type="number"
value={portfolioBudget ?? undefined}
onChange={handlePortfolioBudgetUpdate}
onKeyDown={(e) => handleNumericKeyDown(e)}
/>
</div>
<div className="flex items-center">
<Button className="w-full" onClick={handleBudgetUpdateDb}>
Update
</Button>
</div>
{/* Row 3: Goal */}
<div className="flex items-center">
<span>Goal:</span>
</div>
<div className="flex items-center">
<SettingsDropdown
startingValue={portfolioGoal}
options={PortfolioGoalOptions}
setOption={setPortfolioGoal}
/>
</div>
<Button className="w-full" onClick={handleGoalUpdateDb}>
Update
</Button>
{/* Row 4: Status */}
<div className="flex items-center">
<span>Status:</span>
</div>
<div className="flex items-center">
<SettingsDropdown
startingValue={portfolioStatus}
options={PortfolioStatusOptions}
setOption={setPortfolioStatus}
/>
</div>
<Button className="w-full" onClick={handleStatusUpdateDb}>
Update
</Button>
<div className="col-span-3"> Portfolio Name: {portfolioName}</div>
<div className="col-span-3"> Portfolio Budget: {portfolioBudget}</div>
<div className="col-span-3"> Goal value: {portfolioGoal}</div>
<div> Status value: {portfolioStatus}</div>
{/* Row 5: Delete */}
<div className="col-span-2"></div>
<div className="flex items-center">
<div className="w-auto mt-4 p-4 bg-gray-50 rounded-lg text-brandblue">
<div className="rounded-md border border-gray-700">
<Table>
<TableBody>
<TableRow>
<TableHead className="text-brandblue">
Rename the Portfolio:<p className="text-xs text-gray-500">Permanently change the name of your portfolio</p>
</TableHead>
<TableCell>
<Input value={portfolioName} onChange={handlePortfolioNameChange} />
</TableCell>
<TableCell>
<Button className="w-28" onClick={handleRename}>Rename</Button>
</TableCell>
</TableRow>
<TableRow>
<TableHead className="text-brandblue">
Change the Portfolio Budget:<p className="text-xs text-gray-500">The total budget across ALL properties. Works aim to stay within this budget</p>
</TableHead>
<TableCell>
<Input type="number" value={portfolioBudget ?? undefined} onChange={handlePortfolioBudgetUpdate} onKeyDown={(e) => handleNumericKeyDown(e)}/>
</TableCell>
<TableCell>
<Button className="w-28" onClick={handleBudgetUpdate}>Update</Button>
</TableCell>
</TableRow>
<TableRow>
<TableHead className="text-brandblue">
Change the Portfolio Goal:<p className="text-xs text-gray-500">Adjust the overall aim of the works conducted on this portfolio</p>
</TableHead>
<TableCell>
<SettingsDropdown className="w-full" startingValue={portfolioGoal} options={PortfolioGoalOptions} setOption={setPortfolioGoal}/>
</TableCell>
<TableCell>
<Button className="w-28" onClick={handleGoalUpdate}>Update</Button>
</TableCell>
</TableRow>
<TableRow>
<TableHead className="text-brandblue">
Change the Status of the Portfolio:<p className="text-xs text-gray-500">Adjust where the portfolio stands in the works pipeline</p>
</TableHead>
<TableCell>
<SettingsDropdown className="w-full" startingValue={portfolioStatus} options={PortfolioStatusOptions} setOption={setPortfolioStatus}/>
</TableCell>
<TableCell>
<Button className="w-28" onClick={handleStatusUpdate}>Update</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<div className="rounded-md border border-red-500 mt-2">
<Table>
<TableHead className="text-lg text-brandblue">Danger Zone:</TableHead>
<TableBody>
<TableRow>
<TableHead className="text-brandblue">
Delete the Portfolio:<p className="text-xs text-gray-500">Permanently delete the portfolio and all property data assigned to this portfolio</p>
</TableHead>
<TableCell className="flex justify-end">
<Button className="bg-red-700 w-42" onClick={handleOpenDeleteModal}>Delete Portfolio</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
<Dialog open={isDeleteModalOpen} onOpenChange={setIsDeleteModalOpen}>
<DialogContent>
<DialogTitle>Are you sure?</DialogTitle>
<p>
To confirm, please type the name of the portfolio (
<strong>{portfolioSettingsData.name}</strong>)
</p>
<input
type="text"
value={deleteConfirmationByName}
onChange={(e) => setDeleteConfirmationByName(e.target.value)}
placeholder="Type portfolio name"
/>
<DialogFooter>
<Button
className="max-width: 100% bg-red-700"
onClick={handleOpenDeleteModal}
className="bg-green-600"
onClick={() => setIsDeleteModalOpen(false)}
>
Delete Portfolio
Cancel
</Button>
</div>
{/* Delete portfolio modal */}
<Dialog open={isDeleteModalOpen} onOpenChange={setIsDeleteModalOpen}>
<DialogContent>
<DialogTitle>Are you sure?</DialogTitle>
<p>
To confirm, please type the name of the portfolio (
<strong>{portfolioSettingsData.name}</strong>)
</p>
<input
type="text"
value={deleteConfirmationByName}
onChange={(e) => setDeleteConfirmationByName(e.target.value)}
placeholder="Type portfolio name"
/>
<DialogFooter>
<Button
className="bg-green-600"
onClick={() => setIsDeleteModalOpen(false)}
>
Cancel
</Button>
<Button
className="bg-red-700"
onClick={handleDeleteConfirmation}
disabled={
deleteConfirmationByName !== portfolioSettingsData.name
}
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
</>
<Button
className="bg-red-700"
onClick={handleDeleteConfirmation}
disabled={deleteConfirmationByName !== portfolioSettingsData.name}
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
);
}

View file

@ -7,24 +7,8 @@ export default async function PortfolioSettingsPage({
params: { slug: string };
}) {
const portfolioId = params.slug;
// fetch data securely on the server
// Stef's page!!!!
// 1) Rename
// 2) Update budget, status, goal
// 3) Delete - much harder
// fetch data in the server - name, budget, goal,
// pass it to a client component to render and take user input
const portfolioSettingsData = await getPortfolioSettings(portfolioId);
// Get goal options and status options by importing something like this:
// import {
// PortfolioStatus,
// PortfolioGoal,
// } from "./../../db/schema/portfolio";
// and then pass them to the portfoliosettings component
return (
<>
<div className="flex justify-center max-w-8xl w-8xl">