From 7b112ed7dc7aac147012217a4f4422bc4494b1a0 Mon Sep 17 00:00:00 2001 From: Niklas Dietzel Date: Thu, 2 Oct 2025 23:12:47 +0200 Subject: [PATCH 01/52] convert prismaClient.js to TypeScript --- prisma/prismaClient.js | 16 ---------------- prisma/prismaClient.ts | 8 ++++++++ 2 files changed, 8 insertions(+), 16 deletions(-) delete mode 100644 prisma/prismaClient.js create mode 100644 prisma/prismaClient.ts diff --git a/prisma/prismaClient.js b/prisma/prismaClient.js deleted file mode 100644 index 8d0292f..0000000 --- a/prisma/prismaClient.js +++ /dev/null @@ -1,16 +0,0 @@ - -import { PrismaClient } from "@prisma/client"; - -const prisma = globalThis.prisma ?? new PrismaClient(); - -export default prisma; - -if (process.env.NODE_ENV !== "production") globalThis.prisma = prisma; - -// const bruh = new PrismaClient() - -// bruh.workGroup.create({ -// data: { -// name -// } -// }) \ No newline at end of file diff --git a/prisma/prismaClient.ts b/prisma/prismaClient.ts new file mode 100644 index 0000000..f055c17 --- /dev/null +++ b/prisma/prismaClient.ts @@ -0,0 +1,8 @@ + +import { PrismaClient } from "@prisma/client"; + +const prisma: PrismaClient = globalThis.prisma ?? new PrismaClient(); + +if (process.env.NODE_ENV !== "production") globalThis.prisma = prisma; + +export default prisma; From 6dd323cc6f07d4bc737dedaeb80cfa071df29975 Mon Sep 17 00:00:00 2001 From: Niklas Dietzel Date: Wed, 22 Oct 2025 17:42:04 +0200 Subject: [PATCH 02/52] WIP menu --- app/(pages)/(main)/escape/menu/layout.ts | 8 + app/(pages)/(main)/escape/menu/page.tsx | 74 +++++ app/(pages)/(main)/volunteering/menu/page.tsx | 304 ++++++++++++++++++ app/(pages)/(main)/volunteering/page.js | 25 +- app/api/v2/escape/menu/categories/route.ts | 32 ++ app/api/v2/escape/menu/products/route.ts | 72 +++++ app/api/v2/escape/menu/route.ts | 0 prisma/schema.prisma | 29 +- 8 files changed, 521 insertions(+), 23 deletions(-) create mode 100644 app/(pages)/(main)/escape/menu/layout.ts create mode 100644 app/(pages)/(main)/escape/menu/page.tsx create mode 100644 app/(pages)/(main)/volunteering/menu/page.tsx create mode 100644 app/api/v2/escape/menu/categories/route.ts create mode 100644 app/api/v2/escape/menu/products/route.ts create mode 100644 app/api/v2/escape/menu/route.ts diff --git a/app/(pages)/(main)/escape/menu/layout.ts b/app/(pages)/(main)/escape/menu/layout.ts new file mode 100644 index 0000000..9f9a080 --- /dev/null +++ b/app/(pages)/(main)/escape/menu/layout.ts @@ -0,0 +1,8 @@ +export const metadata = { + title: "Escape Menu", + description: "Escape Bar Menu", +}; + +export default function Layout({children}) { + return children; +} \ No newline at end of file diff --git a/app/(pages)/(main)/escape/menu/page.tsx b/app/(pages)/(main)/escape/menu/page.tsx new file mode 100644 index 0000000..25de512 --- /dev/null +++ b/app/(pages)/(main)/escape/menu/page.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { Button, Card, Collapse, Grid, Typography } from "@mui/material"; +import { useEffect, useState } from "react"; +import { MenuCategoryWithProducts } from "@/app/api/v2/escape/menu/products/route"; + + +export default function menu() { + const [menuCategories, setMenuCategories] = useState([]); + + useEffect(() => { + fetch("/api/v2/escape/menu/products") + .then(res => res.json()) + .then(categories => setMenuCategories(categories)) + }, []); + + return ( +
+ + Menu + + + { menuCategories.map((item) => + + + ) } +
+ ) +} + +function Category(props: { + category: MenuCategoryWithProducts, +}) { + const category = props.category; + const [expanded, setExpanded] = useState(false); + + return ( + + + + + + + { + category.menu_products.map((item) => + <> + + { item.name } + + + { item.volume } CL + + + { item.price },- + + + ) + } + + + + + + ) + +} diff --git a/app/(pages)/(main)/volunteering/menu/page.tsx b/app/(pages)/(main)/volunteering/menu/page.tsx new file mode 100644 index 0000000..cab23a3 --- /dev/null +++ b/app/(pages)/(main)/volunteering/menu/page.tsx @@ -0,0 +1,304 @@ +"use client"; + +import { Button, Input, Stack, Typography } from "@mui/material"; +import { useEffect, useState } from "react"; +import { + MenuCategoryCreate, + MenuCategoryWithProducts, + MenuProductCreate +} from "@/app/api/v2/escape/menu/products/route"; +import { MenuCategory, MenuProduct } from "@prisma/client"; + +export default function menu() { + const [menuCategories, setMenuCategories] = useState([]); + + useEffect(() => { + refetchMenu(); + }, []); + + const refetchMenu = () => fetchMenu().then(menu => setMenuCategories(menu)); + + return ( + + { + menuCategories.map((item) => { + return ( + + ); + }) + } + + + + ) +} + +function updateProduct(product: MenuProduct, newAttributes: Partial): Promise { + return fetch("/api/v2/escape/menu/products", { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({...product, ...newAttributes}) + }) +} + +function createProduct(product: MenuProductCreate): Promise { + return fetch("/api/v2/escape/menu/products", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(product) + }); +} + +function updateCategory(category: MenuCategoryWithProducts, newAttributes: Partial): Promise { + + return fetch("/api/v2/escape/menu/categories", { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({...category, ...newAttributes, ...{menu_products: undefined}}) + }); +} + +function createCategory(category: MenuCategoryCreate): Promise { + return fetch("/api/v2/escape/menu/categories", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(category) + }); +} + + +function Category(props: { category: MenuCategoryWithProducts, onUpdate: () => void }) { + + let [categoryName, setCategoryName] = useState(props.category.name); + + let [hasBeenUpdated, setHasBeenUpdated] = useState(false); + let [isFirst, setIsFirst] = useState(true); + + + useEffect(() => { + if (isFirst) { + setIsFirst(false); + return; + } + + setHasBeenUpdated(true); + }, [categoryName]); + + return ( + + { + props.category.id !== null ? ( + + { + setCategoryName(e.target.value) + } } + + style={ + { + fontSize: "3.75rem" + } + } + + > + + + ) + : { props.category.name } + } + + + + Name + Price + Volume (cL) + + + + { + props.category.menu_products.map((item) => ( + + ) + ) + } + + + + ) +} + +function Product(props: { product: MenuProduct, onUpdate: () => void }) { + const product = props.product; + + let [productName, setProductName] = useState(product.name); + let [productPrice, setProductPrice] = useState(product.price); + let [productVolume, setProductVolume] = useState(product.volume); + + let [hasBeenUpdated, setHasBeenUpdated] = useState(false); + let [isFirst, setIsFirst] = useState(true); + + useEffect(() => { + if (isFirst) { + setIsFirst(false); + return; + } + + setHasBeenUpdated(true); + }, [productName, productPrice, productVolume]); + + return ( + + + + setProductName(e.target.value) } + > + setProductPrice(Number(e.target.value)) } + > + + setProductVolume(Number(e.target.value)) } + > + + + + ) +} + +function NewProduct(props: { onUpdate: () => void, categoryId: number | null }) { + let [productName, setProductName] = useState(""); + let [productPrice, setProductPrice] = useState(null); + let [productVolume, setProductVolume] = useState(null); + + return ( + + + setProductName(e.target.value) } + placeholder="Name" + > + setProductPrice(Number(e.target.value)) } + placeholder="Price" + > + + setProductVolume(Number(e.target.value)) } + placeholder="Volume" + > + + + + ); +} + + +function NewCategory(props: { onUpdate: () => void }) { + let [categoryName, setCategoryName] = useState(""); + + return ( + + setCategoryName(e.target.value) } + style={ {fontSize: "3.75rem"} } + + > + + + + + ) +} + +async function fetchMenu(): Promise { + const menu = await fetch("/api/v2/escape/menu/products"); + return await menu.json(); +} \ No newline at end of file diff --git a/app/(pages)/(main)/volunteering/page.js b/app/(pages)/(main)/volunteering/page.js index 8032ff6..a20eb41 100644 --- a/app/(pages)/(main)/volunteering/page.js +++ b/app/(pages)/(main)/volunteering/page.js @@ -25,6 +25,7 @@ const BUTTON_CONTENT_1 = [ // { title: "Café shifts", path: "/volunteering/cafe" }, { title: "Vouchers", path: "/volunteering/logs" }, { title: "Membership", path: "/volunteering/membership" }, + { title: "Menu", path: "/volunteering/menu" }, { title: "Website content", path: "/studio" }, ]; @@ -56,20 +57,20 @@ function VolunteeringPage(params) { const [vouchersEarned, setVouchersEarned] = useState(0); const [vouchersUsed, setVouchersUsed] = useState(0); - + useEffect(() => { sanityFetch(setPages); }, []); - - + + useEffect(() => { - + fetch("/api/v2/semester") .then(res => res.json()) .then(data => { setSemester(data.semester) }); - + fetch("/api/v2/semesterVolunteerInfo") .then(res => res.json()) .then(data => { @@ -80,9 +81,9 @@ function VolunteeringPage(params) { setVouchersUsed(data.vouchersUsed) }) - + }, []) - + // Semester-based data return ( @@ -111,14 +112,14 @@ function VolunteeringPage(params) { }) : <>} - + ); } function createNavigation(semester, paidMemberships, vouchersEarned, vouchersUsed, numVolunteers, volunteerHours) { - - const buttonGroup1 = createButtons(BUTTON_CONTENT_1); + + const buttonGroup1 = createButtons(BUTTON_CONTENT_1); return ( @@ -172,7 +173,7 @@ function createNavigation(semester, paidMemberships, vouchersEarned, vouchersUse } function createButtons(content) { - + const gridItems = content.map((e) => { return ( @@ -194,7 +195,7 @@ function createButtons(content) { ); }); - + return ( {gridItems} diff --git a/app/api/v2/escape/menu/categories/route.ts b/app/api/v2/escape/menu/categories/route.ts new file mode 100644 index 0000000..fb6b9e9 --- /dev/null +++ b/app/api/v2/escape/menu/categories/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from "next/server"; +import { MenuCategory } from "@prisma/client"; +import prismaClient from "@/prisma/prismaClient"; +import { MenuCategoryCreate } from "@/app/api/v2/escape/menu/products/route"; + +export async function PATCH( + req: NextRequest +) { + const category: MenuCategory = await req.json(); + + const newProduct = await prismaClient.menuCategory.update({ + where: { + id: category.id + }, + data: category + }) + + + return NextResponse.json(JSON.stringify(newProduct)); +} + +export async function POST( + req: NextRequest +) { + const category: MenuCategoryCreate = await req.json(); + + const newCategory = await prismaClient.menuCategory.create({ + data: category + }); + + return NextResponse.json(JSON.stringify(newCategory)); +} \ No newline at end of file diff --git a/app/api/v2/escape/menu/products/route.ts b/app/api/v2/escape/menu/products/route.ts new file mode 100644 index 0000000..51950d7 --- /dev/null +++ b/app/api/v2/escape/menu/products/route.ts @@ -0,0 +1,72 @@ +import { NextRequest, NextResponse } from "next/server"; +import prisma from "@/prisma/prismaClient"; +import { Prisma } from "@prisma/client"; +import prismaClient from "@/prisma/prismaClient"; + +export async function GET() { + let menuCategories = await prisma.menuCategory.findMany( + { + include: { + menu_products: true + } + } + ); + + + let nullCategoryProducts = await prisma.menuProduct.findMany( + { + where: { + category_id: { + equals: null + } + }, + } + ); + let nullCategory = { + name: "Uncategorized", + id: null, + menu_products: nullCategoryProducts + }; + + menuCategories.push(nullCategory) + + return NextResponse.json(menuCategories); +} + +export async function PATCH( + req: NextRequest +) { + const product: MenuProduct = await req.json(); + + const newProduct = await prismaClient.menuProduct.update({ + where: { + id: product.id + }, + data: product + }) + + + return NextResponse.json(JSON.stringify(newProduct)); +} + +export async function POST( + req: NextRequest +) { + const product: MenuProductCreate = await req.json(); + + await prisma.menuProduct.create({ + data: product + }); + + return NextResponse.json(JSON.stringify({})); +} + +const menuCategoryWithProducts = Prisma.validator()({include: {menu_products: true}}) +export type MenuCategoryWithProducts = Prisma.MenuCategoryGetPayload + +export type MenuProductCreate = Prisma.MenuProductCreateArgs["data"] + +const menuProduct = Prisma.validator()({}); +export type MenuProduct = Prisma.MenuProductGetPayload; + +export type MenuCategoryCreate = Prisma.MenuCategoryCreateArgs["data"] \ No newline at end of file diff --git a/app/api/v2/escape/menu/route.ts b/app/api/v2/escape/menu/route.ts new file mode 100644 index 0000000..e69de29 diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 474f1bf..25d7f56 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -244,14 +244,14 @@ model ActivateToken { } model Voucher { - id String @id @default(cuid()) + id String @id @default(cuid()) usedAt DateTime? workLogEntryId String - loggedDate DateTime @db.Timestamp(0) - expirationDate DateTime @db.Timestamp(0) + loggedDate DateTime @db.Timestamp(0) + expirationDate DateTime @db.Timestamp(0) userId String - workLogEntry WorkLog @relation(fields: [workLogEntryId], references: [id], onDelete: Cascade) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + workLogEntry WorkLog @relation(fields: [workLogEntryId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([workLogEntryId], map: "Voucher_workLogEntryId_fkey") @@index([userId], map: "Voucher_userId_fkey") @@ -369,15 +369,22 @@ model ShiftCafe { @@index([shiftManager], map: "ShiftCafe_shiftWorker_User_idx") } +model MenuCategory { + id Int @id @default(autoincrement()) + name String @db.VarChar(45) + menu_products MenuProduct[] @relation("MenuProduct_category") +} + model MenuProduct { - id Int @id - name String @db.VarChar(45) - volume Float @db.Float + id Int @id @default(autoincrement()) + name String @db.VarChar(45) + volume Float @db.Float price Int priceVolunteer Int - glutenfree Int @db.TinyInt - active Int? @default(1) @db.TinyInt - category String? @db.VarChar(45) + glutenfree Int @db.TinyInt + active Int @default(1) @db.TinyInt + category_id Int? + category MenuCategory? @relation("MenuProduct_category", fields: [category_id], references: [id]) } model old_users { From 3c927932e228b6761f69f4a2dc78b10c2f5fddf4 Mon Sep 17 00:00:00 2001 From: Niklas Dietzel Date: Wed, 22 Oct 2025 18:13:20 +0200 Subject: [PATCH 03/52] require being logged in to access /volunteering/menu --- app/(pages)/(main)/volunteering/menu/page.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/(pages)/(main)/volunteering/menu/page.tsx b/app/(pages)/(main)/volunteering/menu/page.tsx index cab23a3..57e63de 100644 --- a/app/(pages)/(main)/volunteering/menu/page.tsx +++ b/app/(pages)/(main)/volunteering/menu/page.tsx @@ -8,8 +8,11 @@ import { MenuProductCreate } from "@/app/api/v2/escape/menu/products/route"; import { MenuCategory, MenuProduct } from "@prisma/client"; +import authWrapper from "@/app/middleware/authWrapper"; -export default function menu() { +export default authWrapper(MenuEditPage); + +function MenuEditPage() { const [menuCategories, setMenuCategories] = useState([]); useEffect(() => { @@ -301,4 +304,5 @@ function NewCategory(props: { onUpdate: () => void }) { async function fetchMenu(): Promise { const menu = await fetch("/api/v2/escape/menu/products"); return await menu.json(); -} \ No newline at end of file +} + From 05492ba937528083e50855cf9ae9deb6321c28cc Mon Sep 17 00:00:00 2001 From: Niklas Dietzel Date: Wed, 22 Oct 2025 18:17:17 +0200 Subject: [PATCH 04/52] move GET /menu/products endpoint to /menu (because it makes more sense) --- app/(pages)/(main)/escape/menu/page.tsx | 2 +- app/(pages)/(main)/volunteering/menu/page.tsx | 2 +- app/api/v2/escape/menu/products/route.ts | 30 ----------------- app/api/v2/escape/menu/route.ts | 32 +++++++++++++++++++ 4 files changed, 34 insertions(+), 32 deletions(-) diff --git a/app/(pages)/(main)/escape/menu/page.tsx b/app/(pages)/(main)/escape/menu/page.tsx index 25de512..31f1f7a 100644 --- a/app/(pages)/(main)/escape/menu/page.tsx +++ b/app/(pages)/(main)/escape/menu/page.tsx @@ -9,7 +9,7 @@ export default function menu() { const [menuCategories, setMenuCategories] = useState([]); useEffect(() => { - fetch("/api/v2/escape/menu/products") + fetch("/api/v2/escape/menu") .then(res => res.json()) .then(categories => setMenuCategories(categories)) }, []); diff --git a/app/(pages)/(main)/volunteering/menu/page.tsx b/app/(pages)/(main)/volunteering/menu/page.tsx index 57e63de..b52ef4e 100644 --- a/app/(pages)/(main)/volunteering/menu/page.tsx +++ b/app/(pages)/(main)/volunteering/menu/page.tsx @@ -302,7 +302,7 @@ function NewCategory(props: { onUpdate: () => void }) { } async function fetchMenu(): Promise { - const menu = await fetch("/api/v2/escape/menu/products"); + const menu = await fetch("/api/v2/escape/menu"); return await menu.json(); } diff --git a/app/api/v2/escape/menu/products/route.ts b/app/api/v2/escape/menu/products/route.ts index 51950d7..f6a5075 100644 --- a/app/api/v2/escape/menu/products/route.ts +++ b/app/api/v2/escape/menu/products/route.ts @@ -3,36 +3,6 @@ import prisma from "@/prisma/prismaClient"; import { Prisma } from "@prisma/client"; import prismaClient from "@/prisma/prismaClient"; -export async function GET() { - let menuCategories = await prisma.menuCategory.findMany( - { - include: { - menu_products: true - } - } - ); - - - let nullCategoryProducts = await prisma.menuProduct.findMany( - { - where: { - category_id: { - equals: null - } - }, - } - ); - let nullCategory = { - name: "Uncategorized", - id: null, - menu_products: nullCategoryProducts - }; - - menuCategories.push(nullCategory) - - return NextResponse.json(menuCategories); -} - export async function PATCH( req: NextRequest ) { diff --git a/app/api/v2/escape/menu/route.ts b/app/api/v2/escape/menu/route.ts index e69de29..6bb0fdb 100644 --- a/app/api/v2/escape/menu/route.ts +++ b/app/api/v2/escape/menu/route.ts @@ -0,0 +1,32 @@ +import prisma from "@/prisma/prismaClient"; +import { NextResponse } from "next/server"; + +export async function GET(): Promise { + let menuCategories = await prisma.menuCategory.findMany( + { + include: { + menu_products: true + } + } + ); + + + let nullCategoryProducts = await prisma.menuProduct.findMany( + { + where: { + category_id: { + equals: null + } + }, + } + ); + let nullCategory = { + name: "Uncategorized", + id: null, + menu_products: nullCategoryProducts + }; + + menuCategories.push(nullCategory) + + return NextResponse.json(menuCategories); +} \ No newline at end of file From 4b1520f0fe424ee2b1b71fd23b650831e34064f4 Mon Sep 17 00:00:00 2001 From: Niklas Dietzel Date: Wed, 22 Oct 2025 19:37:16 +0200 Subject: [PATCH 05/52] use prisma's autogenerated type definition for MenuProduct instead of redefining it manually --- app/api/v2/escape/menu/products/route.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/app/api/v2/escape/menu/products/route.ts b/app/api/v2/escape/menu/products/route.ts index f6a5075..be2e379 100644 --- a/app/api/v2/escape/menu/products/route.ts +++ b/app/api/v2/escape/menu/products/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import prisma from "@/prisma/prismaClient"; -import { Prisma } from "@prisma/client"; +import { MenuProduct, Prisma } from "@prisma/client"; import prismaClient from "@/prisma/prismaClient"; export async function PATCH( @@ -35,8 +35,4 @@ const menuCategoryWithProducts = Prisma.validator export type MenuProductCreate = Prisma.MenuProductCreateArgs["data"] - -const menuProduct = Prisma.validator()({}); -export type MenuProduct = Prisma.MenuProductGetPayload; - export type MenuCategoryCreate = Prisma.MenuCategoryCreateArgs["data"] \ No newline at end of file From a50dbd5ef4349e512a3ceecb6e305aa41369b72a Mon Sep 17 00:00:00 2001 From: Niklas Dietzel Date: Wed, 22 Oct 2025 19:37:31 +0200 Subject: [PATCH 06/52] add auth checks to menu API --- app/api/v2/escape/menu/categories/route.ts | 16 ++++++++++++++++ app/api/v2/escape/menu/products/route.ts | 17 +++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/app/api/v2/escape/menu/categories/route.ts b/app/api/v2/escape/menu/categories/route.ts index fb6b9e9..526ba6d 100644 --- a/app/api/v2/escape/menu/categories/route.ts +++ b/app/api/v2/escape/menu/categories/route.ts @@ -2,10 +2,20 @@ import { NextRequest, NextResponse } from "next/server"; import { MenuCategory } from "@prisma/client"; import prismaClient from "@/prisma/prismaClient"; import { MenuCategoryCreate } from "@/app/api/v2/escape/menu/products/route"; +import { getServerSession } from "next-auth"; +import { Auth } from "@/app/api/utils/auth"; +import { authOptions } from "@/app/api/utils/authOptions"; export async function PATCH( req: NextRequest ) { + + const session = await getServerSession(authOptions); + const authCheck: Auth = new Auth(session) + .requireRoles([]); + + if (authCheck.failed) return authCheck.response; + const category: MenuCategory = await req.json(); const newProduct = await prismaClient.menuCategory.update({ @@ -22,6 +32,12 @@ export async function PATCH( export async function POST( req: NextRequest ) { + const session = await getServerSession(authOptions); + const authCheck = new Auth(session) + .requireRoles([]); + + if (authCheck.failed) return authCheck.response; + const category: MenuCategoryCreate = await req.json(); const newCategory = await prismaClient.menuCategory.create({ diff --git a/app/api/v2/escape/menu/products/route.ts b/app/api/v2/escape/menu/products/route.ts index be2e379..4488164 100644 --- a/app/api/v2/escape/menu/products/route.ts +++ b/app/api/v2/escape/menu/products/route.ts @@ -2,10 +2,20 @@ import { NextRequest, NextResponse } from "next/server"; import prisma from "@/prisma/prismaClient"; import { MenuProduct, Prisma } from "@prisma/client"; import prismaClient from "@/prisma/prismaClient"; +import { authOptions } from "@/app/api/utils/authOptions"; +import { getServerSession } from "next-auth"; +import { Auth } from "@/app/api/utils/auth"; export async function PATCH( req: NextRequest ) { + const session = await getServerSession(authOptions); + const authCheck = new Auth(session) + .requireRoles([]); + + if (authCheck.failed) return authCheck.response; + + const product: MenuProduct = await req.json(); const newProduct = await prismaClient.menuProduct.update({ @@ -22,6 +32,13 @@ export async function PATCH( export async function POST( req: NextRequest ) { + const session = await getServerSession(authOptions); + const authCheck = new Auth(session) + .requireRoles([]); + + if (authCheck.failed) return authCheck.response; + + const product: MenuProductCreate = await req.json(); await prisma.menuProduct.create({ From 6e6f8bb00269a7cc57bdcea46a981f225dc60444 Mon Sep 17 00:00:00 2001 From: Niklas Dietzel Date: Wed, 22 Oct 2025 19:47:44 +0200 Subject: [PATCH 07/52] separate product and category related functions into their own files --- .../(main)/volunteering/menu/category.tsx | 197 +++++++++++++ app/(pages)/(main)/volunteering/menu/page.tsx | 270 +----------------- .../(main)/volunteering/menu/product.tsx | 73 +++++ 3 files changed, 274 insertions(+), 266 deletions(-) create mode 100644 app/(pages)/(main)/volunteering/menu/category.tsx create mode 100644 app/(pages)/(main)/volunteering/menu/product.tsx diff --git a/app/(pages)/(main)/volunteering/menu/category.tsx b/app/(pages)/(main)/volunteering/menu/category.tsx new file mode 100644 index 0000000..a671048 --- /dev/null +++ b/app/(pages)/(main)/volunteering/menu/category.tsx @@ -0,0 +1,197 @@ +import { useEffect, useState } from "react"; +import { Button, Input, Stack, Typography } from "@mui/material"; +import { + MenuCategoryCreate, + MenuCategoryWithProducts, + MenuProductCreate +} from "@/app/api/v2/escape/menu/products/route"; +import { MenuCategory } from "@prisma/client"; + +import { Product } from "@/app/(pages)/(main)/volunteering/menu/product"; + +function updateCategory(category: MenuCategoryWithProducts, newAttributes: Partial): Promise { + + return fetch("/api/v2/escape/menu/categories", { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({...category, ...newAttributes, ...{menu_products: undefined}}) + }); +} + +function createCategory(category: MenuCategoryCreate): Promise { + return fetch("/api/v2/escape/menu/categories", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(category) + }); +} + +export function Category(props: { category: MenuCategoryWithProducts, onUpdate: () => void }) { + + let [categoryName, setCategoryName] = useState(props.category.name); + + let [hasBeenUpdated, setHasBeenUpdated] = useState(false); + let [isFirst, setIsFirst] = useState(true); + + + useEffect(() => { + if (isFirst) { + setIsFirst(false); + return; + } + + setHasBeenUpdated(true); + }, [categoryName]); + + return ( + + { + props.category.id !== null ? ( + + { + setCategoryName(e.target.value) + } } + + style={ + { + fontSize: "3.75rem" + } + } + + > + + + ) + : { props.category.name } + } + + + + Name + Price + Volume (cL) + + + + { + props.category.menu_products.map((item) => ( + + ) + ) + } + + + + ) +} + +export function NewCategory(props: { onUpdate: () => void }) { + let [categoryName, setCategoryName] = useState(""); + + return ( + + setCategoryName(e.target.value) } + style={ {fontSize: "3.75rem"} } + + > + + + + + ) +} + +function NewProduct(props: { onUpdate: () => void, categoryId: number | null }) { + let [productName, setProductName] = useState(""); + let [productPrice, setProductPrice] = useState(null); + let [productVolume, setProductVolume] = useState(null); + + return ( + + + setProductName(e.target.value) } + placeholder="Name" + > + setProductPrice(Number(e.target.value)) } + placeholder="Price" + > + + setProductVolume(Number(e.target.value)) } + placeholder="Volume" + > + + + + ); +} + +function createProduct(product: MenuProductCreate): Promise { + return fetch("/api/v2/escape/menu/products", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(product) + }); +} diff --git a/app/(pages)/(main)/volunteering/menu/page.tsx b/app/(pages)/(main)/volunteering/menu/page.tsx index b52ef4e..f5a620a 100644 --- a/app/(pages)/(main)/volunteering/menu/page.tsx +++ b/app/(pages)/(main)/volunteering/menu/page.tsx @@ -1,15 +1,12 @@ "use client"; -import { Button, Input, Stack, Typography } from "@mui/material"; +import { Stack } from "@mui/material"; import { useEffect, useState } from "react"; -import { - MenuCategoryCreate, - MenuCategoryWithProducts, - MenuProductCreate -} from "@/app/api/v2/escape/menu/products/route"; -import { MenuCategory, MenuProduct } from "@prisma/client"; +import { MenuCategoryWithProducts } from "@/app/api/v2/escape/menu/products/route"; import authWrapper from "@/app/middleware/authWrapper"; +import { Category, NewCategory } from "@/app/(pages)/(main)/volunteering/menu/category"; +// require login export default authWrapper(MenuEditPage); function MenuEditPage() { @@ -42,265 +39,6 @@ function MenuEditPage() { ) } -function updateProduct(product: MenuProduct, newAttributes: Partial): Promise { - return fetch("/api/v2/escape/menu/products", { - method: "PATCH", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({...product, ...newAttributes}) - }) -} - -function createProduct(product: MenuProductCreate): Promise { - return fetch("/api/v2/escape/menu/products", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(product) - }); -} - -function updateCategory(category: MenuCategoryWithProducts, newAttributes: Partial): Promise { - - return fetch("/api/v2/escape/menu/categories", { - method: "PATCH", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({...category, ...newAttributes, ...{menu_products: undefined}}) - }); -} - -function createCategory(category: MenuCategoryCreate): Promise { - return fetch("/api/v2/escape/menu/categories", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(category) - }); -} - - -function Category(props: { category: MenuCategoryWithProducts, onUpdate: () => void }) { - - let [categoryName, setCategoryName] = useState(props.category.name); - - let [hasBeenUpdated, setHasBeenUpdated] = useState(false); - let [isFirst, setIsFirst] = useState(true); - - - useEffect(() => { - if (isFirst) { - setIsFirst(false); - return; - } - - setHasBeenUpdated(true); - }, [categoryName]); - - return ( - - { - props.category.id !== null ? ( - - { - setCategoryName(e.target.value) - } } - - style={ - { - fontSize: "3.75rem" - } - } - - > - - - ) - : { props.category.name } - } - - - - Name - Price - Volume (cL) - - - - { - props.category.menu_products.map((item) => ( - - ) - ) - } - - - - ) -} - -function Product(props: { product: MenuProduct, onUpdate: () => void }) { - const product = props.product; - - let [productName, setProductName] = useState(product.name); - let [productPrice, setProductPrice] = useState(product.price); - let [productVolume, setProductVolume] = useState(product.volume); - - let [hasBeenUpdated, setHasBeenUpdated] = useState(false); - let [isFirst, setIsFirst] = useState(true); - - useEffect(() => { - if (isFirst) { - setIsFirst(false); - return; - } - - setHasBeenUpdated(true); - }, [productName, productPrice, productVolume]); - - return ( - - - - setProductName(e.target.value) } - > - setProductPrice(Number(e.target.value)) } - > - - setProductVolume(Number(e.target.value)) } - > - - - - ) -} - -function NewProduct(props: { onUpdate: () => void, categoryId: number | null }) { - let [productName, setProductName] = useState(""); - let [productPrice, setProductPrice] = useState(null); - let [productVolume, setProductVolume] = useState(null); - - return ( - - - setProductName(e.target.value) } - placeholder="Name" - > - setProductPrice(Number(e.target.value)) } - placeholder="Price" - > - - setProductVolume(Number(e.target.value)) } - placeholder="Volume" - > - - - - ); -} - - -function NewCategory(props: { onUpdate: () => void }) { - let [categoryName, setCategoryName] = useState(""); - - return ( - - setCategoryName(e.target.value) } - style={ {fontSize: "3.75rem"} } - - > - - - - - ) -} - async function fetchMenu(): Promise { const menu = await fetch("/api/v2/escape/menu"); return await menu.json(); diff --git a/app/(pages)/(main)/volunteering/menu/product.tsx b/app/(pages)/(main)/volunteering/menu/product.tsx new file mode 100644 index 0000000..577e876 --- /dev/null +++ b/app/(pages)/(main)/volunteering/menu/product.tsx @@ -0,0 +1,73 @@ +import { MenuProduct } from "@prisma/client"; +import { useEffect, useState } from "react"; +import { Button, Input, Stack } from "@mui/material"; + +function updateProduct(product: MenuProduct, newAttributes: Partial): Promise { + return fetch("/api/v2/escape/menu/products", { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({...product, ...newAttributes}) + }) +} + +export function Product(props: { product: MenuProduct, onUpdate: () => void }) { + const product = props.product; + + let [productName, setProductName] = useState(product.name); + let [productPrice, setProductPrice] = useState(product.price); + let [productVolume, setProductVolume] = useState(product.volume); + + let [hasBeenUpdated, setHasBeenUpdated] = useState(false); + let [isFirst, setIsFirst] = useState(true); + + useEffect(() => { + if (isFirst) { + setIsFirst(false); + return; + } + + setHasBeenUpdated(true); + }, [productName, productPrice, productVolume]); + + return ( + + + + setProductName(e.target.value) } + > + setProductPrice(Number(e.target.value)) } + > + + setProductVolume(Number(e.target.value)) } + > + + + + ) +} \ No newline at end of file From 86e7f9cf37e12435d0e9b08eaaadbe2c893ce7ce Mon Sep 17 00:00:00 2001 From: Niklas Dietzel Date: Wed, 22 Oct 2025 19:49:24 +0200 Subject: [PATCH 08/52] remove support for uncategorized items the implementation details were quite ugly, and if desired, a category called "Uncategorized" could just be created --- .../(main)/volunteering/menu/category.tsx | 64 +++++++++---------- app/api/v2/escape/menu/route.ts | 20 +----- prisma/schema.prisma | 2 +- 3 files changed, 33 insertions(+), 53 deletions(-) diff --git a/app/(pages)/(main)/volunteering/menu/category.tsx b/app/(pages)/(main)/volunteering/menu/category.tsx index a671048..0965115 100644 --- a/app/(pages)/(main)/volunteering/menu/category.tsx +++ b/app/(pages)/(main)/volunteering/menu/category.tsx @@ -50,39 +50,37 @@ export function Category(props: { category: MenuCategoryWithProducts, onUpdate: return ( { - props.category.id !== null ? ( - - { - setCategoryName(e.target.value) - } } - - style={ - { - fontSize: "3.75rem" - } - } - - > - - - ) - : { props.category.name } + + + { + setCategoryName(e.target.value) + } } + + style={ + { + fontSize: "3.75rem" + } + } + + > + + } diff --git a/app/api/v2/escape/menu/route.ts b/app/api/v2/escape/menu/route.ts index 6bb0fdb..f4a3cf2 100644 --- a/app/api/v2/escape/menu/route.ts +++ b/app/api/v2/escape/menu/route.ts @@ -9,24 +9,6 @@ export async function GET(): Promise { } } ); - - - let nullCategoryProducts = await prisma.menuProduct.findMany( - { - where: { - category_id: { - equals: null - } - }, - } - ); - let nullCategory = { - name: "Uncategorized", - id: null, - menu_products: nullCategoryProducts - }; - - menuCategories.push(nullCategory) - + return NextResponse.json(menuCategories); } \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 25d7f56..5e55e6e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -383,7 +383,7 @@ model MenuProduct { priceVolunteer Int glutenfree Int @db.TinyInt active Int @default(1) @db.TinyInt - category_id Int? + category_id Int category MenuCategory? @relation("MenuProduct_category", fields: [category_id], references: [id]) } From 7b641289eb39d73b69a6eea369a697d5a9a56be7 Mon Sep 17 00:00:00 2001 From: Niklas Dietzel Date: Wed, 22 Oct 2025 20:10:14 +0200 Subject: [PATCH 09/52] switch to a much cleaner Grid based layout --- .../(main)/volunteering/menu/category.tsx | 128 ++++++++++-------- .../(main)/volunteering/menu/product.tsx | 71 +++++----- 2 files changed, 108 insertions(+), 91 deletions(-) diff --git a/app/(pages)/(main)/volunteering/menu/category.tsx b/app/(pages)/(main)/volunteering/menu/category.tsx index 0965115..c1b7222 100644 --- a/app/(pages)/(main)/volunteering/menu/category.tsx +++ b/app/(pages)/(main)/volunteering/menu/category.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { Button, Input, Stack, Typography } from "@mui/material"; +import { Button, Grid, Input, Stack, Typography } from "@mui/material"; import { MenuCategoryCreate, MenuCategoryWithProducts, @@ -83,22 +83,30 @@ export function Category(props: { category: MenuCategoryWithProducts, onUpdate: } - - - Name - Price - Volume (cL) - - - - { - props.category.menu_products.map((item) => ( - + + + + Name + + + Price + + + Volume (cL) + + +
+
+ + { + props.category.menu_products.map((item) => ( + + ) ) - ) - } + } - + +
) } @@ -138,49 +146,53 @@ function NewProduct(props: { onUpdate: () => void, categoryId: number | null }) let [productVolume, setProductVolume] = useState(null); return ( - - - setProductName(e.target.value) } - placeholder="Name" - > - setProductPrice(Number(e.target.value)) } - placeholder="Price" - > - - setProductVolume(Number(e.target.value)) } - placeholder="Volume" - > - - - + <> + + setProductName(e.target.value) } + placeholder="Name" + > + + + + setProductPrice(Number(e.target.value)) } + placeholder="Price" + > + + + + setProductVolume(Number(e.target.value)) } + placeholder="Volume" + > + + + + + + ); } diff --git a/app/(pages)/(main)/volunteering/menu/product.tsx b/app/(pages)/(main)/volunteering/menu/product.tsx index 577e876..d9ca8fa 100644 --- a/app/(pages)/(main)/volunteering/menu/product.tsx +++ b/app/(pages)/(main)/volunteering/menu/product.tsx @@ -1,6 +1,6 @@ import { MenuProduct } from "@prisma/client"; import { useEffect, useState } from "react"; -import { Button, Input, Stack } from "@mui/material"; +import { Box, Button, Grid, Input, Stack } from "@mui/material"; function updateProduct(product: MenuProduct, newAttributes: Partial): Promise { return fetch("/api/v2/escape/menu/products", { @@ -33,41 +33,46 @@ export function Product(props: { product: MenuProduct, onUpdate: () => void }) { return ( - + <> + + setProductName(e.target.value) } + > + - setProductName(e.target.value) } - > - setProductPrice(Number(e.target.value)) } - > - setProductVolume(Number(e.target.value)) } - > + + setProductPrice(Number(e.target.value)) } + > + - - + + + + ) } \ No newline at end of file From 5d01d78e569027d7721f62b39b88507ffd4ff6dc Mon Sep 17 00:00:00 2001 From: Niklas Dietzel Date: Wed, 22 Oct 2025 20:11:22 +0200 Subject: [PATCH 10/52] move `NewProduct` and `createProduct` to product.tsx --- .../(main)/volunteering/menu/category.tsx | 74 +------------------ .../(main)/volunteering/menu/product.tsx | 69 ++++++++++++++++- 2 files changed, 70 insertions(+), 73 deletions(-) diff --git a/app/(pages)/(main)/volunteering/menu/category.tsx b/app/(pages)/(main)/volunteering/menu/category.tsx index c1b7222..e3e8754 100644 --- a/app/(pages)/(main)/volunteering/menu/category.tsx +++ b/app/(pages)/(main)/volunteering/menu/category.tsx @@ -1,13 +1,9 @@ import { useEffect, useState } from "react"; import { Button, Grid, Input, Stack, Typography } from "@mui/material"; -import { - MenuCategoryCreate, - MenuCategoryWithProducts, - MenuProductCreate -} from "@/app/api/v2/escape/menu/products/route"; +import { MenuCategoryCreate, MenuCategoryWithProducts } from "@/app/api/v2/escape/menu/products/route"; import { MenuCategory } from "@prisma/client"; -import { Product } from "@/app/(pages)/(main)/volunteering/menu/product"; +import { NewProduct, Product } from "@/app/(pages)/(main)/volunteering/menu/product"; function updateCategory(category: MenuCategoryWithProducts, newAttributes: Partial): Promise { @@ -139,69 +135,3 @@ export function NewCategory(props: { onUpdate: () => void }) { ) } - -function NewProduct(props: { onUpdate: () => void, categoryId: number | null }) { - let [productName, setProductName] = useState(""); - let [productPrice, setProductPrice] = useState(null); - let [productVolume, setProductVolume] = useState(null); - - return ( - <> - - setProductName(e.target.value) } - placeholder="Name" - > - - - - setProductPrice(Number(e.target.value)) } - placeholder="Price" - > - - - - setProductVolume(Number(e.target.value)) } - placeholder="Volume" - > - - - - - - - ); -} - -function createProduct(product: MenuProductCreate): Promise { - return fetch("/api/v2/escape/menu/products", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(product) - }); -} diff --git a/app/(pages)/(main)/volunteering/menu/product.tsx b/app/(pages)/(main)/volunteering/menu/product.tsx index d9ca8fa..ef2e1c4 100644 --- a/app/(pages)/(main)/volunteering/menu/product.tsx +++ b/app/(pages)/(main)/volunteering/menu/product.tsx @@ -1,6 +1,7 @@ import { MenuProduct } from "@prisma/client"; import { useEffect, useState } from "react"; -import { Box, Button, Grid, Input, Stack } from "@mui/material"; +import { Button, Grid, Input } from "@mui/material"; +import { MenuProductCreate } from "@/app/api/v2/escape/menu/products/route"; function updateProduct(product: MenuProduct, newAttributes: Partial): Promise { return fetch("/api/v2/escape/menu/products", { @@ -75,4 +76,70 @@ export function Product(props: { product: MenuProduct, onUpdate: () => void }) {
) +} + +export function NewProduct(props: { onUpdate: () => void, categoryId: number | null }) { + let [productName, setProductName] = useState(""); + let [productPrice, setProductPrice] = useState(null); + let [productVolume, setProductVolume] = useState(null); + + return ( + <> + + setProductName(e.target.value) } + placeholder="Name" + > + + + + setProductPrice(Number(e.target.value)) } + placeholder="Price" + > + + + + setProductVolume(Number(e.target.value)) } + placeholder="Volume" + > + + + + + + + ); +} + +function createProduct(product: MenuProductCreate): Promise { + return fetch("/api/v2/escape/menu/products", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(product) + }); } \ No newline at end of file From 6d2113739c26aaf959c37a10b05880ef6916346e Mon Sep 17 00:00:00 2001 From: Niklas Dietzel Date: Wed, 22 Oct 2025 20:52:23 +0200 Subject: [PATCH 11/52] add validation to category name inputs --- .../(main)/volunteering/menu/category.tsx | 45 +++++++++++++------ 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/app/(pages)/(main)/volunteering/menu/category.tsx b/app/(pages)/(main)/volunteering/menu/category.tsx index e3e8754..4363943 100644 --- a/app/(pages)/(main)/volunteering/menu/category.tsx +++ b/app/(pages)/(main)/volunteering/menu/category.tsx @@ -1,9 +1,10 @@ import { useEffect, useState } from "react"; -import { Button, Grid, Input, Stack, Typography } from "@mui/material"; +import { Button, Grid, Input, Stack, TextField, Typography } from "@mui/material"; import { MenuCategoryCreate, MenuCategoryWithProducts } from "@/app/api/v2/escape/menu/products/route"; import { MenuCategory } from "@prisma/client"; import { NewProduct, Product } from "@/app/(pages)/(main)/volunteering/menu/product"; +import { styled } from "@mui/system"; function updateCategory(category: MenuCategoryWithProducts, newAttributes: Partial): Promise { @@ -26,6 +27,12 @@ function createCategory(category: MenuCategoryCreate): Promise { }); } +const LargeTextField = styled(TextField)({ + "& .MuiOutlinedInput-input": { + fontSize: "3.75rem", + } +}) + export function Category(props: { category: MenuCategoryWithProducts, onUpdate: () => void }) { let [categoryName, setCategoryName] = useState(props.category.name); @@ -33,6 +40,7 @@ export function Category(props: { category: MenuCategoryWithProducts, onUpdate: let [hasBeenUpdated, setHasBeenUpdated] = useState(false); let [isFirst, setIsFirst] = useState(true); + const categoryNameInvalid: boolean = categoryName.trim() === ""; useEffect(() => { if (isFirst) { @@ -48,22 +56,22 @@ export function Category(props: { category: MenuCategoryWithProducts, onUpdate: { - { setCategoryName(e.target.value) } } - style={ - { - fontSize: "3.75rem" - } - } + required + label="Category Name" + placeholder="Category Name" + error={ categoryNameInvalid } + helperText={ categoryNameInvalid ? "Name must not be empty" : "" } - > + > @@ -78,60 +53,130 @@ export function Product(props: { product: MenuProduct, onUpdate: () => void }) { ) } -export function NewProduct(props: { onUpdate: () => void, categoryId: number | null }) { - let [productName, setProductName] = useState(""); - let [productPrice, setProductPrice] = useState(null); - let [productVolume, setProductVolume] = useState(null); +type ProductInputs = { + name: string, + price: number, + volume: number, +}; + +function ProductInputs( + props: { + + product: ProductInputs, + onUpdate: (value: { + product: ProductInputs + valid: boolean + }) => void, + + validateInputs?: boolean + } +) { + const validateInputs = props.validateInputs ?? true; + + const nameValid = props.product.name.trim() !== ""; + const priceValid = props.product.price > 0; + const volumeValid = props.product.volume > 0; + + const valid = nameValid && priceValid && volumeValid; return ( <> - setProductName(e.target.value) } - placeholder="Name" - > + value={ props.product.name } + onChange={ e => props.onUpdate({valid, product: {...props.product, name: e.target.value}}) } + + label="Product Name" + placeholder="Product Name" + + + error={ !nameValid && validateInputs } + helperText={ !nameValid && validateInputs ? "Name must be set" : "" } + > + - setProductPrice(Number(e.target.value)) } - placeholder="Price" - > + value={ props.product.price } + onChange={ e => props.onUpdate({ + valid, + product: {...props.product, price: Number(e.target.value)} + }) } + + placeholder="Product Price" + label="Product Price" + error={ !priceValid && validateInputs } + helperText={ !priceValid && validateInputs ? "Price must be greater than 0" : "" } + > - setProductVolume(Number(e.target.value)) } - placeholder="Volume" - > + value={ props.product.volume } + onChange={ e => props.onUpdate({ + valid, + product: {...props.product, volume: Number(e.target.value)} + }) } + + label="Volume (cL)" + placeholder="Volume (cL)" + + error={ !volumeValid && validateInputs } + helperText={ !volumeValid && validateInputs ? "Volume must be greater than 0" : "" } + > + ) +} + +export function NewProduct(props: { onUpdate: () => void, categoryId: number | null }) { + const initialState = { + name: "", + price: 0, + volume: 0, + }; + + let [newProduct, setNewProduct] = useState(initialState); + + let [isFirst, setIsFirst] = useState(true); + let [hasBeenUpdated, setHasBeenUpdated] = useState(false); + useEffect(() => { + if (isFirst) { + setIsFirst(false); + return; + } + + setHasBeenUpdated(true); + }, [newProduct]); + + + return ( + <> + { + setNewProduct(value.product); + + } }/> - ); + ) + ; } function createProduct(product: MenuProductCreate): Promise { From 2db85904004c945738921e4777cd8a5a10f126e2 Mon Sep 17 00:00:00 2001 From: Niklas Dietzel Date: Thu, 23 Oct 2025 11:03:20 +0200 Subject: [PATCH 13/52] change active and glutenfree to be booleans in the database schema --- prisma/schema.prisma | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5e55e6e..c4da523 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -381,8 +381,8 @@ model MenuProduct { volume Float @db.Float price Int priceVolunteer Int - glutenfree Int @db.TinyInt - active Int @default(1) @db.TinyInt + glutenfree Boolean + active Boolean @default(true) category_id Int category MenuCategory? @relation("MenuProduct_category", fields: [category_id], references: [id]) } From 65658f1b2dbe7d7065d5eb58ac2b597981d10e7f Mon Sep 17 00:00:00 2001 From: Niklas Dietzel Date: Thu, 23 Oct 2025 11:03:31 +0200 Subject: [PATCH 14/52] add input for gluten free --- .../(main)/volunteering/menu/product.tsx | 23 ++++++++++++++++--- app/api/v2/escape/menu/products/route.ts | 5 ++-- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/app/(pages)/(main)/volunteering/menu/product.tsx b/app/(pages)/(main)/volunteering/menu/product.tsx index 549b6ff..6d39fca 100644 --- a/app/(pages)/(main)/volunteering/menu/product.tsx +++ b/app/(pages)/(main)/volunteering/menu/product.tsx @@ -1,6 +1,6 @@ import { MenuProduct } from "@prisma/client"; import { useEffect, useState } from "react"; -import { Button, Grid, Input, TextField } from "@mui/material"; +import { Button, Checkbox, FormControlLabel, Grid, TextField } from "@mui/material"; import { MenuProductCreate } from "@/app/api/v2/escape/menu/products/route"; function updateProduct(product: MenuProduct, newAttributes: Partial): Promise { @@ -57,6 +57,7 @@ type ProductInputs = { name: string, price: number, volume: number, + glutenfree: boolean, }; function ProductInputs( @@ -129,14 +130,31 @@ function ProductInputs( helperText={ !volumeValid && validateInputs ? "Volume must be greater than 0" : "" } >
+ + + props.onUpdate({ + valid, + product: {...props.product, glutenfree: e.target.checked} + }) } + /> + } + label={ "Gluten-free" } + /> + ) } export function NewProduct(props: { onUpdate: () => void, categoryId: number | null }) { - const initialState = { + const initialState: ProductInputs = { name: "", price: 0, volume: 0, + glutenfree: false, }; let [newProduct, setNewProduct] = useState(initialState); @@ -165,7 +183,6 @@ export function NewProduct(props: { onUpdate: () => void, categoryId: number | n onClick={ () => createProduct({ ...newProduct, priceVolunteer: 0, - glutenfree: 0, category_id: props.categoryId }).then(() => { setNewProduct(initialState); diff --git a/app/api/v2/escape/menu/products/route.ts b/app/api/v2/escape/menu/products/route.ts index 4488164..f1bbca8 100644 --- a/app/api/v2/escape/menu/products/route.ts +++ b/app/api/v2/escape/menu/products/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import prisma from "@/prisma/prismaClient"; -import { MenuProduct, Prisma } from "@prisma/client"; import prismaClient from "@/prisma/prismaClient"; +import { MenuProduct, Prisma } from "@prisma/client"; import { authOptions } from "@/app/api/utils/authOptions"; import { getServerSession } from "next-auth"; import { Auth } from "@/app/api/utils/auth"; @@ -23,7 +23,8 @@ export async function PATCH( id: product.id }, data: product - }) + }); + return NextResponse.json(JSON.stringify(newProduct)); From 39e25ee82f1deb1e0adad9b709b3ce2e88fda31b Mon Sep 17 00:00:00 2001 From: Niklas Dietzel Date: Thu, 23 Oct 2025 15:18:20 +0200 Subject: [PATCH 15/52] fix product validation the problem was the form state being in multiple places that could de-sync (I think) remedied this by hoisting all the state up to the `Product` and `NewProduct` respectively --- .../(main)/volunteering/menu/product.tsx | 150 ++++++++++++------ 1 file changed, 102 insertions(+), 48 deletions(-) diff --git a/app/(pages)/(main)/volunteering/menu/product.tsx b/app/(pages)/(main)/volunteering/menu/product.tsx index 6d39fca..adea86b 100644 --- a/app/(pages)/(main)/volunteering/menu/product.tsx +++ b/app/(pages)/(main)/volunteering/menu/product.tsx @@ -18,7 +18,15 @@ export function Product(props: { product: MenuProduct, onUpdate: () => void }) { let [isFirst, setIsFirst] = useState(true); let [valid, setValid] = useState(false); - let [newProduct, setNewProduct] = useState(props.product); + let [newProduct, setNewProduct] = useState({ + product: props.product, + valid: { + name: true, + price: true, + volume: true, + allValid: true, + } + }); useEffect(() => { @@ -33,16 +41,16 @@ export function Product(props: { product: MenuProduct, onUpdate: () => void }) { return ( <> - { + { console.log(value) - setValid(value.valid); - setNewProduct(value.product); + setValid(value.valid.allValid); + setNewProduct(value); } }> + >{ isUpdating ? : <>Update } ) @@ -215,6 +222,10 @@ export function NewProduct(props: { onUpdate: () => void, categoryId: number | n let [isFirst, setIsFirst] = useState(true); let [hasBeenUpdated, setHasBeenUpdated] = useState(false); + + let [isCreating, setIsCreating] = useState(false); + + useEffect(() => { if (isFirst) { setIsFirst(false); @@ -234,16 +245,25 @@ export function NewProduct(props: { onUpdate: () => void, categoryId: number | n + onClick={ () => { + setIsCreating(true); + createProduct({ + ...newProduct.product, + priceVolunteer: 0, + category_id: props.categoryId + }).then(() => { + setNewProduct(initialState); + setIsCreating(false); + + setHasBeenUpdated(false); + setIsFirst(true); + props.onUpdate(); + }); + + } } + + >{ isCreating ? : <>Create } + ) From ac76e26e84c4cc709da20adb8c75c160af4e91a0 Mon Sep 17 00:00:00 2001 From: Niklas Dietzel Date: Thu, 23 Oct 2025 15:31:18 +0200 Subject: [PATCH 17/52] disable "Create" button when input is invalid --- .../(main)/volunteering/menu/product.tsx | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/app/(pages)/(main)/volunteering/menu/product.tsx b/app/(pages)/(main)/volunteering/menu/product.tsx index 656b30b..97d7ddc 100644 --- a/app/(pages)/(main)/volunteering/menu/product.tsx +++ b/app/(pages)/(main)/volunteering/menu/product.tsx @@ -244,23 +244,23 @@ export function NewProduct(props: { onUpdate: () => void, categoryId: number | n } }/> - From d50e8a1a569a3fbea42ab52051adb2316a7ae93f Mon Sep 17 00:00:00 2001 From: Niklas Dietzel Date: Thu, 23 Oct 2025 15:35:12 +0200 Subject: [PATCH 18/52] add spinner when creating/updating category --- .../(main)/volunteering/menu/category.tsx | 33 ++++++++++++------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/app/(pages)/(main)/volunteering/menu/category.tsx b/app/(pages)/(main)/volunteering/menu/category.tsx index 8c70be3..8cafc96 100644 --- a/app/(pages)/(main)/volunteering/menu/category.tsx +++ b/app/(pages)/(main)/volunteering/menu/category.tsx @@ -5,6 +5,7 @@ import { MenuCategory } from "@prisma/client"; import { NewProduct, Product } from "@/app/(pages)/(main)/volunteering/menu/product"; import { styled } from "@mui/system"; +import CircularProgress from "@mui/material/CircularProgress"; function updateCategory(category: MenuCategoryWithProducts, newAttributes: Partial): Promise { @@ -39,6 +40,7 @@ export function Category(props: { category: MenuCategoryWithProducts, onUpdate: let [hasBeenUpdated, setHasBeenUpdated] = useState(false); let [isFirst, setIsFirst] = useState(true); + let [isUpdating, setIsUpdating] = useState(false); const categoryNameInvalid: boolean = categoryName.trim() === ""; @@ -71,23 +73,26 @@ export function Category(props: { category: MenuCategoryWithProducts, onUpdate: > + >{ isUpdating ? : <>Update } } - + Name @@ -117,6 +122,7 @@ export function Category(props: { category: MenuCategoryWithProducts, onUpdate: export function NewCategory(props: { onUpdate: () => void }) { let [categoryName, setCategoryName] = useState(""); + let [isCreating, setIsCreating] = useState(false); const invalid = categoryName.trim() === ""; @@ -141,15 +147,20 @@ export function NewCategory(props: { onUpdate: () => void }) { disabled={ invalid } - onClick={ () => createCategory( - {name: categoryName} - ).then(() => { - setCategoryName(""); - props.onUpdate(); - }) + onClick={ () => { + setIsCreating(true); + createCategory( + {name: categoryName} + ).then(() => { + setCategoryName(""); + setIsCreating(false); + props.onUpdate(); + }); + } } - >Create + >{ isCreating ? : <>Create } + ) From f8a137f1f17c9fffc25718aa090bd7f647c0c024 Mon Sep 17 00:00:00 2001 From: Niklas Dietzel Date: Thu, 23 Oct 2025 17:09:26 +0200 Subject: [PATCH 19/52] add API endpoint for deleting products --- .../escape/menu/products/[productId]/route.ts | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 app/api/v2/escape/menu/products/[productId]/route.ts diff --git a/app/api/v2/escape/menu/products/[productId]/route.ts b/app/api/v2/escape/menu/products/[productId]/route.ts new file mode 100644 index 0000000..259c249 --- /dev/null +++ b/app/api/v2/escape/menu/products/[productId]/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from "next/server"; +import prisma from "@/prisma/prismaClient"; +import { Prisma } from "@prisma/client"; +import { Auth } from "@/app/api/utils/auth"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/app/api/utils/authOptions"; + + +// delete a product +export async function DELETE( + _req: NextRequest, + context: { params: Promise<{ productId: string }> } +): Promise { + let params = await context.params; + + const session = await getServerSession(authOptions); + const authCheck = new Auth(session, params) + .requireRoles([]) + .requireParams(["productId"]); + + const productId = Number(params.productId); + + if (authCheck.failed) return authCheck.response; + + // verify productId is an integer + if (isNaN(productId) || !productId) { + return NextResponse.json({error: "productId must be an integer"}, {status: 400}); + } + + try { + await prisma.menuProduct.delete({ + where: { + id: productId, + } + }); + } catch (error) { + // error code P2015 means no product with the provided id exists + // see https://www.prisma.io/docs/orm/reference/error-reference#p2015 + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2015") { + return NextResponse.json({error: "Not found"}, {status: 404}); + } + } + + return NextResponse.json(JSON.stringify({})); +} \ No newline at end of file From 666b1be028c23c157ad0de7aab1d4a1ccbe30e45 Mon Sep 17 00:00:00 2001 From: Niklas Dietzel Date: Thu, 23 Oct 2025 17:10:19 +0200 Subject: [PATCH 20/52] add button for deleting products --- .../(main)/volunteering/menu/category.tsx | 2 +- .../(main)/volunteering/menu/product.tsx | 105 +++++++++++++++--- 2 files changed, 93 insertions(+), 14 deletions(-) diff --git a/app/(pages)/(main)/volunteering/menu/category.tsx b/app/(pages)/(main)/volunteering/menu/category.tsx index 8cafc96..f830e80 100644 --- a/app/(pages)/(main)/volunteering/menu/category.tsx +++ b/app/(pages)/(main)/volunteering/menu/category.tsx @@ -92,7 +92,7 @@ export function Category(props: { category: MenuCategoryWithProducts, onUpdate: } - + Name diff --git a/app/(pages)/(main)/volunteering/menu/product.tsx b/app/(pages)/(main)/volunteering/menu/product.tsx index 97d7ddc..f03043c 100644 --- a/app/(pages)/(main)/volunteering/menu/product.tsx +++ b/app/(pages)/(main)/volunteering/menu/product.tsx @@ -1,6 +1,17 @@ import { MenuProduct } from "@prisma/client"; import { useEffect, useState } from "react"; -import { Button, Checkbox, FormControlLabel, Grid, TextField } from "@mui/material"; +import { + Button, + Checkbox, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + FormControlLabel, + Grid, + TextField +} from "@mui/material"; import { MenuProductCreate } from "@/app/api/v2/escape/menu/products/route"; import CircularProgress from "@mui/material/CircularProgress"; @@ -11,7 +22,26 @@ function updateProduct(product: MenuProduct, newAttributes: Partial "Content-Type": "application/json", }, body: JSON.stringify({...product, ...newAttributes}) - }) + }); +} + +function createProduct(product: MenuProductCreate): Promise { + return fetch("/api/v2/escape/menu/products", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(product) + }); +} + +function deleteProduct(productId: number): Promise { + return fetch(`/api/v2/escape/menu/products/${ productId }`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + } + }); } export function Product(props: { product: MenuProduct, onUpdate: () => void }) { @@ -31,6 +61,9 @@ export function Product(props: { product: MenuProduct, onUpdate: () => void }) { } }); + let [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + let [isDeleting, setIsDeleting] = useState(false); + useEffect(() => { if (isFirst) { @@ -64,6 +97,30 @@ export function Product(props: { product: MenuProduct, onUpdate: () => void }) { >{ isUpdating ? : <>Update } + + + + + + setDeleteDialogOpen(false) } + onDelete={ () => { + setIsDeleting(true); + deleteProduct(props.product.id).then(() => { + setIsDeleting(false); + setDeleteDialogOpen(false); + + props.onUpdate(); + }); + } } + isDeleting={ isDeleting } + /> + ) } @@ -199,7 +256,8 @@ function ProductInputs( label={ "Gluten-free" } /> - ) + + ) } export function NewProduct(props: { onUpdate: () => void, categoryId: number | null }) { @@ -266,16 +324,37 @@ export function NewProduct(props: { onUpdate: () => void, categoryId: number | n - ) - ; + ); } -function createProduct(product: MenuProductCreate): Promise { - return fetch("/api/v2/escape/menu/products", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(product) - }); + +function DeletionConfirmationDialog(props: { + open: boolean, + onClose: () => void, + onDelete: () => void, + isDeleting: boolean +}) { + return ( + + + Delete product? + + + + Are you sure you want to delete the product? + + + + + + + + ); } \ No newline at end of file From d67cefa18a3e0210e64f4e531a6fe88143c78c0d Mon Sep 17 00:00:00 2001 From: Niklas Dietzel Date: Wed, 29 Oct 2025 11:27:34 +0100 Subject: [PATCH 21/52] redesign customer-facing menu (`/escape/menu` page) --- app/(pages)/(main)/escape/menu/page.tsx | 85 +++++++++++++------------ 1 file changed, 46 insertions(+), 39 deletions(-) diff --git a/app/(pages)/(main)/escape/menu/page.tsx b/app/(pages)/(main)/escape/menu/page.tsx index 31f1f7a..824fe48 100644 --- a/app/(pages)/(main)/escape/menu/page.tsx +++ b/app/(pages)/(main)/escape/menu/page.tsx @@ -1,11 +1,12 @@ "use client"; -import { Button, Card, Collapse, Grid, Typography } from "@mui/material"; +import { Divider, Grid, Stack, Typography } from "@mui/material"; import { useEffect, useState } from "react"; import { MenuCategoryWithProducts } from "@/app/api/v2/escape/menu/products/route"; +import { MenuProduct } from "@prisma/client"; -export default function menu() { +export default function EscapeMenu() { const [menuCategories, setMenuCategories] = useState([]); useEffect(() => { @@ -15,7 +16,7 @@ export default function menu() { }, []); return ( -
+ Menu @@ -23,9 +24,13 @@ export default function menu() { { menuCategories.map((item) => - + + + + + ) } -
+ ) } @@ -33,42 +38,44 @@ function Category(props: { category: MenuCategoryWithProducts, }) { const category = props.category; - const [expanded, setExpanded] = useState(false); return ( - - - - - - - { - category.menu_products.map((item) => - <> - - { item.name } - - - { item.volume } CL - - - { item.price },- - - - ) - } - - - - - + + { category.name } + + + + { + category.menu_products.map((item) => ( + + )) + } + + ) } + +function Product( + props: { + product: MenuProduct + } +) { + + const product = props.product; + + return ( + <> + + { product.name } + + + { product.volume } CL + + + { product.price },- + + + ) + +} \ No newline at end of file From c5721289d47ddc3b7eacf66b58025d3657732670 Mon Sep 17 00:00:00 2001 From: Niklas Dietzel Date: Wed, 29 Oct 2025 11:36:48 +0100 Subject: [PATCH 22/52] add display for gluten-free products --- app/(pages)/(main)/escape/menu/page.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/app/(pages)/(main)/escape/menu/page.tsx b/app/(pages)/(main)/escape/menu/page.tsx index 824fe48..5b067b4 100644 --- a/app/(pages)/(main)/escape/menu/page.tsx +++ b/app/(pages)/(main)/escape/menu/page.tsx @@ -24,11 +24,10 @@ export default function EscapeMenu() { { menuCategories.map((item) => - + - ) } ) @@ -40,7 +39,7 @@ function Category(props: { const category = props.category; return ( - + { category.name } @@ -67,7 +66,11 @@ function Product( return ( <> - { product.name } + + { product.name } + + { product.glutenfree ? (Gluten-free) : <> } + { product.volume } CL From 9171dd8f514a56c54abb3c308a55239670a2db25 Mon Sep 17 00:00:00 2001 From: Niklas Dietzel Date: Wed, 29 Oct 2025 11:40:24 +0100 Subject: [PATCH 23/52] add responsivity to bar menu --- app/(pages)/(main)/escape/menu/page.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/(pages)/(main)/escape/menu/page.tsx b/app/(pages)/(main)/escape/menu/page.tsx index 5b067b4..e5300c6 100644 --- a/app/(pages)/(main)/escape/menu/page.tsx +++ b/app/(pages)/(main)/escape/menu/page.tsx @@ -42,7 +42,7 @@ function Category(props: { { category.name } - + { category.menu_products.map((item) => ( @@ -65,17 +65,17 @@ function Product( return ( <> - + { product.name } { product.glutenfree ? (Gluten-free) : <> } - + { product.volume } CL - + { product.price },- From b6e1866eddac68b1268e6f5e8355e7c5241f4c5d Mon Sep 17 00:00:00 2001 From: Niklas Dietzel Date: Thu, 30 Oct 2025 14:26:40 +0100 Subject: [PATCH 24/52] convert escape menu to be server-side --- app/(pages)/(main)/escape/menu/page.tsx | 29 +++++++++++-------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/app/(pages)/(main)/escape/menu/page.tsx b/app/(pages)/(main)/escape/menu/page.tsx index e5300c6..518f6ab 100644 --- a/app/(pages)/(main)/escape/menu/page.tsx +++ b/app/(pages)/(main)/escape/menu/page.tsx @@ -1,19 +1,16 @@ -"use client"; - import { Divider, Grid, Stack, Typography } from "@mui/material"; -import { useEffect, useState } from "react"; import { MenuCategoryWithProducts } from "@/app/api/v2/escape/menu/products/route"; import { MenuProduct } from "@prisma/client"; +import prisma from "@/prisma/prismaClient"; -export default function EscapeMenu() { - const [menuCategories, setMenuCategories] = useState([]); +export default async function EscapeMenu() { + const menu: MenuCategoryWithProducts[] = await prisma.menuCategory.findMany({ + include: { + menu_products: true + } + }); - useEffect(() => { - fetch("/api/v2/escape/menu") - .then(res => res.json()) - .then(categories => setMenuCategories(categories)) - }, []); return ( @@ -22,7 +19,7 @@ export default function EscapeMenu() { Menu - { menuCategories.map((item) => + { menu.map((item) => @@ -42,11 +39,11 @@ function Category(props: { { category.name } - + { category.menu_products.map((item) => ( - + )) } @@ -65,17 +62,17 @@ function Product( return ( <> - + { product.name } { product.glutenfree ? (Gluten-free) : <> } - + { product.volume } CL - + { product.price },- From c685e7bde69ad7b4a186935e8aed9715341cd1de Mon Sep 17 00:00:00 2001 From: Niklas Dietzel Date: Thu, 30 Oct 2025 14:36:30 +0100 Subject: [PATCH 25/52] redo menu grid column sizes --- app/(pages)/(main)/escape/menu/page.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/(pages)/(main)/escape/menu/page.tsx b/app/(pages)/(main)/escape/menu/page.tsx index 518f6ab..aa80c76 100644 --- a/app/(pages)/(main)/escape/menu/page.tsx +++ b/app/(pages)/(main)/escape/menu/page.tsx @@ -39,7 +39,7 @@ function Category(props: { { category.name } - + { category.menu_products.map((item) => ( @@ -62,17 +62,17 @@ function Product( return ( <> - + { product.name } { product.glutenfree ? (Gluten-free) : <> } - + { product.volume } CL - + { product.price },- From 7fbf534e500f01ee5c110ec6fa594d3b20554160 Mon Sep 17 00:00:00 2001 From: Niklas Dietzel Date: Thu, 30 Oct 2025 14:59:33 +0100 Subject: [PATCH 26/52] rename `MenuProduct.active` column to `hidden` --- prisma/schema.prisma | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c4da523..2307f8d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -382,7 +382,7 @@ model MenuProduct { price Int priceVolunteer Int glutenfree Boolean - active Boolean @default(true) + hidden Boolean @default(false) category_id Int category MenuCategory? @relation("MenuProduct_category", fields: [category_id], references: [id]) } From 6539ee04f6ef0fb18dea548b9fa5d0e531734ff9 Mon Sep 17 00:00:00 2001 From: Niklas Dietzel Date: Thu, 30 Oct 2025 15:02:10 +0100 Subject: [PATCH 27/52] add checkbox input to hide/un-hide products --- .../(main)/volunteering/menu/category.tsx | 4 +-- .../(main)/volunteering/menu/product.tsx | 29 ++++++++++++++++++- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/app/(pages)/(main)/volunteering/menu/category.tsx b/app/(pages)/(main)/volunteering/menu/category.tsx index f830e80..4a1ae10 100644 --- a/app/(pages)/(main)/volunteering/menu/category.tsx +++ b/app/(pages)/(main)/volunteering/menu/category.tsx @@ -92,7 +92,7 @@ export function Category(props: { category: MenuCategoryWithProducts, onUpdate: } - + Name @@ -103,7 +103,7 @@ export function Category(props: { category: MenuCategoryWithProducts, onUpdate: Volume (cL) - +
diff --git a/app/(pages)/(main)/volunteering/menu/product.tsx b/app/(pages)/(main)/volunteering/menu/product.tsx index f03043c..f3528cc 100644 --- a/app/(pages)/(main)/volunteering/menu/product.tsx +++ b/app/(pages)/(main)/volunteering/menu/product.tsx @@ -10,7 +10,8 @@ import { DialogTitle, FormControlLabel, Grid, - TextField + TextField, + Tooltip } from "@mui/material"; import { MenuProductCreate } from "@/app/api/v2/escape/menu/products/route"; import CircularProgress from "@mui/material/CircularProgress"; @@ -130,6 +131,7 @@ type ProductInputs = { price: number, volume: number, glutenfree: boolean, + hidden: boolean, }; type ProductInputsState = { @@ -256,6 +258,30 @@ function ProductInputs( label={ "Gluten-free" } />
+ + + + { + const newProduct = {...product, hidden: e.target.checked}; + props.onUpdate( + { + valid: isValid(newProduct), + product: newProduct + } + ); + } } + /> + } + label="Hidden" + /> + + + ) } @@ -267,6 +293,7 @@ export function NewProduct(props: { onUpdate: () => void, categoryId: number | n price: 0, volume: 0, glutenfree: false, + hidden: false, }, valid: { name: false, From 7690f19e1361ecf6764d98dc97c8fe1b4c82d183 Mon Sep 17 00:00:00 2001 From: Niklas Dietzel Date: Thu, 30 Oct 2025 15:02:22 +0100 Subject: [PATCH 28/52] only display non-hidden items on the menu --- app/(pages)/(main)/escape/menu/page.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/(pages)/(main)/escape/menu/page.tsx b/app/(pages)/(main)/escape/menu/page.tsx index aa80c76..f8a2093 100644 --- a/app/(pages)/(main)/escape/menu/page.tsx +++ b/app/(pages)/(main)/escape/menu/page.tsx @@ -7,7 +7,11 @@ import prisma from "@/prisma/prismaClient"; export default async function EscapeMenu() { const menu: MenuCategoryWithProducts[] = await prisma.menuCategory.findMany({ include: { - menu_products: true + menu_products: { + where: { + hidden: false + } + } } }); From 4b029d1e4d29739d31d69c8ff49accad2261a7d7 Mon Sep 17 00:00:00 2001 From: Niklas Dietzel Date: Thu, 30 Oct 2025 15:43:02 +0100 Subject: [PATCH 29/52] add some comments --- app/(pages)/(main)/escape/menu/page.tsx | 2 ++ .../(main)/volunteering/menu/category.tsx | 32 ++++++++++++++++--- app/(pages)/(main)/volunteering/menu/page.tsx | 2 +- .../(main)/volunteering/menu/product.tsx | 18 ++++++----- app/api/v2/escape/menu/categories/route.ts | 5 +++ .../escape/menu/products/[productId]/route.ts | 2 +- app/api/v2/escape/menu/products/route.ts | 5 +++ app/api/v2/escape/menu/route.ts | 1 + 8 files changed, 52 insertions(+), 15 deletions(-) diff --git a/app/(pages)/(main)/escape/menu/page.tsx b/app/(pages)/(main)/escape/menu/page.tsx index f8a2093..c4dff54 100644 --- a/app/(pages)/(main)/escape/menu/page.tsx +++ b/app/(pages)/(main)/escape/menu/page.tsx @@ -34,6 +34,7 @@ export default async function EscapeMenu() { ) } +// Individual menu category. function Category(props: { category: MenuCategoryWithProducts, }) { @@ -56,6 +57,7 @@ function Category(props: { } +// Individual menu product. function Product( props: { product: MenuProduct diff --git a/app/(pages)/(main)/volunteering/menu/category.tsx b/app/(pages)/(main)/volunteering/menu/category.tsx index 4a1ae10..31f37b3 100644 --- a/app/(pages)/(main)/volunteering/menu/category.tsx +++ b/app/(pages)/(main)/volunteering/menu/category.tsx @@ -1,3 +1,5 @@ +// This file contains react components for individual categories, and relevant utility functions. + import { useEffect, useState } from "react"; import { Button, Grid, Input, Stack, TextField, Typography } from "@mui/material"; import { MenuCategoryCreate, MenuCategoryWithProducts } from "@/app/api/v2/escape/menu/products/route"; @@ -7,6 +9,8 @@ import { NewProduct, Product } from "@/app/(pages)/(main)/volunteering/menu/prod import { styled } from "@mui/system"; import CircularProgress from "@mui/material/CircularProgress"; + +// Updates a given category. function updateCategory(category: MenuCategoryWithProducts, newAttributes: Partial): Promise { return fetch("/api/v2/escape/menu/categories", { @@ -14,10 +18,11 @@ function updateCategory(category: MenuCategoryWithProducts, newAttributes: Parti headers: { "Content-Type": "application/json", }, - body: JSON.stringify({...category, ...newAttributes, ...{menu_products: undefined}}) + body: JSON.stringify({...category, ...newAttributes, ...{menu_products: undefined}}) // set menu_products to undefined, because trying to PATCH the category with products makes Prisma angry }); } +// Creates a given category. function createCategory(category: MenuCategoryCreate): Promise { return fetch("/api/v2/escape/menu/categories", { method: "POST", @@ -28,28 +33,41 @@ function createCategory(category: MenuCategoryCreate): Promise { }); } +// a with larger text. Equivalent font-size to

const LargeTextField = styled(TextField)({ "& .MuiOutlinedInput-input": { fontSize: "3.75rem", } }) -export function Category(props: { category: MenuCategoryWithProducts, onUpdate: () => void }) { +export function Category( + props: { + category: MenuCategoryWithProducts, + onUpdate: () => void // called when the category has been updated in the database. + } +) { let [categoryName, setCategoryName] = useState(props.category.name); + // validate category name. Category name cannot be empty. + const categoryNameInvalid: boolean = categoryName.trim() === ""; + + // these are used to disable the UPDATE button when user has not inputted anything yet. let [hasBeenUpdated, setHasBeenUpdated] = useState(false); let [isFirst, setIsFirst] = useState(true); + + // is used for the spinner inside the UPDATE button let [isUpdating, setIsUpdating] = useState(false); - const categoryNameInvalid: boolean = categoryName.trim() === ""; useEffect(() => { + // useEffect is always run once at the start. if (isFirst) { setIsFirst(false); return; } + // this happens when categoryName has been updated for the first time. setHasBeenUpdated(true); }, [categoryName]); @@ -72,7 +90,7 @@ export function Category(props: { category: MenuCategoryWithProducts, onUpdate: helperText={ categoryNameInvalid ? "Name must not be empty" : "" } > - + > + { + isUpdating ? : <>Update // show spinner when updating is in progress + } + } diff --git a/app/(pages)/(main)/volunteering/menu/page.tsx b/app/(pages)/(main)/volunteering/menu/page.tsx index f5a620a..d8249a6 100644 --- a/app/(pages)/(main)/volunteering/menu/page.tsx +++ b/app/(pages)/(main)/volunteering/menu/page.tsx @@ -27,7 +27,7 @@ function MenuEditPage() { category={ item } key={ item.id } onUpdate={ - refetchMenu + refetchMenu // refetch the menu when somthing has been updated } > ); diff --git a/app/(pages)/(main)/volunteering/menu/product.tsx b/app/(pages)/(main)/volunteering/menu/product.tsx index f3528cc..ce52e5d 100644 --- a/app/(pages)/(main)/volunteering/menu/product.tsx +++ b/app/(pages)/(main)/volunteering/menu/product.tsx @@ -78,8 +78,8 @@ export function Product(props: { product: MenuProduct, onUpdate: () => void }) { return ( <> + { /* shared inputs */ } { - console.log(value) setValid(value.valid.allValid); setNewProduct(value); } }> @@ -144,6 +144,8 @@ type ProductInputsState = { }; } +// Due to the fact that NewProduct and Product share the same inputs, they are extracted here. +// This component *does not* keep track of any state. function ProductInputs( props: { @@ -175,9 +177,9 @@ function ProductInputs( { - const newProduct = {...product, name: e.target.value}; - props.onUpdate( + onChange={ e => { // all the inputs here are essentially the same. + const newProduct = {...product, name: e.target.value}; // create new product. + props.onUpdate( // propagate upwards. { valid: isValid(newProduct), product: newProduct @@ -354,12 +356,12 @@ export function NewProduct(props: { onUpdate: () => void, categoryId: number | n ); } - +// Dialog to confirm deletion of a product. function DeletionConfirmationDialog(props: { open: boolean, - onClose: () => void, - onDelete: () => void, - isDeleting: boolean + onClose: () => void, // called when "Cancel" is clicked, or the dialog is closed in any other way. + onDelete: () => void, // called when "Delete" is clicked. + isDeleting: boolean // when true, shows a spinner instead of "Delete" in the button. }) { return ( } diff --git a/app/api/v2/escape/menu/products/route.ts b/app/api/v2/escape/menu/products/route.ts index f1bbca8..c73f640 100644 --- a/app/api/v2/escape/menu/products/route.ts +++ b/app/api/v2/escape/menu/products/route.ts @@ -6,6 +6,8 @@ import { authOptions } from "@/app/api/utils/authOptions"; import { getServerSession } from "next-auth"; import { Auth } from "@/app/api/utils/auth"; + +// Modify a product. Modifies the product based on the id in the request body. export async function PATCH( req: NextRequest ) { @@ -30,6 +32,8 @@ export async function PATCH( return NextResponse.json(JSON.stringify(newProduct)); } + +// Create new product. export async function POST( req: NextRequest ) { @@ -52,5 +56,6 @@ export async function POST( const menuCategoryWithProducts = Prisma.validator()({include: {menu_products: true}}) export type MenuCategoryWithProducts = Prisma.MenuCategoryGetPayload +// Some utility types. export type MenuProductCreate = Prisma.MenuProductCreateArgs["data"] export type MenuCategoryCreate = Prisma.MenuCategoryCreateArgs["data"] \ No newline at end of file diff --git a/app/api/v2/escape/menu/route.ts b/app/api/v2/escape/menu/route.ts index f4a3cf2..262b084 100644 --- a/app/api/v2/escape/menu/route.ts +++ b/app/api/v2/escape/menu/route.ts @@ -1,6 +1,7 @@ import prisma from "@/prisma/prismaClient"; import { NextResponse } from "next/server"; +// Gets the whole menu export async function GET(): Promise { let menuCategories = await prisma.menuCategory.findMany( { From 46e9d787c2f5a4624ab0f7fb09cee5c0a4930646 Mon Sep 17 00:00:00 2001 From: Niklas Dietzel Date: Fri, 31 Oct 2025 11:18:41 +0100 Subject: [PATCH 30/52] add basic API validation --- app/api/v2/escape/menu/categories/route.ts | 15 +++++++++------ app/api/v2/escape/menu/products/route.ts | 14 +++++++------- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/app/api/v2/escape/menu/categories/route.ts b/app/api/v2/escape/menu/categories/route.ts index dc096aa..eeabbda 100644 --- a/app/api/v2/escape/menu/categories/route.ts +++ b/app/api/v2/escape/menu/categories/route.ts @@ -12,14 +12,15 @@ import { authOptions } from "@/app/api/utils/authOptions"; export async function PATCH( req: NextRequest ) { + const category: MenuCategory = await req.json(); const session = await getServerSession(authOptions); - const authCheck: Auth = new Auth(session) - .requireRoles([]); + const authCheck: Auth = new Auth(session, category) + .requireRoles([]) + .requireParams(["id"]); // only id is strictly required if (authCheck.failed) return authCheck.response; - const category: MenuCategory = await req.json(); const newProduct = await prismaClient.menuCategory.update({ where: { @@ -37,13 +38,15 @@ export async function PATCH( export async function POST( req: NextRequest ) { + const category: MenuCategoryCreate = await req.json(); + const session = await getServerSession(authOptions); - const authCheck = new Auth(session) - .requireRoles([]); + const authCheck = new Auth(session, category) + .requireRoles([]) + .requireParams(["name"]); if (authCheck.failed) return authCheck.response; - const category: MenuCategoryCreate = await req.json(); const newCategory = await prismaClient.menuCategory.create({ data: category diff --git a/app/api/v2/escape/menu/products/route.ts b/app/api/v2/escape/menu/products/route.ts index c73f640..385a80c 100644 --- a/app/api/v2/escape/menu/products/route.ts +++ b/app/api/v2/escape/menu/products/route.ts @@ -11,15 +11,16 @@ import { Auth } from "@/app/api/utils/auth"; export async function PATCH( req: NextRequest ) { + const product: MenuProduct = await req.json(); + const session = await getServerSession(authOptions); - const authCheck = new Auth(session) - .requireRoles([]); + const authCheck = new Auth(session, product) + .requireRoles([]) + .requireParams(["id"]); if (authCheck.failed) return authCheck.response; - const product: MenuProduct = await req.json(); - const newProduct = await prismaClient.menuProduct.update({ where: { id: product.id @@ -27,8 +28,6 @@ export async function PATCH( data: product }); - - return NextResponse.json(JSON.stringify(newProduct)); } @@ -39,7 +38,8 @@ export async function POST( ) { const session = await getServerSession(authOptions); const authCheck = new Auth(session) - .requireRoles([]); + .requireRoles([]) + .requireParams(["name", "hidden", "price", "volume", "glutenfree", "category_id", "priceVolunteer"]); if (authCheck.failed) return authCheck.response; From 93ab40f6df6f7d3127fbf2cfed228be6549af72d Mon Sep 17 00:00:00 2001 From: Niklas Dietzel Date: Fri, 31 Oct 2025 11:20:31 +0100 Subject: [PATCH 31/52] default setting `priceVolunteer` to `price` of the product due to unimplemented frontend elements --- app/(pages)/(main)/volunteering/menu/product.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/(pages)/(main)/volunteering/menu/product.tsx b/app/(pages)/(main)/volunteering/menu/product.tsx index ce52e5d..a2042df 100644 --- a/app/(pages)/(main)/volunteering/menu/product.tsx +++ b/app/(pages)/(main)/volunteering/menu/product.tsx @@ -336,7 +336,7 @@ export function NewProduct(props: { onUpdate: () => void, categoryId: number | n setIsCreating(true); createProduct({ ...newProduct.product, - priceVolunteer: 0, + priceVolunteer: newProduct.product.price, // default to price because there is no input for volunteer price yet category_id: props.categoryId }).then(() => { setNewProduct(initialState); From 5b801d3c57903255bcdf689e3dfdceb2eabc88c4 Mon Sep 17 00:00:00 2001 From: Niklas Dietzel Date: Fri, 31 Oct 2025 11:23:55 +0100 Subject: [PATCH 32/52] fix API validation breaking POST /escape/products endpoint --- app/api/v2/escape/menu/products/route.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/api/v2/escape/menu/products/route.ts b/app/api/v2/escape/menu/products/route.ts index 385a80c..609b9e6 100644 --- a/app/api/v2/escape/menu/products/route.ts +++ b/app/api/v2/escape/menu/products/route.ts @@ -36,15 +36,16 @@ export async function PATCH( export async function POST( req: NextRequest ) { + const product: MenuProductCreate = await req.json(); + const session = await getServerSession(authOptions); - const authCheck = new Auth(session) + const authCheck = new Auth(session, product) .requireRoles([]) .requireParams(["name", "hidden", "price", "volume", "glutenfree", "category_id", "priceVolunteer"]); if (authCheck.failed) return authCheck.response; - const product: MenuProductCreate = await req.json(); await prisma.menuProduct.create({ data: product From 8b9b8b3b38c9ed2beb7ecb79305c95001ab6fc8e Mon Sep 17 00:00:00 2001 From: Niklas Dietzel Date: Fri, 31 Oct 2025 11:58:18 +0100 Subject: [PATCH 33/52] align buttons better --- .../(main)/volunteering/menu/category.tsx | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/app/(pages)/(main)/volunteering/menu/category.tsx b/app/(pages)/(main)/volunteering/menu/category.tsx index 31f37b3..b6060bd 100644 --- a/app/(pages)/(main)/volunteering/menu/category.tsx +++ b/app/(pages)/(main)/volunteering/menu/category.tsx @@ -33,6 +33,12 @@ function createCategory(category: MenuCategoryCreate): Promise { }); } +function deleteCategory(categoryId: number): Promise { + return fetch(`/api/v2/escape/menu/categories/${ categoryId }`, { + method: "DELETE", + }); +} + // a with larger text. Equivalent font-size to

const LargeTextField = styled(TextField)({ "& .MuiOutlinedInput-input": { @@ -73,9 +79,9 @@ export function Category( return ( - { + - + + + + - - } + + + From 1be22db0b82cf3bcdeae2f255f70ef15e773943a Mon Sep 17 00:00:00 2001 From: Niklas Dietzel Date: Fri, 31 Oct 2025 12:20:02 +0100 Subject: [PATCH 34/52] extract DeletionConfirmationDialog into a generic component --- .../(main)/volunteering/menu/product.tsx | 76 +++++-------------- .../input/DeletionConfirmationDialog.tsx | 43 +++++++++++ 2 files changed, 62 insertions(+), 57 deletions(-) create mode 100644 app/components/input/DeletionConfirmationDialog.tsx diff --git a/app/(pages)/(main)/volunteering/menu/product.tsx b/app/(pages)/(main)/volunteering/menu/product.tsx index a2042df..f9c6faf 100644 --- a/app/(pages)/(main)/volunteering/menu/product.tsx +++ b/app/(pages)/(main)/volunteering/menu/product.tsx @@ -1,20 +1,9 @@ import { MenuProduct } from "@prisma/client"; import { useEffect, useState } from "react"; -import { - Button, - Checkbox, - Dialog, - DialogActions, - DialogContent, - DialogContentText, - DialogTitle, - FormControlLabel, - Grid, - TextField, - Tooltip -} from "@mui/material"; +import { Button, Checkbox, FormControlLabel, Grid, TextField, Tooltip } from "@mui/material"; import { MenuProductCreate } from "@/app/api/v2/escape/menu/products/route"; import CircularProgress from "@mui/material/CircularProgress"; +import { DeletionConfirmationDialog } from "@/app/components/input/DeletionConfirmationDialog"; function updateProduct(product: MenuProduct, newAttributes: Partial): Promise { return fetch("/api/v2/escape/menu/products", { @@ -109,18 +98,23 @@ export function Product(props: { product: MenuProduct, onUpdate: () => void }) { Delete - setDeleteDialogOpen(false) } - onDelete={ () => { - setIsDeleting(true); - deleteProduct(props.product.id).then(() => { - setIsDeleting(false); - setDeleteDialogOpen(false); - - props.onUpdate(); - }); - } } - isDeleting={ isDeleting } - /> + setDeleteDialogOpen(false) } + onDelete={ () => { + setIsDeleting(true); + deleteProduct(props.product.id).then(() => { + setIsDeleting(false); + setDeleteDialogOpen(false); + + props.onUpdate(); + }); + } } + showSpinner={ isDeleting } + title={ "Delete product?" } + > + Are you sure you want to delete this product? + ) @@ -354,36 +348,4 @@ export function NewProduct(props: { onUpdate: () => void, categoryId: number | n ); -} - -// Dialog to confirm deletion of a product. -function DeletionConfirmationDialog(props: { - open: boolean, - onClose: () => void, // called when "Cancel" is clicked, or the dialog is closed in any other way. - onDelete: () => void, // called when "Delete" is clicked. - isDeleting: boolean // when true, shows a spinner instead of "Delete" in the button. -}) { - return ( - - - Delete product? - - - - Are you sure you want to delete the product? - - - - - - - - ); } \ No newline at end of file diff --git a/app/components/input/DeletionConfirmationDialog.tsx b/app/components/input/DeletionConfirmationDialog.tsx new file mode 100644 index 0000000..252aafa --- /dev/null +++ b/app/components/input/DeletionConfirmationDialog.tsx @@ -0,0 +1,43 @@ +import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from "@mui/material"; +import CircularProgress from "@mui/material/CircularProgress"; +import { ReactNode } from "react"; + +export type DeletionConfirmationDialogProps = { + open: boolean, + onClose: () => void; // called when "Cancel" is clicked, or the dialog is closed in any other way. + onDelete: () => void; // called when "Delete" is clicked. + showSpinner: boolean; // when true, shows a spinner instead of "Delete" in the button. + + title: ReactNode; // Title of the dialog + children: ReactNode; // content +} + +export function DeletionConfirmationDialog( + props: DeletionConfirmationDialogProps +) { + console.log(props) + + return ( + + + { props.title } + + + + { props.children } + + + + + + + + ) +} \ No newline at end of file From e6dad7b91012368845ae2a976c20fd1cd25b794d Mon Sep 17 00:00:00 2001 From: Niklas Dietzel Date: Fri, 31 Oct 2025 12:20:15 +0100 Subject: [PATCH 35/52] add ability to delete categories --- .../(main)/volunteering/menu/category.tsx | 37 ++++++++++++++-- .../menu/categories/[categoryId]/route.ts | 44 +++++++++++++++++++ 2 files changed, 77 insertions(+), 4 deletions(-) create mode 100644 app/api/v2/escape/menu/categories/[categoryId]/route.ts diff --git a/app/(pages)/(main)/volunteering/menu/category.tsx b/app/(pages)/(main)/volunteering/menu/category.tsx index b6060bd..a860326 100644 --- a/app/(pages)/(main)/volunteering/menu/category.tsx +++ b/app/(pages)/(main)/volunteering/menu/category.tsx @@ -8,6 +8,7 @@ import { MenuCategory } from "@prisma/client"; import { NewProduct, Product } from "@/app/(pages)/(main)/volunteering/menu/product"; import { styled } from "@mui/system"; import CircularProgress from "@mui/material/CircularProgress"; +import { DeletionConfirmationDialog } from "@/app/components/input/DeletionConfirmationDialog"; // Updates a given category. @@ -66,6 +67,10 @@ export function Category( let [isUpdating, setIsUpdating] = useState(false); + let [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + let [isDeleting, setIsDeleting] = useState(false); + + useEffect(() => { // useEffect is always run once at the start. if (isFirst) { @@ -81,7 +86,7 @@ export function Category( - + + + + + @@ -147,6 +163,21 @@ export function Category( + + setDeleteDialogOpen(false) } + onDelete={ () => { + setIsDeleting(true); + deleteCategory(props.category.id).then(() => { + props.onUpdate(); + }); + } } + showSpinner={ isDeleting } + title="Delete category?" + > + Are you sure you want to delete this category? + ) } @@ -187,9 +218,7 @@ export function NewCategory(props: { onUpdate: () => void }) { setIsCreating(false); props.onUpdate(); }); - } - - } + } } >{ isCreating ? : <>Create } diff --git a/app/api/v2/escape/menu/categories/[categoryId]/route.ts b/app/api/v2/escape/menu/categories/[categoryId]/route.ts new file mode 100644 index 0000000..1c41e32 --- /dev/null +++ b/app/api/v2/escape/menu/categories/[categoryId]/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { Auth } from "@/app/api/utils/auth"; +import prisma from "@/prisma/prismaClient"; +import { authOptions } from "@/app/api/utils/authOptions"; + +export async function DELETE( + _req: NextRequest, + context: { params: Promise<{ categoryId: string }> } +) { + const params = await context.params; + + let session = await getServerSession(authOptions); + let auth = new Auth(session, params) + .requireRoles([]) + .requireParams(["categoryId"]); + + if (auth.failed) return auth.response; + + let categoryId = Number(params.categoryId); + // verify productId is an integer + if (isNaN(categoryId) || !categoryId) { + return NextResponse.json({error: "categoryId must be an integer"}, {status: 400}); + } + + + await prisma.$transaction(async () => { + await prisma.menuProduct.deleteMany({ + where: { + category_id: categoryId + } + }); + + await prisma.menuCategory.delete( + { + where: { + id: categoryId, + }, + } + ); + }); + + return NextResponse.json({}) +} \ No newline at end of file From 5544bf8a6df04b41ed5fac071be7b23f68408482 Mon Sep 17 00:00:00 2001 From: Niklas Dietzel Date: Thu, 6 Nov 2025 16:59:41 +0100 Subject: [PATCH 36/52] wrap all relevant API returns with `auth.verify()` --- app/api/v2/escape/menu/categories/[categoryId]/route.ts | 2 +- app/api/v2/escape/menu/categories/route.ts | 4 ++-- app/api/v2/escape/menu/products/[productId]/route.ts | 2 +- app/api/v2/escape/menu/products/route.ts | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/api/v2/escape/menu/categories/[categoryId]/route.ts b/app/api/v2/escape/menu/categories/[categoryId]/route.ts index 1c41e32..1ec8961 100644 --- a/app/api/v2/escape/menu/categories/[categoryId]/route.ts +++ b/app/api/v2/escape/menu/categories/[categoryId]/route.ts @@ -40,5 +40,5 @@ export async function DELETE( ); }); - return NextResponse.json({}) + return auth.verify(NextResponse.json({})) } \ No newline at end of file diff --git a/app/api/v2/escape/menu/categories/route.ts b/app/api/v2/escape/menu/categories/route.ts index eeabbda..0036145 100644 --- a/app/api/v2/escape/menu/categories/route.ts +++ b/app/api/v2/escape/menu/categories/route.ts @@ -30,7 +30,7 @@ export async function PATCH( }) - return NextResponse.json(JSON.stringify(newProduct)); + return authCheck.verify(NextResponse.json(JSON.stringify(newProduct))); } @@ -52,5 +52,5 @@ export async function POST( data: category }); - return NextResponse.json(JSON.stringify(newCategory)); + return authCheck.verify(NextResponse.json(JSON.stringify(newCategory))); } \ No newline at end of file diff --git a/app/api/v2/escape/menu/products/[productId]/route.ts b/app/api/v2/escape/menu/products/[productId]/route.ts index f241507..74916b5 100644 --- a/app/api/v2/escape/menu/products/[productId]/route.ts +++ b/app/api/v2/escape/menu/products/[productId]/route.ts @@ -41,5 +41,5 @@ export async function DELETE( } } - return NextResponse.json(JSON.stringify({})); + return authCheck.verify(NextResponse.json(JSON.stringify({}))); } \ No newline at end of file diff --git a/app/api/v2/escape/menu/products/route.ts b/app/api/v2/escape/menu/products/route.ts index 609b9e6..b7e6409 100644 --- a/app/api/v2/escape/menu/products/route.ts +++ b/app/api/v2/escape/menu/products/route.ts @@ -28,7 +28,7 @@ export async function PATCH( data: product }); - return NextResponse.json(JSON.stringify(newProduct)); + return authCheck.verify(NextResponse.json(JSON.stringify(newProduct))); } @@ -51,7 +51,7 @@ export async function POST( data: product }); - return NextResponse.json(JSON.stringify({})); + return authCheck.verify(NextResponse.json(JSON.stringify({}))); } const menuCategoryWithProducts = Prisma.validator()({include: {menu_products: true}}) From c71adee8864773283e7c7c1526041904baa608d8 Mon Sep 17 00:00:00 2001 From: Niklas Dietzel Date: Thu, 6 Nov 2025 17:00:28 +0100 Subject: [PATCH 37/52] rename all `authCheck`s to `auth` for consistency --- app/api/v2/escape/menu/categories/route.ts | 12 ++++++------ app/api/v2/escape/menu/products/[productId]/route.ts | 6 +++--- app/api/v2/escape/menu/products/route.ts | 12 ++++++------ 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/app/api/v2/escape/menu/categories/route.ts b/app/api/v2/escape/menu/categories/route.ts index 0036145..531cb34 100644 --- a/app/api/v2/escape/menu/categories/route.ts +++ b/app/api/v2/escape/menu/categories/route.ts @@ -15,11 +15,11 @@ export async function PATCH( const category: MenuCategory = await req.json(); const session = await getServerSession(authOptions); - const authCheck: Auth = new Auth(session, category) + const auth: Auth = new Auth(session, category) .requireRoles([]) .requireParams(["id"]); // only id is strictly required - if (authCheck.failed) return authCheck.response; + if (auth.failed) return auth.response; const newProduct = await prismaClient.menuCategory.update({ @@ -30,7 +30,7 @@ export async function PATCH( }) - return authCheck.verify(NextResponse.json(JSON.stringify(newProduct))); + return auth.verify(NextResponse.json(JSON.stringify(newProduct))); } @@ -41,16 +41,16 @@ export async function POST( const category: MenuCategoryCreate = await req.json(); const session = await getServerSession(authOptions); - const authCheck = new Auth(session, category) + const auth = new Auth(session, category) .requireRoles([]) .requireParams(["name"]); - if (authCheck.failed) return authCheck.response; + if (auth.failed) return auth.response; const newCategory = await prismaClient.menuCategory.create({ data: category }); - return authCheck.verify(NextResponse.json(JSON.stringify(newCategory))); + return auth.verify(NextResponse.json(JSON.stringify(newCategory))); } \ No newline at end of file diff --git a/app/api/v2/escape/menu/products/[productId]/route.ts b/app/api/v2/escape/menu/products/[productId]/route.ts index 74916b5..c0bf19f 100644 --- a/app/api/v2/escape/menu/products/[productId]/route.ts +++ b/app/api/v2/escape/menu/products/[productId]/route.ts @@ -14,13 +14,13 @@ export async function DELETE( let params = await context.params; const session = await getServerSession(authOptions); - const authCheck = new Auth(session, params) + const auth = new Auth(session, params) .requireRoles([]) .requireParams(["productId"]); const productId = Number(params.productId); - if (authCheck.failed) return authCheck.response; + if (auth.failed) return auth.response; // verify productId is an integer if (isNaN(productId) || !productId) { @@ -41,5 +41,5 @@ export async function DELETE( } } - return authCheck.verify(NextResponse.json(JSON.stringify({}))); + return auth.verify(NextResponse.json(JSON.stringify({}))); } \ No newline at end of file diff --git a/app/api/v2/escape/menu/products/route.ts b/app/api/v2/escape/menu/products/route.ts index b7e6409..6142e0f 100644 --- a/app/api/v2/escape/menu/products/route.ts +++ b/app/api/v2/escape/menu/products/route.ts @@ -14,11 +14,11 @@ export async function PATCH( const product: MenuProduct = await req.json(); const session = await getServerSession(authOptions); - const authCheck = new Auth(session, product) + const auth = new Auth(session, product) .requireRoles([]) .requireParams(["id"]); - if (authCheck.failed) return authCheck.response; + if (auth.failed) return auth.response; const newProduct = await prismaClient.menuProduct.update({ @@ -28,7 +28,7 @@ export async function PATCH( data: product }); - return authCheck.verify(NextResponse.json(JSON.stringify(newProduct))); + return auth.verify(NextResponse.json(JSON.stringify(newProduct))); } @@ -39,11 +39,11 @@ export async function POST( const product: MenuProductCreate = await req.json(); const session = await getServerSession(authOptions); - const authCheck = new Auth(session, product) + const auth = new Auth(session, product) .requireRoles([]) .requireParams(["name", "hidden", "price", "volume", "glutenfree", "category_id", "priceVolunteer"]); - if (authCheck.failed) return authCheck.response; + if (auth.failed) return auth.response; @@ -51,7 +51,7 @@ export async function POST( data: product }); - return authCheck.verify(NextResponse.json(JSON.stringify({}))); + return auth.verify(NextResponse.json(JSON.stringify({}))); } const menuCategoryWithProducts = Prisma.validator()({include: {menu_products: true}}) From 0887c8e21de7e9ed46969a1b96364cbaeba4dbc0 Mon Sep 17 00:00:00 2001 From: Niklas Dietzel Date: Thu, 6 Nov 2025 17:04:01 +0100 Subject: [PATCH 38/52] return HTTP `201 Created` (instead of just `200 Ok`) for relevant API endpoints because it's more correct --- app/api/v2/escape/menu/categories/route.ts | 3 ++- app/api/v2/escape/menu/products/route.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/api/v2/escape/menu/categories/route.ts b/app/api/v2/escape/menu/categories/route.ts index 531cb34..7b297e5 100644 --- a/app/api/v2/escape/menu/categories/route.ts +++ b/app/api/v2/escape/menu/categories/route.ts @@ -52,5 +52,6 @@ export async function POST( data: category }); - return auth.verify(NextResponse.json(JSON.stringify(newCategory))); + // 201 Created + return auth.verify(NextResponse.json(JSON.stringify(newCategory), {status: 201})); } \ No newline at end of file diff --git a/app/api/v2/escape/menu/products/route.ts b/app/api/v2/escape/menu/products/route.ts index 6142e0f..817256a 100644 --- a/app/api/v2/escape/menu/products/route.ts +++ b/app/api/v2/escape/menu/products/route.ts @@ -51,7 +51,8 @@ export async function POST( data: product }); - return auth.verify(NextResponse.json(JSON.stringify({}))); + // 201 Created + return auth.verify(NextResponse.json(JSON.stringify({}), {status: 201})); } const menuCategoryWithProducts = Prisma.validator()({include: {menu_products: true}}) From ec7d5a10e245d2f26be0021aef24194d4c79c0f0 Mon Sep 17 00:00:00 2001 From: Niklas Dietzel Date: Thu, 6 Nov 2025 17:08:11 +0100 Subject: [PATCH 39/52] wrap missing API return with `auth.verify()` --- app/api/v2/escape/menu/categories/[categoryId]/route.ts | 2 +- app/api/v2/escape/menu/route.ts | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/app/api/v2/escape/menu/categories/[categoryId]/route.ts b/app/api/v2/escape/menu/categories/[categoryId]/route.ts index 1ec8961..1dc3e88 100644 --- a/app/api/v2/escape/menu/categories/[categoryId]/route.ts +++ b/app/api/v2/escape/menu/categories/[categoryId]/route.ts @@ -20,7 +20,7 @@ export async function DELETE( let categoryId = Number(params.categoryId); // verify productId is an integer if (isNaN(categoryId) || !categoryId) { - return NextResponse.json({error: "categoryId must be an integer"}, {status: 400}); + return auth.verify(NextResponse.json({error: "categoryId must be an integer"}, {status: 400})); } diff --git a/app/api/v2/escape/menu/route.ts b/app/api/v2/escape/menu/route.ts index 262b084..20619cd 100644 --- a/app/api/v2/escape/menu/route.ts +++ b/app/api/v2/escape/menu/route.ts @@ -1,8 +1,13 @@ import prisma from "@/prisma/prismaClient"; import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { Auth } from "@/app/api/utils/auth"; // Gets the whole menu export async function GET(): Promise { + const session = getServerSession(); + const auth = new Auth(session); + let menuCategories = await prisma.menuCategory.findMany( { include: { @@ -11,5 +16,5 @@ export async function GET(): Promise { } ); - return NextResponse.json(menuCategories); + return auth.verify(NextResponse.json(menuCategories)); } \ No newline at end of file From 5586298daab914b57f79154fc95c83290b029460 Mon Sep 17 00:00:00 2001 From: Niklas Dietzel Date: Thu, 6 Nov 2025 17:21:24 +0100 Subject: [PATCH 40/52] add server-side check to only permit users with the `admin` role to change the menu --- app/api/v2/escape/menu/categories/[categoryId]/route.ts | 2 +- app/api/v2/escape/menu/categories/route.ts | 4 ++-- app/api/v2/escape/menu/products/[productId]/route.ts | 2 +- app/api/v2/escape/menu/products/route.ts | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/api/v2/escape/menu/categories/[categoryId]/route.ts b/app/api/v2/escape/menu/categories/[categoryId]/route.ts index 1dc3e88..c51dc74 100644 --- a/app/api/v2/escape/menu/categories/[categoryId]/route.ts +++ b/app/api/v2/escape/menu/categories/[categoryId]/route.ts @@ -12,7 +12,7 @@ export async function DELETE( let session = await getServerSession(authOptions); let auth = new Auth(session, params) - .requireRoles([]) + .requireRoles(["admin"]) .requireParams(["categoryId"]); if (auth.failed) return auth.response; diff --git a/app/api/v2/escape/menu/categories/route.ts b/app/api/v2/escape/menu/categories/route.ts index 7b297e5..28ac3ca 100644 --- a/app/api/v2/escape/menu/categories/route.ts +++ b/app/api/v2/escape/menu/categories/route.ts @@ -16,7 +16,7 @@ export async function PATCH( const session = await getServerSession(authOptions); const auth: Auth = new Auth(session, category) - .requireRoles([]) + .requireRoles(["admin"]) .requireParams(["id"]); // only id is strictly required if (auth.failed) return auth.response; @@ -42,7 +42,7 @@ export async function POST( const session = await getServerSession(authOptions); const auth = new Auth(session, category) - .requireRoles([]) + .requireRoles(["admin"]) .requireParams(["name"]); if (auth.failed) return auth.response; diff --git a/app/api/v2/escape/menu/products/[productId]/route.ts b/app/api/v2/escape/menu/products/[productId]/route.ts index c0bf19f..252856c 100644 --- a/app/api/v2/escape/menu/products/[productId]/route.ts +++ b/app/api/v2/escape/menu/products/[productId]/route.ts @@ -15,7 +15,7 @@ export async function DELETE( const session = await getServerSession(authOptions); const auth = new Auth(session, params) - .requireRoles([]) + .requireRoles(["admin"]) .requireParams(["productId"]); const productId = Number(params.productId); diff --git a/app/api/v2/escape/menu/products/route.ts b/app/api/v2/escape/menu/products/route.ts index 817256a..9d29e20 100644 --- a/app/api/v2/escape/menu/products/route.ts +++ b/app/api/v2/escape/menu/products/route.ts @@ -15,7 +15,7 @@ export async function PATCH( const session = await getServerSession(authOptions); const auth = new Auth(session, product) - .requireRoles([]) + .requireRoles(["admin"]) .requireParams(["id"]); if (auth.failed) return auth.response; @@ -40,7 +40,7 @@ export async function POST( const session = await getServerSession(authOptions); const auth = new Auth(session, product) - .requireRoles([]) + .requireRoles(["admin"]) .requireParams(["name", "hidden", "price", "volume", "glutenfree", "category_id", "priceVolunteer"]); if (auth.failed) return auth.response; From 58c62531ec2f3f0dbae2e0f6f257701fbb062893 Mon Sep 17 00:00:00 2001 From: Niklas Dietzel Date: Thu, 6 Nov 2025 17:39:02 +0100 Subject: [PATCH 41/52] extract API utility types into their own files/directory --- app/(pages)/(main)/escape/menu/page.tsx | 2 +- app/(pages)/(main)/volunteering/menu/category.tsx | 2 +- app/(pages)/(main)/volunteering/menu/page.tsx | 2 +- app/(pages)/(main)/volunteering/menu/product.tsx | 2 +- app/api/utils/types/MenuCategoryTypes.ts | 9 +++++++++ app/api/utils/types/MenuProductTypes.ts | 3 +++ app/api/v2/escape/menu/categories/route.ts | 2 +- app/api/v2/escape/menu/products/route.ts | 9 ++------- 8 files changed, 19 insertions(+), 12 deletions(-) create mode 100644 app/api/utils/types/MenuCategoryTypes.ts create mode 100644 app/api/utils/types/MenuProductTypes.ts diff --git a/app/(pages)/(main)/escape/menu/page.tsx b/app/(pages)/(main)/escape/menu/page.tsx index c4dff54..da1378a 100644 --- a/app/(pages)/(main)/escape/menu/page.tsx +++ b/app/(pages)/(main)/escape/menu/page.tsx @@ -1,7 +1,7 @@ import { Divider, Grid, Stack, Typography } from "@mui/material"; -import { MenuCategoryWithProducts } from "@/app/api/v2/escape/menu/products/route"; import { MenuProduct } from "@prisma/client"; import prisma from "@/prisma/prismaClient"; +import { MenuCategoryWithProducts } from "@/app/api/utils/types/MenuCategoryTypes"; export default async function EscapeMenu() { diff --git a/app/(pages)/(main)/volunteering/menu/category.tsx b/app/(pages)/(main)/volunteering/menu/category.tsx index a860326..3296e45 100644 --- a/app/(pages)/(main)/volunteering/menu/category.tsx +++ b/app/(pages)/(main)/volunteering/menu/category.tsx @@ -2,13 +2,13 @@ import { useEffect, useState } from "react"; import { Button, Grid, Input, Stack, TextField, Typography } from "@mui/material"; -import { MenuCategoryCreate, MenuCategoryWithProducts } from "@/app/api/v2/escape/menu/products/route"; import { MenuCategory } from "@prisma/client"; import { NewProduct, Product } from "@/app/(pages)/(main)/volunteering/menu/product"; import { styled } from "@mui/system"; import CircularProgress from "@mui/material/CircularProgress"; import { DeletionConfirmationDialog } from "@/app/components/input/DeletionConfirmationDialog"; +import { MenuCategoryCreate, MenuCategoryWithProducts } from "@/app/api/utils/types/MenuCategoryTypes"; // Updates a given category. diff --git a/app/(pages)/(main)/volunteering/menu/page.tsx b/app/(pages)/(main)/volunteering/menu/page.tsx index d8249a6..e9131e0 100644 --- a/app/(pages)/(main)/volunteering/menu/page.tsx +++ b/app/(pages)/(main)/volunteering/menu/page.tsx @@ -2,9 +2,9 @@ import { Stack } from "@mui/material"; import { useEffect, useState } from "react"; -import { MenuCategoryWithProducts } from "@/app/api/v2/escape/menu/products/route"; import authWrapper from "@/app/middleware/authWrapper"; import { Category, NewCategory } from "@/app/(pages)/(main)/volunteering/menu/category"; +import { MenuCategoryWithProducts } from "@/app/api/utils/types/MenuCategoryTypes"; // require login export default authWrapper(MenuEditPage); diff --git a/app/(pages)/(main)/volunteering/menu/product.tsx b/app/(pages)/(main)/volunteering/menu/product.tsx index f9c6faf..5585454 100644 --- a/app/(pages)/(main)/volunteering/menu/product.tsx +++ b/app/(pages)/(main)/volunteering/menu/product.tsx @@ -1,9 +1,9 @@ import { MenuProduct } from "@prisma/client"; import { useEffect, useState } from "react"; import { Button, Checkbox, FormControlLabel, Grid, TextField, Tooltip } from "@mui/material"; -import { MenuProductCreate } from "@/app/api/v2/escape/menu/products/route"; import CircularProgress from "@mui/material/CircularProgress"; import { DeletionConfirmationDialog } from "@/app/components/input/DeletionConfirmationDialog"; +import { MenuProductCreate } from "@/app/api/utils/types/MenuProductTypes"; function updateProduct(product: MenuProduct, newAttributes: Partial): Promise { return fetch("/api/v2/escape/menu/products", { diff --git a/app/api/utils/types/MenuCategoryTypes.ts b/app/api/utils/types/MenuCategoryTypes.ts new file mode 100644 index 0000000..6a2b96b --- /dev/null +++ b/app/api/utils/types/MenuCategoryTypes.ts @@ -0,0 +1,9 @@ +import { Prisma } from "@prisma/client"; +import MenuCategoryDefaultArgs = Prisma.MenuCategoryDefaultArgs; + +// see https://www.prisma.io/docs/orm/prisma-client/type-safety/operating-against-partial-structures-of-model-types +// for an explanation over these two lines. Note: this code is using `satisfies` instead of `Prisma.validator` (also see https://www.prisma.io/blog/satisfies-operator-ur8ys8ccq7zb). +const menuCategoryWithProductsArgs = {include: {menu_products: true}} satisfies MenuCategoryDefaultArgs; +export type MenuCategoryWithProducts = Prisma.MenuCategoryGetPayload + +export type MenuCategoryCreate = Prisma.MenuCategoryCreateArgs["data"] diff --git a/app/api/utils/types/MenuProductTypes.ts b/app/api/utils/types/MenuProductTypes.ts new file mode 100644 index 0000000..f1451b8 --- /dev/null +++ b/app/api/utils/types/MenuProductTypes.ts @@ -0,0 +1,3 @@ +import { Prisma } from "@prisma/client"; + +export type MenuProductCreate = Prisma.MenuProductCreateArgs["data"] \ No newline at end of file diff --git a/app/api/v2/escape/menu/categories/route.ts b/app/api/v2/escape/menu/categories/route.ts index 28ac3ca..b43ab14 100644 --- a/app/api/v2/escape/menu/categories/route.ts +++ b/app/api/v2/escape/menu/categories/route.ts @@ -1,10 +1,10 @@ import { NextRequest, NextResponse } from "next/server"; import { MenuCategory } from "@prisma/client"; import prismaClient from "@/prisma/prismaClient"; -import { MenuCategoryCreate } from "@/app/api/v2/escape/menu/products/route"; import { getServerSession } from "next-auth"; import { Auth } from "@/app/api/utils/auth"; import { authOptions } from "@/app/api/utils/authOptions"; +import { MenuCategoryCreate } from "@/app/api/utils/types/MenuCategoryTypes"; diff --git a/app/api/v2/escape/menu/products/route.ts b/app/api/v2/escape/menu/products/route.ts index 9d29e20..9fcadcd 100644 --- a/app/api/v2/escape/menu/products/route.ts +++ b/app/api/v2/escape/menu/products/route.ts @@ -1,10 +1,11 @@ import { NextRequest, NextResponse } from "next/server"; import prisma from "@/prisma/prismaClient"; import prismaClient from "@/prisma/prismaClient"; -import { MenuProduct, Prisma } from "@prisma/client"; +import { MenuProduct } from "@prisma/client"; import { authOptions } from "@/app/api/utils/authOptions"; import { getServerSession } from "next-auth"; import { Auth } from "@/app/api/utils/auth"; +import { MenuProductCreate } from "@/app/api/utils/types/MenuProductTypes"; // Modify a product. Modifies the product based on the id in the request body. @@ -55,9 +56,3 @@ export async function POST( return auth.verify(NextResponse.json(JSON.stringify({}), {status: 201})); } -const menuCategoryWithProducts = Prisma.validator()({include: {menu_products: true}}) -export type MenuCategoryWithProducts = Prisma.MenuCategoryGetPayload - -// Some utility types. -export type MenuProductCreate = Prisma.MenuProductCreateArgs["data"] -export type MenuCategoryCreate = Prisma.MenuCategoryCreateArgs["data"] \ No newline at end of file From ab94b594b85c8307b6c7e7a9f9727ccd8d6f6d5a Mon Sep 17 00:00:00 2001 From: Niklas Dietzel Date: Thu, 6 Nov 2025 17:43:01 +0100 Subject: [PATCH 42/52] even more `auth.verify()` I somehow keep missing them --- app/api/v2/escape/menu/products/[productId]/route.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/api/v2/escape/menu/products/[productId]/route.ts b/app/api/v2/escape/menu/products/[productId]/route.ts index 252856c..5bf88e1 100644 --- a/app/api/v2/escape/menu/products/[productId]/route.ts +++ b/app/api/v2/escape/menu/products/[productId]/route.ts @@ -24,7 +24,7 @@ export async function DELETE( // verify productId is an integer if (isNaN(productId) || !productId) { - return NextResponse.json({error: "productId must be an integer"}, {status: 400}); + return auth.verify(NextResponse.json({error: "productId must be an integer"}, {status: 400})); } try { @@ -37,7 +37,7 @@ export async function DELETE( // error code P2015 means no product with the provided id exists // see https://www.prisma.io/docs/orm/reference/error-reference#p2015 if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2015") { - return NextResponse.json({error: "Not found"}, {status: 404}); + return auth.verify(NextResponse.json({error: "Not found"}, {status: 404})); } } From 174130514eeee51974ad968fed664e52657c6260 Mon Sep 17 00:00:00 2001 From: Niklas Dietzel Date: Thu, 6 Nov 2025 17:45:36 +0100 Subject: [PATCH 43/52] remove `console.log` in `DeletionConfirmationDialog.tsx` --- app/components/input/DeletionConfirmationDialog.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/app/components/input/DeletionConfirmationDialog.tsx b/app/components/input/DeletionConfirmationDialog.tsx index 252aafa..c67dfe0 100644 --- a/app/components/input/DeletionConfirmationDialog.tsx +++ b/app/components/input/DeletionConfirmationDialog.tsx @@ -15,7 +15,6 @@ export type DeletionConfirmationDialogProps = { export function DeletionConfirmationDialog( props: DeletionConfirmationDialogProps ) { - console.log(props) return ( Date: Thu, 6 Nov 2025 17:50:09 +0100 Subject: [PATCH 44/52] only show validation error when creating a new menu category after user has interacted with it once --- .../(main)/volunteering/menu/category.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/app/(pages)/(main)/volunteering/menu/category.tsx b/app/(pages)/(main)/volunteering/menu/category.tsx index 3296e45..6342086 100644 --- a/app/(pages)/(main)/volunteering/menu/category.tsx +++ b/app/(pages)/(main)/volunteering/menu/category.tsx @@ -186,6 +186,18 @@ export function NewCategory(props: { onUpdate: () => void }) { let [categoryName, setCategoryName] = useState(""); let [isCreating, setIsCreating] = useState(false); + let [hasBeenUpdated, setHasBeenUpdated] = useState(false); + let [isFirst, setIsFirst] = useState(true); + + useEffect(() => { + if (isFirst) { + setIsFirst(false); + return; + } + + setHasBeenUpdated(true); + }, [categoryName]); + const invalid = categoryName.trim() === ""; return ( @@ -201,13 +213,13 @@ export function NewCategory(props: { onUpdate: () => void }) { style={ {fontSize: "3.75rem"} } required - error={ invalid } - helperText={ invalid ? "Name must not be empty" : "" } + error={ invalid && hasBeenUpdated} + helperText={ invalid && hasBeenUpdated ? "Name must not be empty" : "" } > From d0312d91c2908189deda0101b46f274a5c91c370 Mon Sep 17 00:00:00 2001 From: Niklas Dietzel Date: Thu, 6 Nov 2025 18:57:58 +0100 Subject: [PATCH 46/52] add database error checking for menu API endpoints --- .../menu/categories/[categoryId]/route.ts | 38 ++++++++------ app/api/v2/escape/menu/categories/route.ts | 49 ++++++++++++------- .../escape/menu/products/[productId]/route.ts | 5 ++ app/api/v2/escape/menu/products/route.ts | 45 ++++++++++------- 4 files changed, 85 insertions(+), 52 deletions(-) diff --git a/app/api/v2/escape/menu/categories/[categoryId]/route.ts b/app/api/v2/escape/menu/categories/[categoryId]/route.ts index c51dc74..e4aa4b8 100644 --- a/app/api/v2/escape/menu/categories/[categoryId]/route.ts +++ b/app/api/v2/escape/menu/categories/[categoryId]/route.ts @@ -23,22 +23,28 @@ export async function DELETE( return auth.verify(NextResponse.json({error: "categoryId must be an integer"}, {status: 400})); } - - await prisma.$transaction(async () => { - await prisma.menuProduct.deleteMany({ - where: { - category_id: categoryId - } - }); - - await prisma.menuCategory.delete( - { + try { + await prisma.$transaction(async () => { + await prisma.menuProduct.deleteMany({ where: { - id: categoryId, - }, - } - ); - }); + category_id: categoryId + } + }); + + await prisma.menuCategory.delete( + { + where: { + id: categoryId, + }, + } + ); + }); - return auth.verify(NextResponse.json({})) + return auth.verify(NextResponse.json({})) + } catch (e) { + return auth.verify(NextResponse.json( + {error: `something went wrong: ${ e }`}, + {status: 500} + )); + } } \ No newline at end of file diff --git a/app/api/v2/escape/menu/categories/route.ts b/app/api/v2/escape/menu/categories/route.ts index b43ab14..59e8650 100644 --- a/app/api/v2/escape/menu/categories/route.ts +++ b/app/api/v2/escape/menu/categories/route.ts @@ -7,7 +7,6 @@ import { authOptions } from "@/app/api/utils/authOptions"; import { MenuCategoryCreate } from "@/app/api/utils/types/MenuCategoryTypes"; - // Modify a category. Does not allow modifying products inside the category. export async function PATCH( req: NextRequest @@ -21,16 +20,22 @@ export async function PATCH( if (auth.failed) return auth.response; - - const newProduct = await prismaClient.menuCategory.update({ - where: { - id: category.id - }, - data: category - }) - - - return auth.verify(NextResponse.json(JSON.stringify(newProduct))); + try { + + const newProduct = await prismaClient.menuCategory.update({ + where: { + id: category.id + }, + data: category + }); + + return auth.verify(NextResponse.json(JSON.stringify(newProduct))); + } catch (e) { + return auth.verify(NextResponse.json( + {error: `something went wrong: ${ e }`}, + {status: 500} + )); + } } @@ -47,11 +52,17 @@ export async function POST( if (auth.failed) return auth.response; - - const newCategory = await prismaClient.menuCategory.create({ - data: category - }); - - // 201 Created - return auth.verify(NextResponse.json(JSON.stringify(newCategory), {status: 201})); -} \ No newline at end of file + try { + const newCategory = await prismaClient.menuCategory.create({ + data: category + }); + + // 201 Created + return auth.verify(NextResponse.json(JSON.stringify(newCategory), {status: 201})); + } catch (e) { + return auth.verify(NextResponse.json( + {error: `something went wrong: ${ e }`}, + {status: 500} + )); + } +} diff --git a/app/api/v2/escape/menu/products/[productId]/route.ts b/app/api/v2/escape/menu/products/[productId]/route.ts index 5bf88e1..f2d7b76 100644 --- a/app/api/v2/escape/menu/products/[productId]/route.ts +++ b/app/api/v2/escape/menu/products/[productId]/route.ts @@ -38,6 +38,11 @@ export async function DELETE( // see https://www.prisma.io/docs/orm/reference/error-reference#p2015 if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2015") { return auth.verify(NextResponse.json({error: "Not found"}, {status: 404})); + } else { + return auth.verify(NextResponse.json( + {error: `something went wrong: ${ error }`}, + {status: 500} + )); } } diff --git a/app/api/v2/escape/menu/products/route.ts b/app/api/v2/escape/menu/products/route.ts index 9fcadcd..f76f0f9 100644 --- a/app/api/v2/escape/menu/products/route.ts +++ b/app/api/v2/escape/menu/products/route.ts @@ -21,15 +21,21 @@ export async function PATCH( if (auth.failed) return auth.response; - - const newProduct = await prismaClient.menuProduct.update({ - where: { - id: product.id - }, - data: product - }); - - return auth.verify(NextResponse.json(JSON.stringify(newProduct))); + try { + const newProduct = await prismaClient.menuProduct.update({ + where: { + id: product.id + }, + data: product + }); + + return auth.verify(NextResponse.json(JSON.stringify(newProduct))); + } catch (e) { + return auth.verify(NextResponse.json( + {error: `something went wrong: ${ e }`}, + {status: 500} + )); + } } @@ -46,13 +52,18 @@ export async function POST( if (auth.failed) return auth.response; - - - await prisma.menuProduct.create({ - data: product - }); - - // 201 Created - return auth.verify(NextResponse.json(JSON.stringify({}), {status: 201})); + try { + await prisma.menuProduct.create({ + data: product + }); + + // 201 Created + return auth.verify(NextResponse.json(JSON.stringify({}), {status: 201})); + } catch (e) { + return auth.verify(NextResponse.json( + {error: `something went wrong: ${ e }`}, + {status: 500} + )); + } } From 942195b02f6852b68482beaa70568c5142da59f8 Mon Sep 17 00:00:00 2001 From: Niklas Dietzel Date: Thu, 6 Nov 2025 18:58:52 +0100 Subject: [PATCH 47/52] actually use transaction --- app/api/v2/escape/menu/categories/[categoryId]/route.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/api/v2/escape/menu/categories/[categoryId]/route.ts b/app/api/v2/escape/menu/categories/[categoryId]/route.ts index e4aa4b8..6531029 100644 --- a/app/api/v2/escape/menu/categories/[categoryId]/route.ts +++ b/app/api/v2/escape/menu/categories/[categoryId]/route.ts @@ -24,14 +24,14 @@ export async function DELETE( } try { - await prisma.$transaction(async () => { - await prisma.menuProduct.deleteMany({ + await prisma.$transaction(async transaction => { + await transaction.menuProduct.deleteMany({ where: { category_id: categoryId } }); - await prisma.menuCategory.delete( + await transaction.menuCategory.delete( { where: { id: categoryId, From fdda652c029876ea71551547fdea9baa0357292f Mon Sep 17 00:00:00 2001 From: Niklas Dietzel Date: Fri, 7 Nov 2025 12:24:12 +0100 Subject: [PATCH 48/52] fix validation immediately validating `Category name` field after creating a new category --- app/(pages)/(main)/volunteering/menu/category.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/(pages)/(main)/volunteering/menu/category.tsx b/app/(pages)/(main)/volunteering/menu/category.tsx index 53eaa5a..0a73cd0 100644 --- a/app/(pages)/(main)/volunteering/menu/category.tsx +++ b/app/(pages)/(main)/volunteering/menu/category.tsx @@ -209,6 +209,8 @@ export function NewCategory(props: { onUpdate: () => void }) { ).then(() => { setCategoryName(""); setIsCreating(false); + setIsFirst(true); + setHasBeenUpdated(false); props.onUpdate(); }); }; From 0587b967609e648b20a5a4e3fbf1e1cd2588078d Mon Sep 17 00:00:00 2001 From: Niklas Dietzel Date: Fri, 7 Nov 2025 12:49:28 +0100 Subject: [PATCH 49/52] add link to escape menu to the `AppBar` the mobile view might be getting somewhat crowded --- app/(pages)/(main)/layout.js | 7 ++++--- app/components/layout/AppBar.js | 8 +++++--- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/app/(pages)/(main)/layout.js b/app/(pages)/(main)/layout.js index fd1b0c7..081b3bf 100644 --- a/app/(pages)/(main)/layout.js +++ b/app/(pages)/(main)/layout.js @@ -3,7 +3,7 @@ import "./../../globals.css"; -import { EmojiPeople, Groups, Home } from "@mui/icons-material"; +import { EmojiPeople, Groups, Home, MenuBook } from "@mui/icons-material"; import { Box, CssBaseline, @@ -26,10 +26,11 @@ const NavItems = [ name: "Volunteering", icon: , }, + {id: "escape/menu", path: "escape/menu", name: "Menu", icon: } ]; export default function AppLayout({ children }) { - + const pathname = usePathname(); return ( @@ -61,7 +62,7 @@ export default function AppLayout({ children }) { }} > - {children} diff --git a/app/components/layout/AppBar.js b/app/components/layout/AppBar.js index 27a47ba..afae106 100644 --- a/app/components/layout/AppBar.js +++ b/app/components/layout/AppBar.js @@ -110,7 +110,7 @@ function NavElementLargeScreen(item, index, currentPath) { key={`link_nav${index}_itemtext`} sx={{ color: - currentPath == `/${item.path}` + currentPath === `/${item.path}` ? cybTheme.palette.primary.main : cybTheme.palette.text.primary, }} @@ -157,13 +157,15 @@ function NavElementSmallScreen(item, index, iconProps, currentPath) { key={`link_snav${index}_itemtext`} sx={{ color: - currentPath == `/${item.path}` + currentPath === `/${item.path}` ? cybTheme.palette.primary.main : cybTheme.palette.text.primary, + textAlign: "center" }} + primary={ - {item.name == "About CYB" ? "About" : item.name} + {item.name === "About CYB" ? "About" : item.name} } /> From e8fd7bf287c1d8fd0a5e8a978243f7199b2100bc Mon Sep 17 00:00:00 2001 From: Niklas Dietzel Date: Wed, 12 Nov 2025 16:16:58 +0100 Subject: [PATCH 50/52] require `admin` role to load `MenuEditPage` --- app/(pages)/(main)/volunteering/menu/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/(pages)/(main)/volunteering/menu/page.tsx b/app/(pages)/(main)/volunteering/menu/page.tsx index e9131e0..5f6f55f 100644 --- a/app/(pages)/(main)/volunteering/menu/page.tsx +++ b/app/(pages)/(main)/volunteering/menu/page.tsx @@ -7,7 +7,7 @@ import { Category, NewCategory } from "@/app/(pages)/(main)/volunteering/menu/ca import { MenuCategoryWithProducts } from "@/app/api/utils/types/MenuCategoryTypes"; // require login -export default authWrapper(MenuEditPage); +export default authWrapper(MenuEditPage, "admin"); function MenuEditPage() { const [menuCategories, setMenuCategories] = useState([]); From b1211c6b69b17c846333ad7ab89dc8cf6ec8320f Mon Sep 17 00:00:00 2001 From: Niklas Dietzel Date: Wed, 12 Nov 2025 16:29:55 +0100 Subject: [PATCH 51/52] move link to menu editor from the /volunteering page to /board --- .../{volunteering => board}/menu/category.tsx | 2 +- .../{volunteering => board}/menu/page.tsx | 2 +- .../{volunteering => board}/menu/product.tsx | 0 app/(pages)/(main)/board/page.js | 47 ++++++++++--------- app/(pages)/(main)/volunteering/page.js | 1 - 5 files changed, 28 insertions(+), 24 deletions(-) rename app/(pages)/(main)/{volunteering => board}/menu/category.tsx (98%) rename app/(pages)/(main)/{volunteering => board}/menu/page.tsx (93%) rename app/(pages)/(main)/{volunteering => board}/menu/product.tsx (100%) diff --git a/app/(pages)/(main)/volunteering/menu/category.tsx b/app/(pages)/(main)/board/menu/category.tsx similarity index 98% rename from app/(pages)/(main)/volunteering/menu/category.tsx rename to app/(pages)/(main)/board/menu/category.tsx index 0a73cd0..4a570ad 100644 --- a/app/(pages)/(main)/volunteering/menu/category.tsx +++ b/app/(pages)/(main)/board/menu/category.tsx @@ -4,7 +4,7 @@ import { useEffect, useState } from "react"; import { Button, Grid, Input, Stack, TextField, Typography } from "@mui/material"; import { MenuCategory } from "@prisma/client"; -import { NewProduct, Product } from "@/app/(pages)/(main)/volunteering/menu/product"; +import { NewProduct, Product } from "@/app/(pages)/(main)/board/menu/product"; import { styled } from "@mui/system"; import CircularProgress from "@mui/material/CircularProgress"; import { DeletionConfirmationDialog } from "@/app/components/input/DeletionConfirmationDialog"; diff --git a/app/(pages)/(main)/volunteering/menu/page.tsx b/app/(pages)/(main)/board/menu/page.tsx similarity index 93% rename from app/(pages)/(main)/volunteering/menu/page.tsx rename to app/(pages)/(main)/board/menu/page.tsx index 5f6f55f..3beed0a 100644 --- a/app/(pages)/(main)/volunteering/menu/page.tsx +++ b/app/(pages)/(main)/board/menu/page.tsx @@ -3,7 +3,7 @@ import { Stack } from "@mui/material"; import { useEffect, useState } from "react"; import authWrapper from "@/app/middleware/authWrapper"; -import { Category, NewCategory } from "@/app/(pages)/(main)/volunteering/menu/category"; +import { Category, NewCategory } from "@/app/(pages)/(main)/board/menu/category"; import { MenuCategoryWithProducts } from "@/app/api/utils/types/MenuCategoryTypes"; // require login diff --git a/app/(pages)/(main)/volunteering/menu/product.tsx b/app/(pages)/(main)/board/menu/product.tsx similarity index 100% rename from app/(pages)/(main)/volunteering/menu/product.tsx rename to app/(pages)/(main)/board/menu/product.tsx diff --git a/app/(pages)/(main)/board/page.js b/app/(pages)/(main)/board/page.js index cee4831..1635549 100644 --- a/app/(pages)/(main)/board/page.js +++ b/app/(pages)/(main)/board/page.js @@ -1,29 +1,34 @@ - -"use client" +"use client"; import { PageHeader } from "@/app/components/sanity/PageBuilder"; -import { Box } from "@mui/material"; +import { Box, Link, Stack } from "@mui/material"; import { useEffect, useState } from "react"; import Forcegraph from "@/app/components/RecruitmentGraph" export default function BoardPage() { - const [data, setData] = useState({ nodes: [], edges: [] }); - - useEffect(() => { - fetch(`/api/v2/recruitGraph`) - .then(res => res.json()) - .then(data => { - setData({ nodes: data.nodes, edges: data.edges}) - }) - - }, []); - - return ( - - - - - - ); + const [data, setData] = useState({nodes: [], edges: []}); + + useEffect(() => { + fetch(`/api/v2/recruitGraph`) + .then(res => res.json()) + .then(data => { + setData({nodes: data.nodes, edges: data.edges}) + }) + + }, []); + + return ( + + + + + + Menu editor + + + + + + ); } \ No newline at end of file diff --git a/app/(pages)/(main)/volunteering/page.js b/app/(pages)/(main)/volunteering/page.js index a20eb41..805cc61 100644 --- a/app/(pages)/(main)/volunteering/page.js +++ b/app/(pages)/(main)/volunteering/page.js @@ -25,7 +25,6 @@ const BUTTON_CONTENT_1 = [ // { title: "Café shifts", path: "/volunteering/cafe" }, { title: "Vouchers", path: "/volunteering/logs" }, { title: "Membership", path: "/volunteering/membership" }, - { title: "Menu", path: "/volunteering/menu" }, { title: "Website content", path: "/studio" }, ]; From ed00d43184bb193ebfcc07d00ec8be6a4e80b4fc Mon Sep 17 00:00:00 2001 From: Niklas Dietzel Date: Wed, 12 Nov 2025 17:31:31 +0100 Subject: [PATCH 52/52] improve grid column spacing and text wrapping in the menu --- app/(pages)/(main)/escape/menu/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/(pages)/(main)/escape/menu/page.tsx b/app/(pages)/(main)/escape/menu/page.tsx index da1378a..42e37e0 100644 --- a/app/(pages)/(main)/escape/menu/page.tsx +++ b/app/(pages)/(main)/escape/menu/page.tsx @@ -44,7 +44,7 @@ function Category(props: { { category.name } - + { category.menu_products.map((item) => ( @@ -69,7 +69,7 @@ function Product( return ( <> - + { product.name } { product.glutenfree ? (Gluten-free) : <> }