From d694fd1d80a6a9e5b7e193486dcbe11d865c9338 Mon Sep 17 00:00:00 2001 From: Renato Dell'Osso Date: Tue, 2 Sep 2025 22:20:42 -0400 Subject: [PATCH 01/32] Add Wishlist to Prisma Schema --- prisma/schema.prisma | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index cb01793c..f1d804b8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -45,6 +45,7 @@ model User { distributions Distribution[] unallocatedItemRequestAllocations UnallocatedItemRequestAllocation[] signOffs SignOff[] + wishlists Wishlist[] } model UserInvite { @@ -272,3 +273,18 @@ model SignOff { distributions Distribution[] } + +model Wishlist { + id Int @id @default(autoincrement()) + + name String + unitSize String + quantity Int + priority RequestPriority + + lastUpdated DateTime @updatedAt + + comment String + partnerId Int + partner User @relation(fields: [partnerId], references: [id], onDelete: Cascade) +} From f212a9befd077092f591ac156b9eed796788096a Mon Sep 17 00:00:00 2001 From: Renato Dell'Osso Date: Tue, 2 Sep 2025 22:33:19 -0400 Subject: [PATCH 02/32] Add db migration --- .../migration.sql | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 prisma/migrations/20250903023312_create_wishlists/migration.sql diff --git a/prisma/migrations/20250903023312_create_wishlists/migration.sql b/prisma/migrations/20250903023312_create_wishlists/migration.sql new file mode 100644 index 00000000..0a5ba55e --- /dev/null +++ b/prisma/migrations/20250903023312_create_wishlists/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE "Wishlist" ( + "id" SERIAL NOT NULL, + "name" TEXT NOT NULL, + "unitSize" TEXT NOT NULL, + "quantity" INTEGER NOT NULL, + "priority" "RequestPriority" NOT NULL, + "lastUpdated" TIMESTAMP(3) NOT NULL, + "comment" TEXT NOT NULL, + "partnerId" INTEGER NOT NULL, + + CONSTRAINT "Wishlist_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "Wishlist" ADD CONSTRAINT "Wishlist_partnerId_fkey" FOREIGN KEY ("partnerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; From 2d268390a3974dbe2bec3637f7923974c803ec61 Mon Sep 17 00:00:00 2001 From: Renato Dell'Osso Date: Tue, 2 Sep 2025 22:37:19 -0400 Subject: [PATCH 03/32] Rename comment field to comments --- .../migration.sql | 10 ++++++++++ prisma/schema.prisma | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/20250903023635_rename_comment_to_comments/migration.sql diff --git a/prisma/migrations/20250903023635_rename_comment_to_comments/migration.sql b/prisma/migrations/20250903023635_rename_comment_to_comments/migration.sql new file mode 100644 index 00000000..ccdc7540 --- /dev/null +++ b/prisma/migrations/20250903023635_rename_comment_to_comments/migration.sql @@ -0,0 +1,10 @@ +/* + Warnings: + + - You are about to drop the column `comment` on the `Wishlist` table. All the data in the column will be lost. + - Added the required column `comments` to the `Wishlist` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "Wishlist" DROP COLUMN "comment", +ADD COLUMN "comments" TEXT NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f1d804b8..3a3bff66 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -284,7 +284,7 @@ model Wishlist { lastUpdated DateTime @updatedAt - comment String + comments String partnerId Int partner User @relation(fields: [partnerId], references: [id], onDelete: Cascade) } From f0bd900488086a7d0eb25f405140135d3c273b45 Mon Sep 17 00:00:00 2001 From: Renato Dell'Osso Date: Tue, 2 Sep 2025 22:37:28 -0400 Subject: [PATCH 04/32] Add wishlists to seeding --- scripts/seed.ts | 94 ++++++++++++++++++++++++++++++++++++------------- 1 file changed, 69 insertions(+), 25 deletions(-) diff --git a/scripts/seed.ts b/scripts/seed.ts index 8cbb1aa3..0f31a2b1 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -68,7 +68,7 @@ const partnerDetailsTemplate = { orgTitle: "Regional Director", primaryTelephone: "+509 1234-5678", secondaryTelephone: "+509 1234-5679", - email: "marie.joseph@hopemedicine.ht" + email: "marie.joseph@hopemedicine.ht", }, medicalContact: { firstName: "Jean", @@ -76,7 +76,7 @@ const partnerDetailsTemplate = { orgTitle: "Chief Medical Officer", primaryTelephone: "+509 2345-6789", secondaryTelephone: "+509 2345-6790", - email: "jean.baptiste@hopemedicine.ht" + email: "jean.baptiste@hopemedicine.ht", }, adminDirectorContact: { firstName: "Pierre", @@ -84,7 +84,7 @@ const partnerDetailsTemplate = { orgTitle: "Administrative Director", primaryTelephone: "+509 3456-7890", secondaryTelephone: "+509 3456-7891", - email: "pierre.morel@hopemedicine.ht" + email: "pierre.morel@hopemedicine.ht", }, pharmacyContact: { firstName: "Claire", @@ -92,18 +92,20 @@ const partnerDetailsTemplate = { orgTitle: "Chief Pharmacist", primaryTelephone: "+509 4567-8901", secondaryTelephone: "+509 4567-8902", - email: "claire.dumas@hopemedicine.ht" + email: "claire.dumas@hopemedicine.ht", }, contactWhatsAppName: "Dr. Marie Joseph", contactWhatsAppNumber: "+509 1234-5678", // Introduction - organizationHistory: "Established to provide quality healthcare services to underserved communities in Haiti. Our mission is to improve health outcomes through accessible medical care and community outreach programs.", + organizationHistory: + "Established to provide quality healthcare services to underserved communities in Haiti. Our mission is to improve health outcomes through accessible medical care and community outreach programs.", supportRequested: "ongoing_support", yearOrganizationEstablished: 2015, registeredWithMssp: true, proofOfRegistationWithMssp: "mssp_registration_certificate.pdf", - programUpdatesSinceLastReport: "Expanded maternal health services and added mobile clinic outreach program", + programUpdatesSinceLastReport: + "Expanded maternal health services and added mobile clinic outreach program", // Facility facilityType: ["clinic", "primary_care", "health_center"], @@ -114,10 +116,12 @@ const partnerDetailsTemplate = { numberOfInpatientBeds: 25, numberOfPatientsServedAnnually: 12000, communityMobileOutreachOffered: true, - communityMobileOutreachDescription: "Weekly mobile clinics to remote communities, providing basic healthcare and health education", + communityMobileOutreachDescription: + "Weekly mobile clinics to remote communities, providing basic healthcare and health education", // Infrastructure and Services - facilityDescription: "Modern healthcare facility with emergency care, maternal health, pediatrics, and pharmacy services", + facilityDescription: + "Modern healthcare facility with emergency care, maternal health, pediatrics, and pharmacy services", cleanWaterAccessible: true, cleanWaterDescription: "Municipal water supply with backup well water system", closestSourceOfCleanWater: "", @@ -126,17 +130,26 @@ const partnerDetailsTemplate = { electricityAvailable: true, accessibleByDisablePatients: true, medicationDisposalProcessDefined: true, - medicationDisposalProcessDescription: "Proper pharmaceutical waste disposal following WHO guidelines with licensed waste management company", + medicationDisposalProcessDescription: + "Proper pharmaceutical waste disposal following WHO guidelines with licensed waste management company", pickupVehiclePresent: true, pickupVehicleType: "Ambulance and supply truck", pickupLocations: ["port_au_prince"], // Programs and Services Provided - medicalServicesProvided: ["pediatrics", "maternal_care", "lab_tests", "immunizations", "chronic_diseases"], - otherMedicalServicesProvided: "Health education, Community outreach, Vaccination programs", + medicalServicesProvided: [ + "pediatrics", + "maternal_care", + "lab_tests", + "immunizations", + "chronic_diseases", + ], + otherMedicalServicesProvided: + "Health education, Community outreach, Vaccination programs", // Finances - patientsWhoCannotPay: "Sliding scale fees and charity care program available for patients who cannot afford treatment", + patientsWhoCannotPay: + "Sliding scale fees and charity care program available for patients who cannot afford treatment", percentageOfPatientsNeedingFinancialAid: 65, percentageOfPatientsReceivingFreeTreatment: 30, annualSpendingOnMedicationsAndMedicalSupplies: "50001_to_100000", @@ -170,8 +183,15 @@ const partnerDetailsTemplate = { other: "2 lab technicians, 1 social worker", // Medical Supplies - mostNeededMedicalSupplies: ["anti_infectives", "cardiovascular", "bandages", "syringes_needles", "vitamins_minerals"], - otherSpecialityItemsNeeded: "Blood pressure monitors, glucometers, wound care supplies" + mostNeededMedicalSupplies: [ + "anti_infectives", + "cardiovascular", + "bandages", + "syringes_needles", + "vitamins_minerals", + ], + otherSpecialityItemsNeeded: + "Blood pressure monitors, glucometers, wound care supplies", }; const generalItems: Array = [ @@ -275,7 +295,9 @@ function genItem(props: Partial = {}): Omit { const template = pick(itemTemplates); return { ...template, - expirationDate: template.expirationDate ? new Date(template.expirationDate) : null, + expirationDate: template.expirationDate + ? new Date(template.expirationDate) + : null, donorName: pick(donorNames), quantity: randInt(1, 10) * 10, lotNumber: pick(lots), @@ -365,17 +387,17 @@ async function run() { // Vary some details to make each partner unique yearOrganizationEstablished: 2010 + (i % 10), numberOfInpatientBeds: 15 + (i % 20), - numberOfPatientsServedAnnually: 8000 + (i * 500), + numberOfPatientsServedAnnually: 8000 + i * 500, numberOfDoctors: 2 + (i % 5), numberOfNurses: 8 + (i % 10), numberOfMidwives: 1 + (i % 4), numberOfPharmacists: 1 + (i % 3), totalNumberOfStaff: 30 + (i % 25), - totalPatientsServedLastYear: 7500 + (i * 400), - menServedLastYear: 2000 + (i * 100), - womenServedLastYear: 2500 + (i * 120), - boysServedLastYear: 1500 + (i * 80), - girlsServedLastYear: 1500 + (i * 80), + totalPatientsServedLastYear: 7500 + i * 400, + menServedLastYear: 2000 + i * 100, + womenServedLastYear: 2500 + i * 120, + boysServedLastYear: 1500 + i * 80, + girlsServedLastYear: 1500 + i * 80, }, })), }); @@ -411,6 +433,20 @@ async function run() { }), }); + await tx.wishlist.createManyAndReturn({ + data: Array.from({ length: 20 }, () => { + const genItem = pick(generalItems); + return { + partnerId: pick(partners).id, + name: genItem.title, + unitSize: genItem.unitType, + priority: pick(Object.values(RequestPriority)) as RequestPriority, + quantity: randInt(1, 4) * 5, + comments: "pls", + }; + }), + }); + await Promise.all( ( [ @@ -429,7 +465,9 @@ async function run() { partnerId: partner.id, quantity: randInt(1, 8), comments: "pls give me this", - priority: pick(Object.values(RequestPriority)) as RequestPriority, + priority: pick( + Object.values(RequestPriority) + ) as RequestPriority, })), }, })), @@ -455,7 +493,9 @@ async function run() { partnerId: partner.id, quantity: randInt(1, 8), comments: "pls give me this", - priority: pick(Object.values(RequestPriority)) as RequestPriority, + priority: pick( + Object.values(RequestPriority) + ) as RequestPriority, })), }, })), @@ -481,7 +521,9 @@ async function run() { partnerId: partner.id, quantity: randInt(1, 8), comments: "pls give me this", - priority: pick(Object.values(RequestPriority)) as RequestPriority, + priority: pick( + Object.values(RequestPriority) + ) as RequestPriority, })), }, })), @@ -507,7 +549,9 @@ async function run() { partnerId: partner.id, quantity: randInt(1, 8), comments: "pls give me this", - priority: pick(Object.values(RequestPriority)) as RequestPriority, + priority: pick( + Object.values(RequestPriority) + ) as RequestPriority, })), }, })), From afb34ade0cab7dcc58f3082ce2d83e5591ec8c0a Mon Sep 17 00:00:00 2001 From: Renato Dell'Osso Date: Wed, 3 Sep 2025 10:30:28 -0400 Subject: [PATCH 05/32] Add wishlist types and service --- src/services/wishlistService.ts | 120 ++++++++++++++++++++++++++++++++ src/types/api/wishlist.types.ts | 19 +++++ 2 files changed, 139 insertions(+) create mode 100644 src/services/wishlistService.ts create mode 100644 src/types/api/wishlist.types.ts diff --git a/src/services/wishlistService.ts b/src/services/wishlistService.ts new file mode 100644 index 00000000..30d29cd0 --- /dev/null +++ b/src/services/wishlistService.ts @@ -0,0 +1,120 @@ +import { db } from "@/db"; +import { + CreateWishlistData, + UpdateWishlistData, +} from "@/types/api/wishlist.types"; +import { NotFoundError } from "@/util/errors"; +import { $Enums } from "@prisma/client"; +import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; + +export class WishlistService { + static async createWishlist(data: CreateWishlistData) { + await db.wishlist.create({ + data, + }); + } + + static async updateWishlist(data: UpdateWishlistData) { + try { + await db.wishlist.update({ + where: { + id: data.id, + }, + data: { + name: data.name, + unitSize: data.unitSize, + quantity: data.quantity, + priority: data.priority, + comments: data.comments, + }, + }); + } catch (error) { + if (error instanceof PrismaClientKnownRequestError) { + if (error.code === "P2025") { + throw new NotFoundError("Unallocated item request not found"); + } + } + throw error; + } + } + + static async deleteWishlist(id: number) { + await db.wishlist.delete({ + where: { + id, + }, + }); + } + + static async getWishlistsByPartner(partnerId: number) { + return await db.wishlist.findMany({ + where: { + partnerId, + }, + }); + } + + static async getWishlistsStatsByPartner() { + const wishlistPromise = db.wishlist.groupBy({ + by: ["partnerId", "priority"], + _count: { + id: true, + }, + }); + + const partnerPromise = db.user.findMany({ + where: { + type: $Enums.UserType.PARTNER, + }, + select: { + id: true, + name: true, + }, + }); + + const [wishlists, partners] = await Promise.all([ + wishlistPromise, + partnerPromise, + ]); + + const statsByPartner: { + [partnerId: number]: { + partnerName: string; + totalCount: number; + lowCount: number; + mediumCount: number; + highCount: number; + }; + } = {}; + + for (const partner of partners) { + statsByPartner[partner.id] = { + partnerName: partner.name, + totalCount: 0, + lowCount: 0, + mediumCount: 0, + highCount: 0, + }; + } + + for (const wishlist of wishlists) { + statsByPartner[wishlist.partnerId].totalCount += wishlist._count.id; + switch (wishlist.priority) { + case $Enums.RequestPriority.LOW: + statsByPartner[wishlist.partnerId].lowCount += wishlist._count.id; + break; + case $Enums.RequestPriority.MEDIUM: + statsByPartner[wishlist.partnerId].mediumCount += wishlist._count.id; + break; + case $Enums.RequestPriority.HIGH: + statsByPartner[wishlist.partnerId].highCount += wishlist._count.id; + break; + } + } + + return Object.entries(statsByPartner).map(([partnerId, stats]) => ({ + partnerId: Number(partnerId), + ...stats, + })); + } +} diff --git a/src/types/api/wishlist.types.ts b/src/types/api/wishlist.types.ts new file mode 100644 index 00000000..ebf177b5 --- /dev/null +++ b/src/types/api/wishlist.types.ts @@ -0,0 +1,19 @@ +import { $Enums } from "@prisma/client"; + +export interface CreateWishlistData { + partnerId: number; + name: string; + unitSize: string; + quantity: number; + priority: $Enums.RequestPriority; + comments: string; +} + +export interface UpdateWishlistData { + id: number; + name?: string; + unitSize?: string; + quantity?: number; + priority?: $Enums.RequestPriority; + comments?: string; +} From f00fd6d2ee155608ef856f1a29accd4122bd3a37 Mon Sep 17 00:00:00 2001 From: Renato Dell'Osso Date: Wed, 3 Sep 2025 10:35:57 -0400 Subject: [PATCH 06/32] Create WishlistStats type --- src/services/wishlistService.ts | 11 +++-------- src/types/api/wishlist.types.ts | 9 +++++++++ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/services/wishlistService.ts b/src/services/wishlistService.ts index 30d29cd0..6e17b208 100644 --- a/src/services/wishlistService.ts +++ b/src/services/wishlistService.ts @@ -2,6 +2,7 @@ import { db } from "@/db"; import { CreateWishlistData, UpdateWishlistData, + WishlistStats, } from "@/types/api/wishlist.types"; import { NotFoundError } from "@/util/errors"; import { $Enums } from "@prisma/client"; @@ -78,13 +79,7 @@ export class WishlistService { ]); const statsByPartner: { - [partnerId: number]: { - partnerName: string; - totalCount: number; - lowCount: number; - mediumCount: number; - highCount: number; - }; + [partnerId: number]: Omit; } = {}; for (const partner of partners) { @@ -115,6 +110,6 @@ export class WishlistService { return Object.entries(statsByPartner).map(([partnerId, stats]) => ({ partnerId: Number(partnerId), ...stats, - })); + })) as WishlistStats[]; } } diff --git a/src/types/api/wishlist.types.ts b/src/types/api/wishlist.types.ts index ebf177b5..956c78db 100644 --- a/src/types/api/wishlist.types.ts +++ b/src/types/api/wishlist.types.ts @@ -17,3 +17,12 @@ export interface UpdateWishlistData { priority?: $Enums.RequestPriority; comments?: string; } + +export interface WishlistStats { + partnerId: number; + partnerName: string; + totalCount: number; + lowCount: number; + mediumCount: number; + highCount: number; +} From 5e67b533eee23720620caaa0133190334f504246 Mon Sep 17 00:00:00 2001 From: Renato Dell'Osso Date: Wed, 3 Sep 2025 10:45:12 -0400 Subject: [PATCH 07/32] Add POST api/wishlists --- src/app/api/wishlists/route.ts | 55 ++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 src/app/api/wishlists/route.ts diff --git a/src/app/api/wishlists/route.ts b/src/app/api/wishlists/route.ts new file mode 100644 index 00000000..a5b5c296 --- /dev/null +++ b/src/app/api/wishlists/route.ts @@ -0,0 +1,55 @@ +import { auth } from "@/auth"; +import { WishlistService } from "@/services/wishlistService"; +import { CreateWishlistData } from "@/types/api/wishlist.types"; +import { + ArgumentError, + AuthenticationError, + errorResponse, + ok, +} from "@/util/errors"; +import { $Enums } from "@prisma/client"; +import { NextRequest } from "next/server"; +import { z } from "zod"; + +const postParamSchema = z.object({ + name: z.string().min(3).max(100), + unitSize: z.string().min(2).max(100), + quantity: z.number().min(1), + priority: z.enum( + Object.values($Enums.RequestPriority) as [string, ...string[]] + ), + comments: z.string().max(500), +}); + +export async function POST(req: NextRequest) { + try { + const session = await auth(); + if (!session?.user) { + throw new AuthenticationError("Session required"); + } + + if (session.user.type !== $Enums.UserType.PARTNER) { + throw new AuthenticationError("Must be PARTNER"); + } + + const body = await req.json(); + const result = postParamSchema.safeParse(body); + if (!result.success) { + throw new ArgumentError(result.error.message); + } + + // Request is valid + + const wishlist: CreateWishlistData = { + ...result.data, + partnerId: Number(session.user.id), + priority: result.data.priority as $Enums.RequestPriority, + }; + + await WishlistService.createWishlist(wishlist); + + return ok(); + } catch (error) { + errorResponse(error); + } +} From 8198323582c2dc97a70f68719e234d63b1546808 Mon Sep 17 00:00:00 2001 From: Renato Dell'Osso Date: Wed, 3 Sep 2025 14:56:16 -0400 Subject: [PATCH 08/32] Fix return in POST api/wishlists --- src/app/api/wishlists/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/api/wishlists/route.ts b/src/app/api/wishlists/route.ts index a5b5c296..251de88f 100644 --- a/src/app/api/wishlists/route.ts +++ b/src/app/api/wishlists/route.ts @@ -50,6 +50,6 @@ export async function POST(req: NextRequest) { return ok(); } catch (error) { - errorResponse(error); + return errorResponse(error); } } From 2bc32e992a603081d32517ab1ea4fcad68e7258d Mon Sep 17 00:00:00 2001 From: Renato Dell'Osso Date: Wed, 3 Sep 2025 15:04:11 -0400 Subject: [PATCH 09/32] Add GET api/wishlists --- src/app/api/wishlists/route.ts | 51 +++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/src/app/api/wishlists/route.ts b/src/app/api/wishlists/route.ts index 251de88f..5182040c 100644 --- a/src/app/api/wishlists/route.ts +++ b/src/app/api/wishlists/route.ts @@ -1,4 +1,5 @@ import { auth } from "@/auth"; +import UserService from "@/services/userService"; import { WishlistService } from "@/services/wishlistService"; import { CreateWishlistData } from "@/types/api/wishlist.types"; import { @@ -8,7 +9,7 @@ import { ok, } from "@/util/errors"; import { $Enums } from "@prisma/client"; -import { NextRequest } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; const postParamSchema = z.object({ @@ -53,3 +54,51 @@ export async function POST(req: NextRequest) { return errorResponse(error); } } + +export async function GET(req: NextRequest) { + try { + const session = await auth(); + if (!session?.user) { + throw new AuthenticationError("Session required"); + } + + let partnerId = Number(new URL(req.url).searchParams.get("partnerId")); + + // Only let partners see their own wishlists. Default partnerId to their own + if (session.user.type === $Enums.UserType.PARTNER) { + if (partnerId && partnerId !== Number(session.user.id)) { + throw new AuthenticationError( + "Cannot access wishlists for other partners" + ); + } + + partnerId = Number(session.user.id); + } + + if (partnerId) { + if (Number.isNaN(partnerId)) { + throw new ArgumentError("partnerId must be a number"); + } + + if (partnerId <= 0) { + throw new ArgumentError("partnerId must be a positive number"); + } + + const wishlists = await WishlistService.getWishlistsByPartner(partnerId); + return NextResponse.json(wishlists, { + status: 200, + }); + } + + if (!UserService.isStaff(session.user.type)) { + throw new AuthenticationError("Must be staff"); + } + + const stats = await WishlistService.getWishlistsStatsByPartner(); + return NextResponse.json(stats, { + status: 200, + }); + } catch (error) { + return errorResponse(error); + } +} From a3900a968c58d84b7fa2a20bae74b234ff077e1b Mon Sep 17 00:00:00 2001 From: Renato Dell'Osso Date: Wed, 3 Sep 2025 15:26:09 -0400 Subject: [PATCH 10/32] Add PATCH api/wishlists/[wishlistId]. Add authRequirePartner. Add schema file for wishlists --- src/app/api/wishlists/[wishlistId]/route.ts | 51 ++++++++++++++++++++ src/app/api/wishlists/route.ts | 23 ++------- src/schema/wishlist.ts | 28 +++++++++++ src/services/userService.ts | 52 +++++++++++++-------- src/services/wishlistService.ts | 11 +++++ 5 files changed, 126 insertions(+), 39 deletions(-) create mode 100644 src/app/api/wishlists/[wishlistId]/route.ts create mode 100644 src/schema/wishlist.ts diff --git a/src/app/api/wishlists/[wishlistId]/route.ts b/src/app/api/wishlists/[wishlistId]/route.ts new file mode 100644 index 00000000..986bbfb2 --- /dev/null +++ b/src/app/api/wishlists/[wishlistId]/route.ts @@ -0,0 +1,51 @@ +import { updateWishlistSchema } from "@/schema/wishlist"; +import UserService from "@/services/userService"; +import { WishlistService } from "@/services/wishlistService"; +import { ArgumentError, errorResponse, ok } from "@/util/errors"; +import { $Enums } from "@prisma/client"; +import { NextRequest } from "next/server"; + +/** + * Updates a wishlist item. Allows changes to name, unit size, quantity, priority, and comments. + * @param req + * @returns + */ +export async function PATCH( + req: NextRequest, + { params }: { params: Promise<{ wishlistId: string }> } +) { + try { + const session = await UserService.authRequirePartner(); + + const { wishlistId } = await params; + const body = await req.json(); + + const parsed = updateWishlistSchema.safeParse(body); + if (!parsed.success) { + throw new ArgumentError(parsed.error.message); + } + + const wishlistItem = await WishlistService.getWishlistItem( + Number(wishlistId) + ); + + // Check ID first to avoid revealing the existence/non-existence of the wishlist item to an unauthorized user + if (wishlistItem?.partnerId !== Number(session.user.id)) { + throw new ArgumentError("Cannot update wishlist item"); + } + + if (!wishlistItem) { + throw new ArgumentError("Wishlist item not found"); + } + + await WishlistService.updateWishlist({ + ...parsed.data, + id: Number(wishlistId), + priority: parsed.data.priority as $Enums.RequestPriority, + }); + + return ok(); + } catch (error) { + return errorResponse(error); + } +} diff --git a/src/app/api/wishlists/route.ts b/src/app/api/wishlists/route.ts index 5182040c..5ec0260a 100644 --- a/src/app/api/wishlists/route.ts +++ b/src/app/api/wishlists/route.ts @@ -1,4 +1,5 @@ import { auth } from "@/auth"; +import { createWishlistSchema } from "@/schema/wishlist"; import UserService from "@/services/userService"; import { WishlistService } from "@/services/wishlistService"; import { CreateWishlistData } from "@/types/api/wishlist.types"; @@ -10,31 +11,13 @@ import { } from "@/util/errors"; import { $Enums } from "@prisma/client"; import { NextRequest, NextResponse } from "next/server"; -import { z } from "zod"; - -const postParamSchema = z.object({ - name: z.string().min(3).max(100), - unitSize: z.string().min(2).max(100), - quantity: z.number().min(1), - priority: z.enum( - Object.values($Enums.RequestPriority) as [string, ...string[]] - ), - comments: z.string().max(500), -}); export async function POST(req: NextRequest) { try { - const session = await auth(); - if (!session?.user) { - throw new AuthenticationError("Session required"); - } - - if (session.user.type !== $Enums.UserType.PARTNER) { - throw new AuthenticationError("Must be PARTNER"); - } + const session = await UserService.authRequirePartner(); const body = await req.json(); - const result = postParamSchema.safeParse(body); + const result = createWishlistSchema.safeParse(body); if (!result.success) { throw new ArgumentError(result.error.message); } diff --git a/src/schema/wishlist.ts b/src/schema/wishlist.ts new file mode 100644 index 00000000..482147f9 --- /dev/null +++ b/src/schema/wishlist.ts @@ -0,0 +1,28 @@ +import { $Enums } from "@prisma/client"; +import { z } from "zod"; + +const createObj = { + name: z.string().min(3).max(100), + unitSize: z.string().min(2).max(100), + quantity: z.number().min(1), + priority: z.enum( + Object.values($Enums.RequestPriority) as [string, ...string[]] + ), + comments: z.string().max(500), +}; + +export const createWishlistSchema = z.object(createObj); + +/** + * The create schema, but every field is optional and there's a mandatory ID field. + * I tried to automatically map fields from createObj, but it caused type errors. + * @see createWishlistSchema + */ +export const updateWishlistSchema = z.object({ + id: z.number().min(1), + name: createObj.name.optional(), + unitSize: createObj.unitSize.optional(), + quantity: createObj.quantity.optional(), + priority: createObj.priority.optional(), + comments: createObj.comments.optional(), +}); diff --git a/src/services/userService.ts b/src/services/userService.ts index b5aa5804..9b2e1b28 100644 --- a/src/services/userService.ts +++ b/src/services/userService.ts @@ -1,9 +1,10 @@ import { db } from "@/db"; -import { UserType } from "@prisma/client"; -import { - ArgumentError, - NotFoundError, - ConflictError, +import { $Enums, UserType } from "@prisma/client"; +import { + ArgumentError, + NotFoundError, + ConflictError, + AuthenticationError, } from "@/util/errors"; import * as argon2 from "argon2"; import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; @@ -14,11 +15,11 @@ import UserInviteTemplate from "@/email/templates/UserInvite"; import { CreateUserFromInviteData, CreateUserInviteData, - UpdateUserData + UpdateUserData, } from "@/types/api/user.types"; +import { auth } from "@/auth"; export default class UserService { - static async getUsers() { const users = await db.user.findMany({ select: { @@ -43,11 +44,11 @@ export default class UserService { enabled: true, }, }); - + if (!user) { throw new NotFoundError(`User with ID ${userId} not found`); } - + return user; } @@ -62,7 +63,7 @@ export default class UserService { enabled: true, }, }); - + return user; } @@ -71,11 +72,11 @@ export default class UserService { where: { token }, select: { email: true, name: true, expiration: true }, }); - + if (!invite || invite.expiration < new Date()) { throw new ArgumentError("Invalid invite token"); } - + return invite; } @@ -124,7 +125,7 @@ export default class UserService { } const passwordHash = await argon2.hash(data.password); - + try { await db.user.create({ data: { @@ -190,12 +191,8 @@ export default class UserService { } } - static isAdmin(userType: UserType): boolean { - return ( - userType === UserType.ADMIN || - userType === UserType.SUPER_ADMIN - ); + return userType === UserType.ADMIN || userType === UserType.SUPER_ADMIN; } static isStaff(userType: UserType): boolean { @@ -209,4 +206,21 @@ export default class UserService { static isSuperAdmin(userType: UserType): boolean { return userType === UserType.SUPER_ADMIN; } -} \ No newline at end of file + + /** + * Checks that the user is signed in and is a partner. + * @returns the session + */ + static async authRequirePartner() { + const session = await auth(); + if (!session?.user) { + throw new AuthenticationError("Session required"); + } + + if (session.user.type !== $Enums.UserType.PARTNER) { + throw new AuthenticationError("Must be PARTNER"); + } + + return session; + } +} diff --git a/src/services/wishlistService.ts b/src/services/wishlistService.ts index 6e17b208..fbac2e6a 100644 --- a/src/services/wishlistService.ts +++ b/src/services/wishlistService.ts @@ -15,6 +15,9 @@ export class WishlistService { }); } + /** + * Automatically updates lastUpdated + */ static async updateWishlist(data: UpdateWishlistData) { try { await db.wishlist.update({ @@ -47,6 +50,14 @@ export class WishlistService { }); } + static async getWishlistItem(id: number) { + return await db.wishlist.findUnique({ + where: { + id, + }, + }); + } + static async getWishlistsByPartner(partnerId: number) { return await db.wishlist.findMany({ where: { From 6f16318f375326268a1b8a7dc127d005cc2bf9d3 Mon Sep 17 00:00:00 2001 From: Renato Dell'Osso Date: Wed, 3 Sep 2025 15:28:15 -0400 Subject: [PATCH 11/32] Add DELETE api/wishlists/[wishlistId[ --- src/app/api/wishlists/[wishlistId]/route.ts | 32 ++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/app/api/wishlists/[wishlistId]/route.ts b/src/app/api/wishlists/[wishlistId]/route.ts index 986bbfb2..6ceb9062 100644 --- a/src/app/api/wishlists/[wishlistId]/route.ts +++ b/src/app/api/wishlists/[wishlistId]/route.ts @@ -31,7 +31,7 @@ export async function PATCH( // Check ID first to avoid revealing the existence/non-existence of the wishlist item to an unauthorized user if (wishlistItem?.partnerId !== Number(session.user.id)) { - throw new ArgumentError("Cannot update wishlist item"); + throw new ArgumentError("Cannot update another partner's wishlist item"); } if (!wishlistItem) { @@ -49,3 +49,33 @@ export async function PATCH( return errorResponse(error); } } + +export async function DELETE( + _: NextRequest, + { params }: { params: Promise<{ wishlistId: string }> } +) { + try { + const session = await UserService.authRequirePartner(); + + const { wishlistId } = await params; + + const wishlistItem = await WishlistService.getWishlistItem( + Number(wishlistId) + ); + + // Check ID first to avoid revealing the existence/non-existence of the wishlist item to an unauthorized user + if (wishlistItem?.partnerId !== Number(session.user.id)) { + throw new ArgumentError("Cannot delete another partner's wishlist item"); + } + + if (!wishlistItem) { + throw new ArgumentError("Wishlist item not found"); + } + + await WishlistService.deleteWishlist(Number(wishlistId)); + + return ok(); + } catch (error) { + return errorResponse(error); + } +} From c846d8e07444fc698311dada0f945665be92dd7e Mon Sep 17 00:00:00 2001 From: Renato Dell'Osso Date: Wed, 3 Sep 2025 15:42:05 -0400 Subject: [PATCH 12/32] Better typing for updateWishlistSchema --- src/app/api/wishlists/[wishlistId]/route.ts | 4 +--- src/schema/wishlist.ts | 23 ++++++++++++--------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/app/api/wishlists/[wishlistId]/route.ts b/src/app/api/wishlists/[wishlistId]/route.ts index 6ceb9062..c81f3835 100644 --- a/src/app/api/wishlists/[wishlistId]/route.ts +++ b/src/app/api/wishlists/[wishlistId]/route.ts @@ -2,7 +2,6 @@ import { updateWishlistSchema } from "@/schema/wishlist"; import UserService from "@/services/userService"; import { WishlistService } from "@/services/wishlistService"; import { ArgumentError, errorResponse, ok } from "@/util/errors"; -import { $Enums } from "@prisma/client"; import { NextRequest } from "next/server"; /** @@ -39,9 +38,8 @@ export async function PATCH( } await WishlistService.updateWishlist({ - ...parsed.data, id: Number(wishlistId), - priority: parsed.data.priority as $Enums.RequestPriority, + ...parsed.data, }); return ok(); diff --git a/src/schema/wishlist.ts b/src/schema/wishlist.ts index 482147f9..8bbfb058 100644 --- a/src/schema/wishlist.ts +++ b/src/schema/wishlist.ts @@ -1,5 +1,6 @@ +import { UpdateWishlistData } from "@/types/api/wishlist.types"; import { $Enums } from "@prisma/client"; -import { z } from "zod"; +import { z, ZodObject } from "zod"; const createObj = { name: z.string().min(3).max(100), @@ -14,15 +15,17 @@ const createObj = { export const createWishlistSchema = z.object(createObj); /** - * The create schema, but every field is optional and there's a mandatory ID field. - * I tried to automatically map fields from createObj, but it caused type errors. + * The create schema, but every field is optional. + * * @see createWishlistSchema */ -export const updateWishlistSchema = z.object({ - id: z.number().min(1), - name: createObj.name.optional(), - unitSize: createObj.unitSize.optional(), - quantity: createObj.quantity.optional(), - priority: createObj.priority.optional(), - comments: createObj.comments.optional(), +export const updateWishlistSchema: ZodObject< + Partial, // Input type + "strip", + z.ZodTypeAny, + Omit // Output type +> = z.object({ + ...Object.fromEntries( + Object.entries(createObj).map(([key, value]) => [key, value.optional()]) + ), }); From 09f4fb765a56edab25f527c9cb82d23ebb0a4f20 Mon Sep 17 00:00:00 2001 From: Renato Dell'Osso Date: Wed, 3 Sep 2025 21:17:48 -0400 Subject: [PATCH 13/32] Make quantity a decimal not an int --- prisma/schema.prisma | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3a3bff66..cdab0966 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -279,7 +279,7 @@ model Wishlist { name String unitSize String - quantity Int + quantity Decimal priority RequestPriority lastUpdated DateTime @updatedAt From 0d9267b6f825919c9b4df34b3835a13f5987865b Mon Sep 17 00:00:00 2001 From: Renato Dell'Osso Date: Wed, 3 Sep 2025 21:19:37 -0400 Subject: [PATCH 14/32] Revert prior change --- prisma/schema.prisma | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index cdab0966..3a3bff66 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -279,7 +279,7 @@ model Wishlist { name String unitSize String - quantity Decimal + quantity Int priority RequestPriority lastUpdated DateTime @updatedAt From cadc4f6d6923d0a158950bcd68fe7f85ed224680 Mon Sep 17 00:00:00 2001 From: Renato Dell'Osso Date: Wed, 3 Sep 2025 21:25:38 -0400 Subject: [PATCH 15/32] Add note about errors to authRequirePartner --- src/services/userService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/userService.ts b/src/services/userService.ts index 9b2e1b28..ad0834cd 100644 --- a/src/services/userService.ts +++ b/src/services/userService.ts @@ -208,7 +208,7 @@ export default class UserService { } /** - * Checks that the user is signed in and is a partner. + * Checks that the user is signed in and is a partner. Throws an error if not. * @returns the session */ static async authRequirePartner() { From e0b6ffc5e27d83d3405753259785db6bbc4ff0cc Mon Sep 17 00:00:00 2001 From: Renato Dell'Osso Date: Wed, 3 Sep 2025 21:34:13 -0400 Subject: [PATCH 16/32] Clean up updateWishlist --- src/services/wishlistService.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/services/wishlistService.ts b/src/services/wishlistService.ts index fbac2e6a..d1912178 100644 --- a/src/services/wishlistService.ts +++ b/src/services/wishlistService.ts @@ -24,13 +24,7 @@ export class WishlistService { where: { id: data.id, }, - data: { - name: data.name, - unitSize: data.unitSize, - quantity: data.quantity, - priority: data.priority, - comments: data.comments, - }, + data, }); } catch (error) { if (error instanceof PrismaClientKnownRequestError) { From fb48e30fd33188abffcda645d71928c00cbffad9 Mon Sep 17 00:00:00 2001 From: Renato Dell'Osso Date: Wed, 3 Sep 2025 21:37:46 -0400 Subject: [PATCH 17/32] Make UpdateWishlistData build off of CreateWishListData --- src/types/api/wishlist.types.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/types/api/wishlist.types.ts b/src/types/api/wishlist.types.ts index 956c78db..4b596a07 100644 --- a/src/types/api/wishlist.types.ts +++ b/src/types/api/wishlist.types.ts @@ -9,13 +9,9 @@ export interface CreateWishlistData { comments: string; } -export interface UpdateWishlistData { +export interface UpdateWishlistData + extends Partial> { id: number; - name?: string; - unitSize?: string; - quantity?: number; - priority?: $Enums.RequestPriority; - comments?: string; } export interface WishlistStats { From 38ef77f6a6d1e81b764b86da25e5a2614245f7cb Mon Sep 17 00:00:00 2001 From: Renato Dell'Osso Date: Sun, 7 Sep 2025 20:10:30 -0400 Subject: [PATCH 18/32] Parse PATCH api/wishlists/[id] params with zod --- src/app/api/wishlists/[wishlistId]/route.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/app/api/wishlists/[wishlistId]/route.ts b/src/app/api/wishlists/[wishlistId]/route.ts index c81f3835..10c352a9 100644 --- a/src/app/api/wishlists/[wishlistId]/route.ts +++ b/src/app/api/wishlists/[wishlistId]/route.ts @@ -3,6 +3,9 @@ import UserService from "@/services/userService"; import { WishlistService } from "@/services/wishlistService"; import { ArgumentError, errorResponse, ok } from "@/util/errors"; import { NextRequest } from "next/server"; +import { z } from "zod"; + +const idSchema = z.number().min(1); /** * Updates a wishlist item. Allows changes to name, unit size, quantity, priority, and comments. @@ -16,7 +19,12 @@ export async function PATCH( try { const session = await UserService.authRequirePartner(); - const { wishlistId } = await params; + const parsedId = idSchema.safeParse(await params); + if (!parsedId.success) { + throw new ArgumentError("Invalid wishlist ID"); + } + + const wishlistId = parsedId.data; const body = await req.json(); const parsed = updateWishlistSchema.safeParse(body); From 28a48512f8532b257c64e25472ca91a48664feca Mon Sep 17 00:00:00 2001 From: Renato Dell'Osso Date: Sun, 7 Sep 2025 20:12:53 -0400 Subject: [PATCH 19/32] Parse DELETE api/wishlists/[id] params with zod --- src/app/api/wishlists/[wishlistId]/route.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/app/api/wishlists/[wishlistId]/route.ts b/src/app/api/wishlists/[wishlistId]/route.ts index 10c352a9..2c831988 100644 --- a/src/app/api/wishlists/[wishlistId]/route.ts +++ b/src/app/api/wishlists/[wishlistId]/route.ts @@ -32,9 +32,7 @@ export async function PATCH( throw new ArgumentError(parsed.error.message); } - const wishlistItem = await WishlistService.getWishlistItem( - Number(wishlistId) - ); + const wishlistItem = await WishlistService.getWishlistItem(wishlistId); // Check ID first to avoid revealing the existence/non-existence of the wishlist item to an unauthorized user if (wishlistItem?.partnerId !== Number(session.user.id)) { @@ -63,11 +61,13 @@ export async function DELETE( try { const session = await UserService.authRequirePartner(); - const { wishlistId } = await params; + const parsedId = idSchema.safeParse(await params); + if (!parsedId.success) { + throw new ArgumentError("Invalid wishlist ID"); + } - const wishlistItem = await WishlistService.getWishlistItem( - Number(wishlistId) - ); + const wishlistId = parsedId.data; + const wishlistItem = await WishlistService.getWishlistItem(wishlistId); // Check ID first to avoid revealing the existence/non-existence of the wishlist item to an unauthorized user if (wishlistItem?.partnerId !== Number(session.user.id)) { From aa2f04ca8890579d272729da344e94aaa07b6f8f Mon Sep 17 00:00:00 2001 From: Renato Dell'Osso Date: Sun, 7 Sep 2025 20:18:03 -0400 Subject: [PATCH 20/32] Parse GET api/wishlists params with zod --- src/app/api/wishlists/[wishlistId]/route.ts | 4 +--- src/app/api/wishlists/route.ts | 11 +++++++++-- src/schema/wishlist.ts | 2 ++ 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/app/api/wishlists/[wishlistId]/route.ts b/src/app/api/wishlists/[wishlistId]/route.ts index 2c831988..564b38e0 100644 --- a/src/app/api/wishlists/[wishlistId]/route.ts +++ b/src/app/api/wishlists/[wishlistId]/route.ts @@ -1,12 +1,10 @@ -import { updateWishlistSchema } from "@/schema/wishlist"; +import { idSchema, updateWishlistSchema } from "@/schema/wishlist"; import UserService from "@/services/userService"; import { WishlistService } from "@/services/wishlistService"; import { ArgumentError, errorResponse, ok } from "@/util/errors"; import { NextRequest } from "next/server"; import { z } from "zod"; -const idSchema = z.number().min(1); - /** * Updates a wishlist item. Allows changes to name, unit size, quantity, priority, and comments. * @param req diff --git a/src/app/api/wishlists/route.ts b/src/app/api/wishlists/route.ts index 5ec0260a..b736f581 100644 --- a/src/app/api/wishlists/route.ts +++ b/src/app/api/wishlists/route.ts @@ -1,5 +1,5 @@ import { auth } from "@/auth"; -import { createWishlistSchema } from "@/schema/wishlist"; +import { createWishlistSchema, idSchema } from "@/schema/wishlist"; import UserService from "@/services/userService"; import { WishlistService } from "@/services/wishlistService"; import { CreateWishlistData } from "@/types/api/wishlist.types"; @@ -45,7 +45,14 @@ export async function GET(req: NextRequest) { throw new AuthenticationError("Session required"); } - let partnerId = Number(new URL(req.url).searchParams.get("partnerId")); + const parsedPartnerId = idSchema.safeParse( + req.nextUrl.searchParams.get("partnerId") + ); + + if (!parsedPartnerId.success) { + throw new ArgumentError("Invalid partnerId"); + } + let partnerId = parsedPartnerId.data; // Only let partners see their own wishlists. Default partnerId to their own if (session.user.type === $Enums.UserType.PARTNER) { diff --git a/src/schema/wishlist.ts b/src/schema/wishlist.ts index 8bbfb058..e5772229 100644 --- a/src/schema/wishlist.ts +++ b/src/schema/wishlist.ts @@ -29,3 +29,5 @@ export const updateWishlistSchema: ZodObject< Object.entries(createObj).map(([key, value]) => [key, value.optional()]) ), }); + +export const idSchema = z.number().min(1); From f00bbaa9b7439aa687b29f76e88dc6e996b007b1 Mon Sep 17 00:00:00 2001 From: Renato Dell'Osso Date: Sun, 7 Sep 2025 20:18:15 -0400 Subject: [PATCH 21/32] Remove unneeded import --- src/app/api/wishlists/[wishlistId]/route.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/api/wishlists/[wishlistId]/route.ts b/src/app/api/wishlists/[wishlistId]/route.ts index 564b38e0..43c48d70 100644 --- a/src/app/api/wishlists/[wishlistId]/route.ts +++ b/src/app/api/wishlists/[wishlistId]/route.ts @@ -3,7 +3,6 @@ import UserService from "@/services/userService"; import { WishlistService } from "@/services/wishlistService"; import { ArgumentError, errorResponse, ok } from "@/util/errors"; import { NextRequest } from "next/server"; -import { z } from "zod"; /** * Updates a wishlist item. Allows changes to name, unit size, quantity, priority, and comments. From 35be09d285a6bc33581bf38b2973c219bfe8e3d2 Mon Sep 17 00:00:00 2001 From: Renato Dell'Osso Date: Sun, 7 Sep 2025 20:29:35 -0400 Subject: [PATCH 22/32] Convert authRequirePartner to isPartner --- src/app/api/wishlists/[wishlistId]/route.ts | 17 +++++++++++++---- src/app/api/wishlists/route.ts | 8 ++++++-- src/services/userService.ts | 18 +++--------------- 3 files changed, 22 insertions(+), 21 deletions(-) diff --git a/src/app/api/wishlists/[wishlistId]/route.ts b/src/app/api/wishlists/[wishlistId]/route.ts index 43c48d70..4404c5a9 100644 --- a/src/app/api/wishlists/[wishlistId]/route.ts +++ b/src/app/api/wishlists/[wishlistId]/route.ts @@ -1,3 +1,4 @@ +import { auth } from "@/auth"; import { idSchema, updateWishlistSchema } from "@/schema/wishlist"; import UserService from "@/services/userService"; import { WishlistService } from "@/services/wishlistService"; @@ -14,7 +15,11 @@ export async function PATCH( { params }: { params: Promise<{ wishlistId: string }> } ) { try { - const session = await UserService.authRequirePartner(); + const session = await auth(); + + if (!UserService.isPartner(session?.user)) { + throw new ArgumentError("Must be PARTNER"); + } const parsedId = idSchema.safeParse(await params); if (!parsedId.success) { @@ -32,7 +37,7 @@ export async function PATCH( const wishlistItem = await WishlistService.getWishlistItem(wishlistId); // Check ID first to avoid revealing the existence/non-existence of the wishlist item to an unauthorized user - if (wishlistItem?.partnerId !== Number(session.user.id)) { + if (wishlistItem?.partnerId !== Number(session!.user.id)) { throw new ArgumentError("Cannot update another partner's wishlist item"); } @@ -56,7 +61,11 @@ export async function DELETE( { params }: { params: Promise<{ wishlistId: string }> } ) { try { - const session = await UserService.authRequirePartner(); + const session = await auth(); + + if (!UserService.isPartner(session?.user)) { + throw new ArgumentError("Must be PARTNER"); + } const parsedId = idSchema.safeParse(await params); if (!parsedId.success) { @@ -67,7 +76,7 @@ export async function DELETE( const wishlistItem = await WishlistService.getWishlistItem(wishlistId); // Check ID first to avoid revealing the existence/non-existence of the wishlist item to an unauthorized user - if (wishlistItem?.partnerId !== Number(session.user.id)) { + if (wishlistItem?.partnerId !== Number(session!.user.id)) { throw new ArgumentError("Cannot delete another partner's wishlist item"); } diff --git a/src/app/api/wishlists/route.ts b/src/app/api/wishlists/route.ts index b736f581..b1f32922 100644 --- a/src/app/api/wishlists/route.ts +++ b/src/app/api/wishlists/route.ts @@ -14,7 +14,11 @@ import { NextRequest, NextResponse } from "next/server"; export async function POST(req: NextRequest) { try { - const session = await UserService.authRequirePartner(); + const session = await auth(); + + if (!UserService.isPartner(session?.user)) { + throw new ArgumentError("Only partners can create wishlist items"); + } const body = await req.json(); const result = createWishlistSchema.safeParse(body); @@ -26,7 +30,7 @@ export async function POST(req: NextRequest) { const wishlist: CreateWishlistData = { ...result.data, - partnerId: Number(session.user.id), + partnerId: Number(session!.user.id), priority: result.data.priority as $Enums.RequestPriority, }; diff --git a/src/services/userService.ts b/src/services/userService.ts index ad0834cd..638e648a 100644 --- a/src/services/userService.ts +++ b/src/services/userService.ts @@ -4,7 +4,6 @@ import { ArgumentError, NotFoundError, ConflictError, - AuthenticationError, } from "@/util/errors"; import * as argon2 from "argon2"; import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; @@ -17,7 +16,6 @@ import { CreateUserInviteData, UpdateUserData, } from "@/types/api/user.types"; -import { auth } from "@/auth"; export default class UserService { static async getUsers() { @@ -208,19 +206,9 @@ export default class UserService { } /** - * Checks that the user is signed in and is a partner. Throws an error if not. - * @returns the session + * @returns false if user is undefined or not a partner, true if user is a partner */ - static async authRequirePartner() { - const session = await auth(); - if (!session?.user) { - throw new AuthenticationError("Session required"); - } - - if (session.user.type !== $Enums.UserType.PARTNER) { - throw new AuthenticationError("Must be PARTNER"); - } - - return session; + static isPartner(user: { type: $Enums.UserType } | undefined) { + return user && user.type === $Enums.UserType.PARTNER; } } From 188af22d331332552b570f229e54fd8bf46847a4 Mon Sep 17 00:00:00 2001 From: Renato Dell'Osso Date: Sun, 7 Sep 2025 20:30:33 -0400 Subject: [PATCH 23/32] Fix GET /wishlists without ID --- src/app/api/wishlists/route.ts | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/src/app/api/wishlists/route.ts b/src/app/api/wishlists/route.ts index b1f32922..256bec73 100644 --- a/src/app/api/wishlists/route.ts +++ b/src/app/api/wishlists/route.ts @@ -49,9 +49,9 @@ export async function GET(req: NextRequest) { throw new AuthenticationError("Session required"); } - const parsedPartnerId = idSchema.safeParse( - req.nextUrl.searchParams.get("partnerId") - ); + const parsedPartnerId = idSchema + .optional() + .safeParse(req.nextUrl.searchParams.get("partnerId")); if (!parsedPartnerId.success) { throw new ArgumentError("Invalid partnerId"); @@ -70,14 +70,6 @@ export async function GET(req: NextRequest) { } if (partnerId) { - if (Number.isNaN(partnerId)) { - throw new ArgumentError("partnerId must be a number"); - } - - if (partnerId <= 0) { - throw new ArgumentError("partnerId must be a positive number"); - } - const wishlists = await WishlistService.getWishlistsByPartner(partnerId); return NextResponse.json(wishlists, { status: 200, From 6f5917d8ac31955aa42ea2027182e29cd40ec5e0 Mon Sep 17 00:00:00 2001 From: Renato Dell'Osso Date: Sun, 7 Sep 2025 20:31:25 -0400 Subject: [PATCH 24/32] Use isPartner in GET --- src/app/api/wishlists/route.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/api/wishlists/route.ts b/src/app/api/wishlists/route.ts index 256bec73..8a232a5e 100644 --- a/src/app/api/wishlists/route.ts +++ b/src/app/api/wishlists/route.ts @@ -1,4 +1,5 @@ import { auth } from "@/auth"; +import { isPartner } from "@/lib/userUtils"; import { createWishlistSchema, idSchema } from "@/schema/wishlist"; import UserService from "@/services/userService"; import { WishlistService } from "@/services/wishlistService"; @@ -59,7 +60,7 @@ export async function GET(req: NextRequest) { let partnerId = parsedPartnerId.data; // Only let partners see their own wishlists. Default partnerId to their own - if (session.user.type === $Enums.UserType.PARTNER) { + if (UserService.isPartner(session.user)) { if (partnerId && partnerId !== Number(session.user.id)) { throw new AuthenticationError( "Cannot access wishlists for other partners" From 0f9492765a6b71444db36fecfbb3262e6fe3c514 Mon Sep 17 00:00:00 2001 From: Renato Dell'Osso Date: Sun, 7 Sep 2025 20:31:32 -0400 Subject: [PATCH 25/32] Remove unneeded import --- src/app/api/wishlists/route.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/api/wishlists/route.ts b/src/app/api/wishlists/route.ts index 8a232a5e..2e713905 100644 --- a/src/app/api/wishlists/route.ts +++ b/src/app/api/wishlists/route.ts @@ -1,5 +1,4 @@ import { auth } from "@/auth"; -import { isPartner } from "@/lib/userUtils"; import { createWishlistSchema, idSchema } from "@/schema/wishlist"; import UserService from "@/services/userService"; import { WishlistService } from "@/services/wishlistService"; From 5d3f1c746e045ecfcf7e14bbddc0f81b64746237 Mon Sep 17 00:00:00 2001 From: Renato Dell'Osso Date: Sun, 7 Sep 2025 20:42:44 -0400 Subject: [PATCH 26/32] Misc fixes --- src/app/api/wishlists/route.ts | 4 +++- src/schema/wishlist.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/app/api/wishlists/route.ts b/src/app/api/wishlists/route.ts index 2e713905..7e93cff1 100644 --- a/src/app/api/wishlists/route.ts +++ b/src/app/api/wishlists/route.ts @@ -17,7 +17,7 @@ export async function POST(req: NextRequest) { const session = await auth(); if (!UserService.isPartner(session?.user)) { - throw new ArgumentError("Only partners can create wishlist items"); + throw new ArgumentError("Must be PARTNER"); } const body = await req.json(); @@ -51,9 +51,11 @@ export async function GET(req: NextRequest) { const parsedPartnerId = idSchema .optional() + .nullable() .safeParse(req.nextUrl.searchParams.get("partnerId")); if (!parsedPartnerId.success) { + console.log(parsedPartnerId.error); throw new ArgumentError("Invalid partnerId"); } let partnerId = parsedPartnerId.data; diff --git a/src/schema/wishlist.ts b/src/schema/wishlist.ts index e5772229..e93000d6 100644 --- a/src/schema/wishlist.ts +++ b/src/schema/wishlist.ts @@ -30,4 +30,4 @@ export const updateWishlistSchema: ZodObject< ), }); -export const idSchema = z.number().min(1); +export const idSchema = z.coerce.number().int().positive(); From 7ce38eb50a7142650ced803fbef163edbd6fab9f Mon Sep 17 00:00:00 2001 From: Renato Dell'Osso Date: Sun, 7 Sep 2025 20:44:40 -0400 Subject: [PATCH 27/32] Fix per wishlist endpoints --- src/app/api/wishlists/[wishlistId]/route.ts | 4 ++-- src/app/api/wishlists/route.ts | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/app/api/wishlists/[wishlistId]/route.ts b/src/app/api/wishlists/[wishlistId]/route.ts index 4404c5a9..ce9918fb 100644 --- a/src/app/api/wishlists/[wishlistId]/route.ts +++ b/src/app/api/wishlists/[wishlistId]/route.ts @@ -21,7 +21,7 @@ export async function PATCH( throw new ArgumentError("Must be PARTNER"); } - const parsedId = idSchema.safeParse(await params); + const parsedId = idSchema.safeParse((await params).wishlistId); if (!parsedId.success) { throw new ArgumentError("Invalid wishlist ID"); } @@ -67,7 +67,7 @@ export async function DELETE( throw new ArgumentError("Must be PARTNER"); } - const parsedId = idSchema.safeParse(await params); + const parsedId = idSchema.safeParse((await params).wishlistId); if (!parsedId.success) { throw new ArgumentError("Invalid wishlist ID"); } diff --git a/src/app/api/wishlists/route.ts b/src/app/api/wishlists/route.ts index 7e93cff1..65f7dba9 100644 --- a/src/app/api/wishlists/route.ts +++ b/src/app/api/wishlists/route.ts @@ -55,7 +55,6 @@ export async function GET(req: NextRequest) { .safeParse(req.nextUrl.searchParams.get("partnerId")); if (!parsedPartnerId.success) { - console.log(parsedPartnerId.error); throw new ArgumentError("Invalid partnerId"); } let partnerId = parsedPartnerId.data; From 842e3b9022fe967501e5277b7965ccbb3833025a Mon Sep 17 00:00:00 2001 From: Renato Dell'Osso Date: Sun, 7 Sep 2025 20:54:42 -0400 Subject: [PATCH 28/32] Use queryRaw for counting wishlists --- src/services/wishlistService.ts | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/services/wishlistService.ts b/src/services/wishlistService.ts index d1912178..5b648f41 100644 --- a/src/services/wishlistService.ts +++ b/src/services/wishlistService.ts @@ -61,12 +61,17 @@ export class WishlistService { } static async getWishlistsStatsByPartner() { - const wishlistPromise = db.wishlist.groupBy({ - by: ["partnerId", "priority"], - _count: { - id: true, - }, - }); + const wishlistPromise = db.$queryRaw< + { + partnerId: number; + priority: $Enums.RequestPriority; + count: bigint; + }[] + >` + SELECT "partnerId", "priority", COUNT("priority") as count + FROM "Wishlist" + GROUP BY "partnerId", "priority" + `; const partnerPromise = db.user.findMany({ where: { @@ -98,16 +103,17 @@ export class WishlistService { } for (const wishlist of wishlists) { - statsByPartner[wishlist.partnerId].totalCount += wishlist._count.id; + const count = Number(wishlist.count); + statsByPartner[wishlist.partnerId].totalCount += count; switch (wishlist.priority) { case $Enums.RequestPriority.LOW: - statsByPartner[wishlist.partnerId].lowCount += wishlist._count.id; + statsByPartner[wishlist.partnerId].lowCount += count; break; case $Enums.RequestPriority.MEDIUM: - statsByPartner[wishlist.partnerId].mediumCount += wishlist._count.id; + statsByPartner[wishlist.partnerId].mediumCount += count; break; case $Enums.RequestPriority.HIGH: - statsByPartner[wishlist.partnerId].highCount += wishlist._count.id; + statsByPartner[wishlist.partnerId].highCount += count; break; } } From e93302ee3658df6721e313d6197f4014a8a5eaaf Mon Sep 17 00:00:00 2001 From: Renato Dell'Osso Date: Sun, 7 Sep 2025 21:08:35 -0400 Subject: [PATCH 29/32] Update src/services/wishlistService.ts Co-authored-by: Sujal Dhakal --- src/services/wishlistService.ts | 79 +++++++++++---------------------- 1 file changed, 25 insertions(+), 54 deletions(-) diff --git a/src/services/wishlistService.ts b/src/services/wishlistService.ts index 5b648f41..5444f607 100644 --- a/src/services/wishlistService.ts +++ b/src/services/wishlistService.ts @@ -61,66 +61,37 @@ export class WishlistService { } static async getWishlistsStatsByPartner() { - const wishlistPromise = db.$queryRaw< + const stats = await db.$queryRaw< { partnerId: number; - priority: $Enums.RequestPriority; - count: bigint; + partnerName: string; + totalCount: bigint; + lowCount: bigint; + mediuCount: bigint; + highCount: bigint; }[] >` - SELECT "partnerId", "priority", COUNT("priority") as count - FROM "Wishlist" - GROUP BY "partnerId", "priority" + SELECT + u.id as "partnerId", + u.name as "partnerName", + COUNT(w.id) as "totalCount", + SUM(CASE WHEN w.priority = 'LOW' THEN 1 ELSE 0 END) as "lowCount", + SUM(CASE WHEN w.priority = 'MEDIUM' THEN 1 ELSE 0 END) as "mediumCount", + SUM(CASE WHEN w.priority = 'HIGH' THEN 1 ELSE 0 END) as "highCount" + FROM "User" u + LEFT JOIN "Wishlist" w ON u.id = w."partnerId" + WHERE u.type = 'PARTNER' + GROUP BY u.id, u.name `; - const partnerPromise = db.user.findMany({ - where: { - type: $Enums.UserType.PARTNER, - }, - select: { - id: true, - name: true, - }, - }); - - const [wishlists, partners] = await Promise.all([ - wishlistPromise, - partnerPromise, - ]); - - const statsByPartner: { - [partnerId: number]: Omit; - } = {}; - - for (const partner of partners) { - statsByPartner[partner.id] = { - partnerName: partner.name, - totalCount: 0, - lowCount: 0, - mediumCount: 0, - highCount: 0, - }; - } - - for (const wishlist of wishlists) { - const count = Number(wishlist.count); - statsByPartner[wishlist.partnerId].totalCount += count; - switch (wishlist.priority) { - case $Enums.RequestPriority.LOW: - statsByPartner[wishlist.partnerId].lowCount += count; - break; - case $Enums.RequestPriority.MEDIUM: - statsByPartner[wishlist.partnerId].mediumCount += count; - break; - case $Enums.RequestPriority.HIGH: - statsByPartner[wishlist.partnerId].highCount += count; - break; - } - } + return stats.map(stat => ({ + partnerId: stat.partnerId, + partnerName: stat.partnerName, + totalCount: Number(stat.totalCount), + lowCount: Number(stat.lowCount), + mediumCount: Number(stat.mediumCount), + highCount: Number(stat.highCount), + })); - return Object.entries(statsByPartner).map(([partnerId, stats]) => ({ - partnerId: Number(partnerId), - ...stats, - })) as WishlistStats[]; } } From fc07b3f28e26ecee6ef05541eff5bc8ede9aa674 Mon Sep 17 00:00:00 2001 From: Renato Dell'Osso Date: Sun, 7 Sep 2025 21:09:44 -0400 Subject: [PATCH 30/32] Fix typo --- src/services/wishlistService.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/services/wishlistService.ts b/src/services/wishlistService.ts index 5444f607..7544d5d6 100644 --- a/src/services/wishlistService.ts +++ b/src/services/wishlistService.ts @@ -2,10 +2,8 @@ import { db } from "@/db"; import { CreateWishlistData, UpdateWishlistData, - WishlistStats, } from "@/types/api/wishlist.types"; import { NotFoundError } from "@/util/errors"; -import { $Enums } from "@prisma/client"; import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; export class WishlistService { @@ -67,7 +65,7 @@ export class WishlistService { partnerName: string; totalCount: bigint; lowCount: bigint; - mediuCount: bigint; + mediumCount: bigint; highCount: bigint; }[] >` From b076184f72f77e36ae42e0b7a53247b20cf998c4 Mon Sep 17 00:00:00 2001 From: Renato Dell'Osso Date: Sun, 7 Sep 2025 21:10:18 -0400 Subject: [PATCH 31/32] Update src/app/api/wishlists/[wishlistId]/route.ts Co-authored-by: Sujal Dhakal --- src/app/api/wishlists/[wishlistId]/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/api/wishlists/[wishlistId]/route.ts b/src/app/api/wishlists/[wishlistId]/route.ts index ce9918fb..cd80dfdb 100644 --- a/src/app/api/wishlists/[wishlistId]/route.ts +++ b/src/app/api/wishlists/[wishlistId]/route.ts @@ -69,7 +69,7 @@ export async function DELETE( const parsedId = idSchema.safeParse((await params).wishlistId); if (!parsedId.success) { - throw new ArgumentError("Invalid wishlist ID"); + throw new ArgumentError(parsedId.error.message); } const wishlistId = parsedId.data; From 4a932bcff1f2305a23e2933ab54f782a50b378da Mon Sep 17 00:00:00 2001 From: Renato Dell'Osso Date: Sun, 7 Sep 2025 21:10:45 -0400 Subject: [PATCH 32/32] Update src/app/api/wishlists/route.ts Co-authored-by: Sujal Dhakal --- src/app/api/wishlists/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/api/wishlists/route.ts b/src/app/api/wishlists/route.ts index 65f7dba9..78a9e8bd 100644 --- a/src/app/api/wishlists/route.ts +++ b/src/app/api/wishlists/route.ts @@ -55,7 +55,7 @@ export async function GET(req: NextRequest) { .safeParse(req.nextUrl.searchParams.get("partnerId")); if (!parsedPartnerId.success) { - throw new ArgumentError("Invalid partnerId"); + throw new ArgumentError(parsedPartnerId.error.message); } let partnerId = parsedPartnerId.data;