From ae4e225bca7b60c6ac86e25e12c04fb9e01885ec Mon Sep 17 00:00:00 2001 From: chrisgoggins <55658109+chrisgoggins@users.noreply.github.com> Date: Thu, 25 Sep 2025 15:35:24 -0400 Subject: [PATCH] feat: add filtering and pagination for GET table routes --- src/app/api/distributions/admin/route.ts | 32 +- .../donorOffers/[donorOfferId]/edit/route.ts | 22 +- .../api/donorOffers/[donorOfferId]/route.ts | 30 +- .../items/[itemId]/lineItems/route.ts | 24 +- .../items/[itemId]/requests/route.ts | 24 +- src/app/api/donorOffers/route.ts | 31 +- src/app/api/items/route.ts | 24 +- .../api/shippingStatus/[partnerId]/route.ts | 22 +- src/app/api/signOffs/[partnerId]/route.ts | 22 +- src/app/api/unallocatedItemRequest/route.ts | 20 +- src/app/api/unallocatedItemRequests/route.ts | 32 +- .../api/unallocatedItems/lineItems/route.ts | 32 +- src/app/api/unallocatedItems/route.ts | 31 +- src/app/api/users/route.ts | 24 +- src/schema/tableParams.ts | 43 +++ src/services/distributionService.ts | 70 +++- src/services/donorOfferService.ts | 268 ++++++++++++--- src/services/itemService.ts | 27 +- src/services/shippingStatusService.ts | 324 ++++++++++++------ src/services/signOffService.ts | 38 +- src/services/unallocatedItemService.ts | 214 +++++++++--- src/services/userService.ts | 30 +- src/types/api/distribution.types.ts | 10 + src/types/api/donorOffer.types.ts | 22 ++ src/types/api/filter.types.ts | 7 + src/types/api/shippingStatus.types.ts | 1 + src/types/api/signOff.types.ts | 5 + src/util/table.ts | 82 +++++ 28 files changed, 1219 insertions(+), 292 deletions(-) create mode 100644 src/schema/tableParams.ts create mode 100644 src/types/api/filter.types.ts create mode 100644 src/util/table.ts diff --git a/src/app/api/distributions/admin/route.ts b/src/app/api/distributions/admin/route.ts index 06b10156..5037bd24 100644 --- a/src/app/api/distributions/admin/route.ts +++ b/src/app/api/distributions/admin/route.ts @@ -1,10 +1,10 @@ import { NextRequest } from "next/server"; import { auth } from "@/auth"; -import { AuthenticationError, AuthorizationError } from "@/util/errors"; -import { errorResponse } from "@/util/errors"; +import { AuthenticationError, AuthorizationError, ArgumentError, errorResponse } from "@/util/errors"; import UserService from "@/services/userService"; import DistributionService from "@/services/distributionService"; import { NextResponse } from "next/server"; +import { tableParamsSchema } from "@/schema/tableParams"; export async function GET(request: NextRequest) { try { @@ -20,13 +20,33 @@ export async function GET(request: NextRequest) { const url = new URL(request.url); const completed = url.searchParams.get("completed"); + const tableParams = tableParamsSchema.safeParse({ + filters: url.searchParams.get("filters"), + page: url.searchParams.get("page"), + pageSize: url.searchParams.get("pageSize"), + }); + + if (!tableParams.success) { + throw new ArgumentError(tableParams.error.message); + } + + const { filters, page, pageSize } = tableParams.data; + if (completed === "true") { - const signoffs = await DistributionService.getCompletedSignOffs(); - return NextResponse.json({ signoffs }); + const signoffs = await DistributionService.getCompletedSignOffs( + filters ?? undefined, + page ?? undefined, + pageSize ?? undefined, + ); + return NextResponse.json(signoffs); } - const partnerAllocationsWithVisibility = await DistributionService.getPartnerAllocationSummaries(); - return NextResponse.json({ data: partnerAllocationsWithVisibility }); + const partnerAllocationsWithVisibility = await DistributionService.getPartnerAllocationSummaries( + filters ?? undefined, + page ?? undefined, + pageSize ?? undefined, + ); + return NextResponse.json(partnerAllocationsWithVisibility); } catch (error) { return errorResponse(error); } diff --git a/src/app/api/donorOffers/[donorOfferId]/edit/route.ts b/src/app/api/donorOffers/[donorOfferId]/edit/route.ts index 43238f01..7823be67 100644 --- a/src/app/api/donorOffers/[donorOfferId]/edit/route.ts +++ b/src/app/api/donorOffers/[donorOfferId]/edit/route.ts @@ -10,6 +10,7 @@ import { AuthorizationError, errorResponse, } from "@/util/errors"; +import { tableParamsSchema } from "@/schema/tableParams"; const paramSchema = z.object({ donorOfferId: z @@ -19,7 +20,7 @@ const paramSchema = z.object({ }); export async function GET( - _: NextRequest, + request: NextRequest, { params }: { params: Promise<{ donorOfferId: string }> } ) { try { @@ -38,7 +39,24 @@ export async function GET( throw new ArgumentError(parsed.error.message); } - const result = await DonorOfferService.getDonorOfferForEdit(parsed.data.donorOfferId); + const tableParams = tableParamsSchema.safeParse({ + filters: request.nextUrl.searchParams.get("filters"), + page: request.nextUrl.searchParams.get("page"), + pageSize: request.nextUrl.searchParams.get("pageSize"), + }); + + if (!tableParams.success) { + throw new ArgumentError(tableParams.error.message); + } + + const { filters, page, pageSize } = tableParams.data; + + const result = await DonorOfferService.getDonorOfferForEdit( + parsed.data.donorOfferId, + filters ?? undefined, + page ?? undefined, + pageSize ?? undefined, + ); return NextResponse.json(result); } catch (error) { return errorResponse(error); diff --git a/src/app/api/donorOffers/[donorOfferId]/route.ts b/src/app/api/donorOffers/[donorOfferId]/route.ts index 046de5bc..6854202e 100644 --- a/src/app/api/donorOffers/[donorOfferId]/route.ts +++ b/src/app/api/donorOffers/[donorOfferId]/route.ts @@ -11,6 +11,7 @@ import { errorResponse, ok, } from "@/util/errors"; +import { tableParamsSchema } from "@/schema/tableParams"; const paramSchema = z.object({ donorOfferId: z @@ -35,7 +36,7 @@ const postBodySchema = z.object({ }); export async function GET( - _: NextRequest, + request: NextRequest, { params }: { params: Promise<{ donorOfferId: string }> } ) { try { @@ -51,12 +52,35 @@ export async function GET( throw new ArgumentError(parsed.error.message); } + const tableParams = tableParamsSchema.safeParse({ + filters: request.nextUrl.searchParams.get("filters"), + page: request.nextUrl.searchParams.get("page"), + pageSize: request.nextUrl.searchParams.get("pageSize"), + }); + + if (!tableParams.success) { + throw new ArgumentError(tableParams.error.message); + } + + const { filters, page, pageSize } = tableParams.data; + let result; if (session.user.type === "PARTNER") { - result = await DonorOfferService.getPartnerDonorOfferDetails(parsed.data.donorOfferId, session.user.id); + result = await DonorOfferService.getPartnerDonorOfferDetails( + parsed.data.donorOfferId, + session.user.id, + filters ?? undefined, + page ?? undefined, + pageSize ?? undefined, + ); } else if (UserService.isStaff(session.user.type)) { - result = await DonorOfferService.getAdminDonorOfferDetails(parsed.data.donorOfferId); + result = await DonorOfferService.getAdminDonorOfferDetails( + parsed.data.donorOfferId, + filters ?? undefined, + page ?? undefined, + pageSize ?? undefined, + ); } else { throw new AuthorizationError("Unauthorized user type"); } diff --git a/src/app/api/donorOffers/items/[itemId]/lineItems/route.ts b/src/app/api/donorOffers/items/[itemId]/lineItems/route.ts index 2621b64b..ac48b7c6 100644 --- a/src/app/api/donorOffers/items/[itemId]/lineItems/route.ts +++ b/src/app/api/donorOffers/items/[itemId]/lineItems/route.ts @@ -10,6 +10,7 @@ import { AuthorizationError, errorResponse, } from "@/util/errors"; +import { tableParamsSchema } from "@/schema/tableParams"; const paramSchema = z.object({ itemId: z @@ -19,7 +20,7 @@ const paramSchema = z.object({ }); export async function GET( - _: NextRequest, + request: NextRequest, { params }: { params: Promise<{ itemId: string }> } ) { try { @@ -39,8 +40,25 @@ export async function GET( throw new ArgumentError(parsed.error.message); } - const result = await DonorOfferService.getItemLineItems(parsed.data.itemId); - + const tableParams = tableParamsSchema.safeParse({ + filters: request.nextUrl.searchParams.get("filters"), + page: request.nextUrl.searchParams.get("page"), + pageSize: request.nextUrl.searchParams.get("pageSize"), + }); + + if (!tableParams.success) { + throw new ArgumentError(tableParams.error.message); + } + + const { filters, page, pageSize } = tableParams.data; + + const result = await DonorOfferService.getItemLineItems( + parsed.data.itemId, + filters ?? undefined, + page ?? undefined, + pageSize ?? undefined, + ); + return Response.json(result); } catch (error) { return errorResponse(error); diff --git a/src/app/api/donorOffers/items/[itemId]/requests/route.ts b/src/app/api/donorOffers/items/[itemId]/requests/route.ts index f1ad6a8e..435bf8c7 100644 --- a/src/app/api/donorOffers/items/[itemId]/requests/route.ts +++ b/src/app/api/donorOffers/items/[itemId]/requests/route.ts @@ -10,6 +10,7 @@ import { AuthorizationError, errorResponse, } from "@/util/errors"; +import { tableParamsSchema } from "@/schema/tableParams"; const paramSchema = z.object({ itemId: z @@ -19,7 +20,7 @@ const paramSchema = z.object({ }); export async function GET( - _: NextRequest, + request: NextRequest, { params }: { params: Promise<{ itemId: string }> } ) { try { @@ -39,8 +40,25 @@ export async function GET( throw new ArgumentError(parsed.error.message); } - const result = await DonorOfferService.getItemRequests(parsed.data.itemId); - + const tableParams = tableParamsSchema.safeParse({ + filters: request.nextUrl.searchParams.get("filters"), + page: request.nextUrl.searchParams.get("page"), + pageSize: request.nextUrl.searchParams.get("pageSize"), + }); + + if (!tableParams.success) { + throw new ArgumentError(tableParams.error.message); + } + + const { filters, page, pageSize } = tableParams.data; + + const result = await DonorOfferService.getItemRequests( + parsed.data.itemId, + filters ?? undefined, + page ?? undefined, + pageSize ?? undefined, + ); + return Response.json(result); } catch (error) { return errorResponse(error); diff --git a/src/app/api/donorOffers/route.ts b/src/app/api/donorOffers/route.ts index 7f629cb8..23fa6361 100644 --- a/src/app/api/donorOffers/route.ts +++ b/src/app/api/donorOffers/route.ts @@ -1,4 +1,4 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { auth } from "@/auth"; import DonorOfferService from "@/services/donorOfferService"; @@ -6,22 +6,45 @@ import UserService from "@/services/userService"; import { AuthenticationError, AuthorizationError, + ArgumentError, errorResponse, } from "@/util/errors"; +import { tableParamsSchema } from "@/schema/tableParams"; -export async function GET() { +export async function GET(request: NextRequest) { try { const session = await auth(); if (!session?.user) { throw new AuthenticationError("Session required"); } + const parsed = tableParamsSchema.safeParse({ + filters: request.nextUrl.searchParams.get("filters"), + page: request.nextUrl.searchParams.get("page"), + pageSize: request.nextUrl.searchParams.get("pageSize"), + }); + + if (!parsed.success) { + throw new ArgumentError(parsed.error.message); + } + + const { filters, page, pageSize } = parsed.data; + let result; if (session.user.type === "PARTNER") { - result = await DonorOfferService.getPartnerDonorOffers(parseInt(session.user.id)); + result = await DonorOfferService.getPartnerDonorOffers( + parseInt(session.user.id), + filters ?? undefined, + page ?? undefined, + pageSize ?? undefined, + ); } else if (UserService.isStaff(session.user.type)) { - result = await DonorOfferService.getAdminDonorOffers(); + result = await DonorOfferService.getAdminDonorOffers( + filters ?? undefined, + page ?? undefined, + pageSize ?? undefined, + ); } else { throw new AuthorizationError("Must be STAFF, ADMIN, or SUPER_ADMIN"); } diff --git a/src/app/api/items/route.ts b/src/app/api/items/route.ts index 2a31a456..be5829d1 100644 --- a/src/app/api/items/route.ts +++ b/src/app/api/items/route.ts @@ -5,6 +5,7 @@ import { AuthenticationError, AuthorizationError, ArgumentError } from "@/util/e import { NextResponse, NextRequest } from "next/server"; import { ItemFormSchema } from "@/schema/itemForm"; import UserService from "@/services/userService"; +import { tableParamsSchema } from "@/schema/tableParams"; export async function POST(request: NextRequest): Promise { try { @@ -31,7 +32,7 @@ export async function POST(request: NextRequest): Promise { } } -export async function GET(): Promise { +export async function GET(request: NextRequest): Promise { try { const session = await auth(); if (!session?.user) { @@ -42,8 +43,25 @@ export async function GET(): Promise { throw new AuthorizationError("Must be ADMIN or SUPER_ADMIN"); } - const items = await ItemService.getAllItems(); - return NextResponse.json(items, { status: 200 }); + const parsed = tableParamsSchema.safeParse({ + filters: request.nextUrl.searchParams.get("filters"), + page: request.nextUrl.searchParams.get("page"), + pageSize: request.nextUrl.searchParams.get("pageSize"), + includeInvites: request.nextUrl.searchParams.get("includeInvites"), + }); + + if (!parsed.success) { + throw new ArgumentError(parsed.error.message); + } + + const { filters, page, pageSize } = parsed.data; + + const { items, total } = await ItemService.getAllItems( + filters ?? undefined, + page ?? undefined, + pageSize ?? undefined + ); + return NextResponse.json({items, total}, { status: 200 }); } catch (error) { return errorResponse(error); } diff --git a/src/app/api/shippingStatus/[partnerId]/route.ts b/src/app/api/shippingStatus/[partnerId]/route.ts index 4ee8d1f7..2fa98769 100644 --- a/src/app/api/shippingStatus/[partnerId]/route.ts +++ b/src/app/api/shippingStatus/[partnerId]/route.ts @@ -6,6 +6,7 @@ import { ShipmentStatus } from "@prisma/client"; import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import UserService from "@/services/userService"; +import { tableParamsSchema } from "@/schema/tableParams"; const updateShippingStatusSchema = z.object({ donorShippingNumber: z.string(), @@ -14,7 +15,7 @@ const updateShippingStatusSchema = z.object({ }); export async function GET( - _: NextRequest, + request: NextRequest, { params }: { params: Promise<{ partnerId: string }> } ) { try { @@ -32,7 +33,24 @@ export async function GET( throw new ArgumentError("Invalid partner ID"); } - const result = await ShippingStatusService.getPartnerShippingStatuses(partnerId); + const tableParams = tableParamsSchema.safeParse({ + filters: request.nextUrl.searchParams.get("filters"), + page: request.nextUrl.searchParams.get("page"), + pageSize: request.nextUrl.searchParams.get("pageSize"), + }); + + if (!tableParams.success) { + throw new ArgumentError(tableParams.error.message); + } + + const { filters, page, pageSize } = tableParams.data; + + const result = await ShippingStatusService.getPartnerShippingStatuses( + partnerId, + filters ?? undefined, + page ?? undefined, + pageSize ?? undefined, + ); return NextResponse.json(result, { status: 200 }); } catch (error) { diff --git a/src/app/api/signOffs/[partnerId]/route.ts b/src/app/api/signOffs/[partnerId]/route.ts index a28f5936..64dec79d 100644 --- a/src/app/api/signOffs/[partnerId]/route.ts +++ b/src/app/api/signOffs/[partnerId]/route.ts @@ -5,6 +5,7 @@ import { AuthenticationError, AuthorizationError, ArgumentError } from "@/util/e import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import UserService from "@/services/userService"; +import { tableParamsSchema } from "@/schema/tableParams"; const paramSchema = z.object({ partnerId: z.string().transform((val) => { @@ -17,7 +18,7 @@ const paramSchema = z.object({ }); export async function GET( - _: NextRequest, + request: NextRequest, { params }: { params: Promise<{ partnerId: string }> } ) { try { @@ -37,7 +38,24 @@ export async function GET( throw new ArgumentError(parsed.error.message); } - const signOffs = await SignOffService.getSignOffsByPartner(parsed.data.partnerId); + const tableParams = tableParamsSchema.safeParse({ + filters: request.nextUrl.searchParams.get("filters"), + page: request.nextUrl.searchParams.get("page"), + pageSize: request.nextUrl.searchParams.get("pageSize"), + }); + + if (!tableParams.success) { + throw new ArgumentError(tableParams.error.message); + } + + const { filters, page, pageSize } = tableParams.data; + + const signOffs = await SignOffService.getSignOffsByPartner( + parsed.data.partnerId, + filters ?? undefined, + page ?? undefined, + pageSize ?? undefined, + ); return NextResponse.json(signOffs, { status: 200 }); } catch (error) { diff --git a/src/app/api/unallocatedItemRequest/route.ts b/src/app/api/unallocatedItemRequest/route.ts index 0514156e..184fc2ca 100644 --- a/src/app/api/unallocatedItemRequest/route.ts +++ b/src/app/api/unallocatedItemRequest/route.ts @@ -6,6 +6,7 @@ import { UserType } from "@prisma/client"; import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { RequestPriority } from "@prisma/client"; +import { tableParamsSchema } from "@/schema/tableParams"; const createMultipleRequestsSchema = z.array(z.object({ generalItem: z.object({ @@ -49,7 +50,7 @@ export async function POST(req: NextRequest) { } } -export async function GET() { +export async function GET(request: NextRequest) { try { const session = await auth(); if (!session?.user) { @@ -60,8 +61,23 @@ export async function GET() { throw new AuthorizationError("User must be a partner"); } + const tableParams = tableParamsSchema.safeParse({ + filters: request.nextUrl.searchParams.get("filters"), + page: request.nextUrl.searchParams.get("page"), + pageSize: request.nextUrl.searchParams.get("pageSize"), + }); + + if (!tableParams.success) { + throw new ArgumentError(tableParams.error.message); + } + + const { filters, page, pageSize } = tableParams.data; + const requests = await UnallocatedItemService.getPartnerUnallocatedItemRequests( - parseInt(session.user.id) + parseInt(session.user.id), + filters ?? undefined, + page ?? undefined, + pageSize ?? undefined, ); return NextResponse.json(requests, { status: 200 }); diff --git a/src/app/api/unallocatedItemRequests/route.ts b/src/app/api/unallocatedItemRequests/route.ts index 8e2246b6..383ef128 100644 --- a/src/app/api/unallocatedItemRequests/route.ts +++ b/src/app/api/unallocatedItemRequests/route.ts @@ -5,6 +5,7 @@ import UserService from "@/services/userService"; import { AuthenticationError, AuthorizationError, ArgumentError } from "@/util/errors"; import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; +import { tableParamsSchema } from "@/schema/tableParams"; const paramSchema = z.object({ title: z.string(), @@ -36,6 +37,18 @@ export async function GET(req: NextRequest) { } const url = new URL(req.url); + const tableParams = tableParamsSchema.safeParse({ + filters: url.searchParams.get("filters"), + page: url.searchParams.get("page"), + pageSize: url.searchParams.get("pageSize"), + }); + + if (!tableParams.success) { + throw new ArgumentError(tableParams.error.message); + } + + const { filters, page, pageSize } = tableParams.data; + const parsed = paramSchema.safeParse({ title: url.searchParams.get("title"), type: url.searchParams.get("type"), @@ -48,13 +61,18 @@ export async function GET(req: NextRequest) { throw new ArgumentError(parsed.error.message); } - const result = await UnallocatedItemService.getUnallocatedItemRequests({ - title: parsed.data.title, - type: parsed.data.type, - expirationDate: parsed.data.expirationDate, - unitType: parsed.data.unitType, - quantityPerUnit: parsed.data.quantityPerUnit, - }); + const result = await UnallocatedItemService.getUnallocatedItemRequests( + { + title: parsed.data.title, + type: parsed.data.type, + expirationDate: parsed.data.expirationDate, + unitType: parsed.data.unitType, + quantityPerUnit: parsed.data.quantityPerUnit, + }, + filters ?? undefined, + page ?? undefined, + pageSize ?? undefined, + ); return NextResponse.json(result, { status: 200 }); } catch (error) { diff --git a/src/app/api/unallocatedItems/lineItems/route.ts b/src/app/api/unallocatedItems/lineItems/route.ts index 0bd78972..9285c2ee 100644 --- a/src/app/api/unallocatedItems/lineItems/route.ts +++ b/src/app/api/unallocatedItems/lineItems/route.ts @@ -5,6 +5,7 @@ import UserService from "@/services/userService"; import { AuthenticationError, AuthorizationError, ArgumentError } from "@/util/errors"; import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; +import { tableParamsSchema } from "@/schema/tableParams"; const paramSchema = z.object({ title: z.string(), @@ -37,6 +38,18 @@ export async function GET(request: NextRequest) { const params = request.nextUrl.searchParams; + const tableParams = tableParamsSchema.safeParse({ + filters: params.get("filters"), + page: params.get("page"), + pageSize: params.get("pageSize"), + }); + + if (!tableParams.success) { + throw new ArgumentError(tableParams.error.message); + } + + const { filters, page, pageSize } = tableParams.data; + const parsed = paramSchema.safeParse({ title: params.get("title"), type: params.get("type"), @@ -49,13 +62,18 @@ export async function GET(request: NextRequest) { throw new ArgumentError(parsed.error.message); } - const result = await UnallocatedItemService.getLineItems({ - title: parsed.data.title, - type: parsed.data.type, - expirationDate: parsed.data.expirationDate, - unitType: parsed.data.unitType, - quantityPerUnit: parsed.data.quantityPerUnit, - }); + const result = await UnallocatedItemService.getLineItems( + { + title: parsed.data.title, + type: parsed.data.type, + expirationDate: parsed.data.expirationDate, + unitType: parsed.data.unitType, + quantityPerUnit: parsed.data.quantityPerUnit, + }, + filters ?? undefined, + page ?? undefined, + pageSize ?? undefined, + ); return NextResponse.json(result, { status: 200 }); } catch (error) { diff --git a/src/app/api/unallocatedItems/route.ts b/src/app/api/unallocatedItems/route.ts index 31269328..fbdbc8b6 100644 --- a/src/app/api/unallocatedItems/route.ts +++ b/src/app/api/unallocatedItems/route.ts @@ -8,6 +8,7 @@ import { RequestPriority, UserType } from "@prisma/client"; import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { zfd } from "zod-form-data"; +import { tableParamsSchema } from "@/schema/tableParams"; const createUnallocatedItemRequestSchema = zfd.formData({ title: zfd.text(), @@ -28,6 +29,19 @@ export async function GET(request: NextRequest) { } const params = request.nextUrl.searchParams; + + const tableParams = tableParamsSchema.safeParse({ + filters: params.get("filters"), + page: params.get("page"), + pageSize: params.get("pageSize"), + }); + + if (!tableParams.success) { + throw new ArgumentError(tableParams.error.message); + } + + const { filters, page, pageSize } = tableParams.data; + const expirationDateBefore = parseDateIfDefined( params.get("expirationDateBefore") ); @@ -43,12 +57,17 @@ export async function GET(request: NextRequest) { throw new ArgumentError("expirationDateAfter must be a valid ISO-8601 timestamp"); } - const result = await UnallocatedItemService.getUnallocatedItems({ - expirationDateBefore, - expirationDateAfter, - userType: session.user.type, - userId: session.user.id, - }); + const result = await UnallocatedItemService.getUnallocatedItems( + { + expirationDateBefore, + expirationDateAfter, + userType: session.user.type, + userId: session.user.id, + }, + filters ?? undefined, + page ?? undefined, + pageSize ?? undefined, + ); return NextResponse.json(result, { status: 200 }); } catch (error) { diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts index 295ffcf5..83e9811d 100644 --- a/src/app/api/users/route.ts +++ b/src/app/api/users/route.ts @@ -10,13 +10,14 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { zfd } from "zod-form-data"; import UserService from "@/services/userService"; +import { tableParamsSchema } from "@/schema/tableParams"; const createUserSchema = zfd.formData({ inviteToken: zfd.text(), password: zfd.text(), }); -const searchParamsSchema = z.object({ +const includeInvitesParamsSchema = z.object({ includeInvites: z.string().optional().nullable().transform(val => val === "true") }); @@ -32,22 +33,31 @@ export async function GET(request: NextRequest) { throw new AuthorizationError("Must be STAFF, ADMIN, or SUPER_ADMIN"); } - const parsed = searchParamsSchema.safeParse({ - includeInvites: request.nextUrl.searchParams.get("includeInvites") + const parsed = tableParamsSchema.merge(includeInvitesParamsSchema).safeParse({ + filters: request.nextUrl.searchParams.get("filters"), + page: request.nextUrl.searchParams.get("page"), + pageSize: request.nextUrl.searchParams.get("pageSize"), + includeInvites: request.nextUrl.searchParams.get("includeInvites"), }); if (!parsed.success) { throw new ArgumentError(parsed.error.message); } - const users = await UserService.getUsers(); + const { filters, page, pageSize, includeInvites } = parsed.data; - if (parsed.data.includeInvites) { + const { users, total } = await UserService.getUsers( + filters ?? undefined, + page ?? undefined, + pageSize ?? undefined + ); + + if (includeInvites) { const invites = await UserService.getUserInvites(); - return NextResponse.json({ users, invites }, { status: 200 }); + return NextResponse.json({ users, invites, total }, { status: 200 }); } - return NextResponse.json(users, { status: 200 }); + return NextResponse.json({ users, total }, { status: 200 }); } catch (error) { return errorResponse(error); } diff --git a/src/schema/tableParams.ts b/src/schema/tableParams.ts new file mode 100644 index 00000000..7d9c74ec --- /dev/null +++ b/src/schema/tableParams.ts @@ -0,0 +1,43 @@ +import { z } from "zod"; +import type { Filters, FilterValue } from "@/types/api/filter.types"; + +const filterValueSchema: z.ZodType = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("string"), + value: z.string(), + }), + z.object({ + type: z.literal("number"), + gte: z.coerce.number(), + lte: z.coerce.number().optional(), + }), + z.object({ + type: z.literal("date"), + gte: z.string(), + lte: z.string().optional(), + }), + z.object({ + type: z.literal("enum"), + values: z.array(z.string()), + }), +]); + +export const filtersSchema: z.ZodType = z.record(filterValueSchema); + +export const tableParamsSchema = z.object({ + pageSize: z.coerce.number().int().positive().optional().nullable(), + page: z.coerce.number().int().positive().optional().nullable(), + filters: z + .string() + .transform((s) => { + try { + const parsed = JSON.parse(s.trim()); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed; + return z.NEVER; + } catch { + return z.NEVER; + } + }) + .pipe(filtersSchema) + .optional().nullable(), +}); diff --git a/src/services/distributionService.ts b/src/services/distributionService.ts index e5bca9a4..b2ca804c 100644 --- a/src/services/distributionService.ts +++ b/src/services/distributionService.ts @@ -1,5 +1,5 @@ import { db } from "@/db"; -import { UserType, ShipmentStatus } from "@prisma/client"; +import { Prisma, UserType, ShipmentStatus } from "@prisma/client"; import { format } from "date-fns"; import { ArgumentError, NotFoundError } from "@/util/errors"; import { @@ -10,8 +10,12 @@ import { DistributionRecord, AdminDistributionsResult, CompletedSignOff, - PartnerAllocationSummary + PartnerAllocationSummary, + CompletedSignOffResponse, + PartnerAllocationSummaryResponse } from "@/types/api/distribution.types"; +import { Filters } from "@/types/api/filter.types"; +import { buildQueryWithPagination, buildWhereFromFilters } from "@/util/table"; export default class DistributionService { static async getSignedDistributions(partnerId: number): Promise { @@ -224,8 +228,18 @@ export default class DistributionService { return { records }; } - static async getCompletedSignOffs(): Promise { - const signoffs = await db.signOff.findMany({ + static async getCompletedSignOffs( + filters?: Filters, + page?: number, + pageSize?: number, + ): Promise { + const where = buildWhereFromFilters( + Object.keys(Prisma.SignOffScalarFieldEnum), + filters, + ); + + const query: Prisma.SignOffFindManyArgs = { + where, select: { partnerName: true, staffMemberName: true, @@ -237,22 +251,43 @@ export default class DistributionService { }, }, }, - }); + }; + + buildQueryWithPagination(query, page, pageSize); + + const [signoffs, total] = await Promise.all([ + db.signOff.findMany(query), + db.signOff.count({ where }), + ]); - return signoffs.map((signoff) => ({ + const mapped: CompletedSignOff[] = signoffs.map((signoff) => ({ partnerName: signoff.partnerName, staffMemberName: signoff.staffMemberName, date: signoff.date, createdAt: signoff.createdAt, distributionCount: signoff._count.distributions, })); + + return { signoffs: mapped, total }; } - static async getPartnerAllocationSummaries(): Promise { - const usersWithAllocations = await db.user.findMany({ - where: { - type: "PARTNER", - }, + static async getPartnerAllocationSummaries( + filters?: Filters, + page?: number, + pageSize?: number, + ): Promise { + const filterWhere = buildWhereFromFilters( + Object.keys(Prisma.UserScalarFieldEnum), + filters, + ); + + const where: Prisma.UserWhereInput = { + ...filterWhere, + type: "PARTNER", + }; + + const query: Prisma.UserFindManyArgs = { + where, include: { unallocatedItemRequests: { include: { @@ -278,9 +313,16 @@ export default class DistributionService { }, }, }, - }); + }; - return usersWithAllocations.map((user) => { + buildQueryWithPagination(query, page, pageSize); + + const [usersWithAllocations, total] = await Promise.all([ + db.user.findMany(query), + db.user.count({ where }), + ]); + + const mapped: PartnerAllocationSummary[] = usersWithAllocations.map((user) => { const unallocatedRequestAllocations = user.unallocatedItemRequests.flatMap( (request) => request.allocations || [] ); @@ -310,6 +352,8 @@ export default class DistributionService { pendingSignOffCount: user._count.distributions, }; }); + + return { data: mapped, total }; } static async toggleAllocationVisibility(allocType: "unallocated" | "donorOffer", id: number, visible: boolean): Promise { diff --git a/src/services/donorOfferService.ts b/src/services/donorOfferService.ts index 6f89d569..d4c2865d 100644 --- a/src/services/donorOfferService.ts +++ b/src/services/donorOfferService.ts @@ -1,5 +1,5 @@ import { db } from "@/db"; -import { DonorOfferState, UserType, RequestPriority, ItemCategory, Item, DonorOffer, DonorOfferItem, DonorOfferItemRequest } from "@prisma/client"; +import { DonorOfferState, UserType, RequestPriority, ItemCategory, DonorOffer, DonorOfferItem, DonorOfferItemRequest, Prisma } from "@prisma/client"; import { isAfter } from "date-fns"; import { z } from "zod"; import { format } from "date-fns"; @@ -14,8 +14,14 @@ import { UpdateRequestItem, FinalizeDetailsResult, FinalizeDonorOfferResult, - DonorOfferEditDetails + DonorOfferEditDetails, + PartnerDonorOffersResponse, + AdminDonorOffersResponse, + ItemRequestsWithTotal, + DonorOfferItemLineItemsResponse, } from "@/types/api/donorOffer.types"; +import { Filters } from "@/types/api/filter.types"; +import { buildQueryWithPagination, buildWhereFromFilters } from "@/util/table"; const DonorOfferItemSchema = z.object({ title: z.string().min(1, "Title is required"), @@ -121,15 +127,28 @@ type FinalizeDonorOfferItem = z.infer; export default class DonorOfferService { - static async getPartnerDonorOffers(partnerId: number): Promise { - const donorOffers = await db.donorOffer.findMany({ - where: { - partnerVisibilities: { - some: { - partnerId, - }, + static async getPartnerDonorOffers( + partnerId: number, + filters?: Filters, + page?: number, + pageSize?: number, + ): Promise { + const filterWhere = buildWhereFromFilters( + Object.keys(Prisma.DonorOfferScalarFieldEnum), + filters, + ); + + const where: Prisma.DonorOfferWhereInput = { + ...filterWhere, + partnerVisibilities: { + some: { + partnerId, }, }, + }; + + const query: Prisma.DonorOfferFindManyArgs = { + where, include: { items: { include: { @@ -141,9 +160,16 @@ export default class DonorOfferService { }, }, }, - }); + }; + + buildQueryWithPagination(query, page, pageSize); + + const [donorOffers, total] = await Promise.all([ + db.donorOffer.findMany(query), + db.donorOffer.count({ where }), + ]); - return donorOffers.map((offer) => { + const mappedOffers: PartnerDonorOffer[] = donorOffers.map((offer) => { let state = null; if ( @@ -171,10 +197,25 @@ export default class DonorOfferService { state: state, }; }); + + return { + donorOffers: mappedOffers, + total, + }; } - static async getAdminDonorOffers(): Promise { - const donorOffers = await db.donorOffer.findMany({ + static async getAdminDonorOffers( + filters?: Filters, + page?: number, + pageSize?: number, + ): Promise { + const where = buildWhereFromFilters( + Object.keys(Prisma.DonorOfferScalarFieldEnum), + filters, + ); + + const query: Prisma.DonorOfferFindManyArgs = { + where, include: { partnerVisibilities: { include: { @@ -197,9 +238,16 @@ export default class DonorOfferService { }, }, }, - }); + }; + + buildQueryWithPagination(query, page, pageSize); + + const [donorOffers, total] = await Promise.all([ + db.donorOffer.findMany(query), + db.donorOffer.count({ where }), + ]); - return donorOffers.map((offer) => ({ + const mappedOffers: AdminDonorOffer[] = donorOffers.map((offer) => ({ donorOfferId: offer.id, offerName: offer.offerName, donorName: offer.donorName, @@ -212,11 +260,28 @@ export default class DonorOfferService { ), })), })); + + return { donorOffers: mappedOffers, total }; } - static async getItemRequests(itemId: number): Promise { - const requests = await db.donorOfferItemRequest.findMany({ - where: { donorOfferItemId: itemId }, + static async getItemRequests( + itemId: number, + filters?: Filters, + page?: number, + pageSize?: number, + ): Promise { + const filterWhere = buildWhereFromFilters( + Object.keys(Prisma.DonorOfferItemRequestScalarFieldEnum), + filters, + ); + + const where: Prisma.DonorOfferItemRequestWhereInput = { + ...filterWhere, + donorOfferItemId: itemId, + }; + + const query: Prisma.DonorOfferItemRequestFindManyArgs = { + where, include: { donorOfferItem: true, partner: { @@ -225,7 +290,14 @@ export default class DonorOfferService { }, }, }, - }); + }; + + buildQueryWithPagination(query, page, pageSize); + + const [requests, total] = await Promise.all([ + db.donorOfferItemRequest.findMany(query), + db.donorOfferItemRequest.count({ where }), + ]); const allocations = await Promise.all( requests.map(async (request) => { @@ -240,23 +312,42 @@ export default class DonorOfferService { }) ); - return requests.map((request, index) => ({ + const mapped: ItemRequestWithAllocations[] = requests.map((request, index) => ({ ...request, allocations: allocations[index], })); + + return { requests: mapped, total }; } - static async getItemLineItems(itemId: number): Promise { - const donorOfferItem = await db.donorOfferItem.findUnique({ - where: { - id: itemId, - }, - select: { - items: true, - }, - }); + static async getItemLineItems( + itemId: number, + filters?: Filters, + page?: number, + pageSize?: number, + ): Promise { + const filterWhere = buildWhereFromFilters( + Object.keys(Prisma.ItemScalarFieldEnum), + filters, + ); + + const where: Prisma.ItemWhereInput = { + ...filterWhere, + donorOfferItemId: itemId, + }; + + const query: Prisma.ItemFindManyArgs = { + where, + }; + + buildQueryWithPagination(query, page, pageSize); - return donorOfferItem?.items ?? []; + const [items, total] = await Promise.all([ + db.item.findMany(query), + db.item.count({ where }), + ]); + + return { items, total }; } static async createDonorOffer( @@ -374,7 +465,13 @@ export default class DonorOfferService { return { success: true }; } - static async getPartnerDonorOfferDetails(donorOfferId: number, partnerId: string): Promise { + static async getPartnerDonorOfferDetails( + donorOfferId: number, + partnerId: string, + filters?: Filters, + page?: number, + pageSize?: number, + ): Promise { const donorOffer = await db.donorOffer.findUnique({ where: { id: donorOfferId }, }); @@ -383,14 +480,33 @@ export default class DonorOfferService { throw new NotFoundError("Donor offer not found"); } - const donorOfferItemsRequests = ( - await db.donorOfferItem.findMany({ - where: { donorOfferId }, - include: { - requests: { where: { partnerId: parseInt(partnerId) } }, - }, - }) - ).map( + const partnerIdNumber = parseInt(partnerId); + + const filterWhere = buildWhereFromFilters( + Object.keys(Prisma.DonorOfferItemScalarFieldEnum), + filters, + ); + + const where: Prisma.DonorOfferItemWhereInput = { + ...filterWhere, + donorOfferId, + }; + + const query: Prisma.DonorOfferItemFindManyArgs = { + where, + include: { + requests: { where: { partnerId: partnerIdNumber } }, + }, + }; + + buildQueryWithPagination(query, page, pageSize); + + const [items, total] = await Promise.all([ + db.donorOfferItem.findMany(query), + db.donorOfferItem.count({ where }), + ]); + + const donorOfferItemsRequests = items.map( (item) => ({ donorOfferItemId: item.id, @@ -421,10 +537,20 @@ export default class DonorOfferService { return { donorOfferName: donorOffer.offerName, donorOfferItemsRequests, + total, }; } - static async getAdminDonorOfferDetails(donorOfferId: number): Promise<{ donorOffer: DonorOffer; itemsWithRequests: (DonorOfferItem & { requests: (DonorOfferItemRequest & { partner: { name: string } })[] })[] }> { + static async getAdminDonorOfferDetails( + donorOfferId: number, + filters?: Filters, + page?: number, + pageSize?: number, + ): Promise<{ + donorOffer: DonorOffer; + itemsWithRequests: (DonorOfferItem & { requests: (DonorOfferItemRequest & { partner: { name: string } })[] })[]; + total: number; + }> { const donorOffer = await db.donorOffer.findUnique({ where: { id: donorOfferId }, }); @@ -433,14 +559,36 @@ export default class DonorOfferService { throw new NotFoundError("Donor offer not found"); } - const itemsWithRequests = await db.donorOfferItem.findMany({ - where: { donorOfferId }, - include: { requests: { include: { partner: { select: { name: true } } } } }, - }); + const filterWhere = buildWhereFromFilters( + Object.keys(Prisma.DonorOfferItemScalarFieldEnum), + filters, + ); + + const where: Prisma.DonorOfferItemWhereInput = { + ...filterWhere, + donorOfferId, + }; + + const query: Prisma.DonorOfferItemFindManyArgs = { + where, + include: { + requests: { + include: { partner: { select: { name: true } } }, + }, + }, + }; + + buildQueryWithPagination(query, page, pageSize); + + const [itemsWithRequests, total] = await Promise.all([ + db.donorOfferItem.findMany(query), + db.donorOfferItem.count({ where }), + ]); return { donorOffer, itemsWithRequests, + total, }; } @@ -658,11 +806,15 @@ export default class DonorOfferService { return { success: true, donorOfferId, createdCount: validItems.length }; } - static async getDonorOfferForEdit(donorOfferId: number): Promise { + static async getDonorOfferForEdit( + donorOfferId: number, + filters?: Filters, + page?: number, + pageSize?: number, + ): Promise { const donorOffer = await db.donorOffer.findUnique({ where: { id: donorOfferId }, include: { - items: true, partnerVisibilities: { select: { partner: { @@ -675,17 +827,39 @@ export default class DonorOfferService { if (!donorOffer) return null; + const filterWhere = buildWhereFromFilters( + Object.keys(Prisma.DonorOfferItemScalarFieldEnum), + filters, + ); + + const where: Prisma.DonorOfferItemWhereInput = { + ...filterWhere, + donorOfferId, + }; + + const query: Prisma.DonorOfferItemFindManyArgs = { + where, + }; + + buildQueryWithPagination(query, page, pageSize); + + const [items, total] = await Promise.all([ + db.donorOfferItem.findMany(query), + db.donorOfferItem.count({ where }), + ]); + return { id: donorOffer.id, offerName: donorOffer.offerName, donorName: donorOffer.donorName, partnerResponseDeadline: donorOffer.partnerResponseDeadline, donorResponseDeadline: donorOffer.donorResponseDeadline, - items: donorOffer.items, + items, partners: donorOffer.partnerVisibilities.map((pv) => ({ id: pv.partner.id, name: pv.partner.name, })), + total, }; } diff --git a/src/services/itemService.ts b/src/services/itemService.ts index 5131073d..a9ea8414 100644 --- a/src/services/itemService.ts +++ b/src/services/itemService.ts @@ -1,5 +1,5 @@ import { db } from "@/db"; -import { ItemCategory } from "@prisma/client"; +import { ItemCategory, Prisma } from "@prisma/client"; import { z } from "zod"; import * as XLSX from "xlsx"; import Papa from "papaparse"; @@ -9,6 +9,8 @@ import { BulkUploadResult, UnallocatedItemRequestSummary } from "@/types/api/item.types"; +import { buildQueryWithPagination, buildWhereFromFilters } from "@/util/table"; +import { Filters } from "@/types/api/filter.types"; const requiredKeys = [ "title", @@ -103,9 +105,26 @@ export class ItemService { return createdItem; } - static async getAllItems() { - const items = await db.item.findMany(); - return items; + static async getAllItems(filters?: Filters, page?: number, pageSize?: number) { + const where = buildWhereFromFilters( + Object.keys(Prisma.ItemScalarFieldEnum), + filters, + ); + + const query: Prisma.ItemFindManyArgs = { + where + }; + + console.log(query); + + buildQueryWithPagination(query, page, pageSize); + + const [items, total] = await Promise.all([ + db.item.findMany(query), + db.item.count({ where }), + ]); + + return { items, total } } static async getUnallocatedItemRequestsForItem(itemId: number): Promise { diff --git a/src/services/shippingStatusService.ts b/src/services/shippingStatusService.ts index 20ade570..8990ee7e 100644 --- a/src/services/shippingStatusService.ts +++ b/src/services/shippingStatusService.ts @@ -1,147 +1,261 @@ import { db } from "@/db"; -import { Item, ShippingStatus } from "@prisma/client"; +import { Item, Prisma, ShippingStatus } from "@prisma/client"; import { ShippingStatusWithItems, UpdateShippingStatusData, } from "@/types/api/shippingStatus.types"; +import { Filters, FilterValue } from "@/types/api/filter.types"; +import { buildWhereFromFilters } from "@/util/table"; export class ShippingStatusService { - static async getShippingStatuses(): Promise { - const shippingNumberPairs = await db.item.findMany({ - where: { + static async getShippingStatuses( + filters?: Filters, + page?: number, + pageSize?: number, + ): Promise { + const { itemFilters, statusFilters } = this.splitShippingFilters(filters); + + const itemWhereFilters = buildWhereFromFilters( + Object.keys(Prisma.ItemScalarFieldEnum), + itemFilters, + ); + + const clauses: Prisma.ItemWhereInput[] = [ + { donorShippingNumber: { not: null }, hfhShippingNumber: { not: null }, }, - distinct: ["donorShippingNumber", "hfhShippingNumber"], - }); - - const itemMap: Item[][] = []; - const statuses: ShippingStatus[] = []; - let i = 0; - - for (const pair of shippingNumberPairs) { - const donorShippingNumber = pair.donorShippingNumber as string; - const hfhShippingNumber = pair.hfhShippingNumber as string; - - const status = await db.shippingStatus.findFirst({ - where: { - donorShippingNumber: donorShippingNumber, - hfhShippingNumber: hfhShippingNumber, - }, - }); + ]; - if (status) { - statuses.push({ ...status, id: i }); - } else { - statuses.push({ - id: i, - donorShippingNumber: donorShippingNumber, - hfhShippingNumber: hfhShippingNumber, - value: "WAITING_ARRIVAL_FROM_DONOR", - }); - } - - const items = await db.item.findMany({ - where: { - donorShippingNumber: donorShippingNumber, - hfhShippingNumber: hfhShippingNumber, - }, - }); - itemMap.push(items); - i++; + if (Object.keys(itemWhereFilters).length > 0) { + clauses.push(itemWhereFilters); } - return { - shippingStatuses: statuses, - items: itemMap, - }; + return this.buildShippingStatusResponse(clauses, statusFilters, page, pageSize); } static async getPartnerShippingStatuses( - partnerId: number + partnerId: number, + filters?: Filters, + page?: number, + pageSize?: number, ): Promise { - const shippingNumberPairs = await db.item.findMany({ - where: { - AND: [ + const { itemFilters, statusFilters } = this.splitShippingFilters(filters); + + const itemWhereFilters = buildWhereFromFilters( + Object.keys(Prisma.ItemScalarFieldEnum), + itemFilters, + ); + + const clauses: Prisma.ItemWhereInput[] = [ + { + donorShippingNumber: { not: null }, + hfhShippingNumber: { not: null }, + }, + { + OR: [ { - donorShippingNumber: { not: null }, - hfhShippingNumber: { not: null }, + unallocatedItemRequestAllocations: { + some: { + partnerId, + }, + }, }, { - OR: [ - { - unallocatedItemRequestAllocations: { - some: { - partnerId, - }, + unallocatedItemRequestAllocations: { + some: { + unallocatedItemRequest: { + partnerId, }, }, - { - unallocatedItemRequestAllocations: { - some: { - unallocatedItemRequest: { - partnerId, - }, - }, - }, - }, - { - donorOfferItemRequestAllocations: { - some: { - donorOfferItemRequest: { - partnerId, - }, - }, + }, + }, + { + donorOfferItemRequestAllocations: { + some: { + donorOfferItemRequest: { + partnerId, }, }, - ], + }, }, ], }, + ]; + + if (Object.keys(itemWhereFilters).length > 0) { + clauses.push(itemWhereFilters); + } + + return this.buildShippingStatusResponse(clauses, statusFilters, page, pageSize); + } + + private static splitShippingFilters(filters?: Filters): { + itemFilters?: Filters; + statusFilters?: Filters; + } { + if (!filters) { + return {}; + } + + const itemFieldKeys = new Set(Object.keys(Prisma.ItemScalarFieldEnum)); + const itemFilters: Filters = {}; + const statusFilters: Filters = {}; + + for (const [key, value] of Object.entries(filters)) { + if (!value) { + continue; + } + + if (itemFieldKeys.has(key)) { + itemFilters[key] = value; + } else { + statusFilters[key] = value; + } + } + + return { + itemFilters: Object.keys(itemFilters).length ? itemFilters : undefined, + statusFilters: Object.keys(statusFilters).length ? statusFilters : undefined, + }; + } + + private static matchesFilterValue(value: unknown, filter: FilterValue): boolean { + if (value === null || value === undefined) { + return false; + } + + switch (filter.type) { + case "string": { + if (typeof value !== "string") return false; + return value.toLowerCase().includes(filter.value.toLowerCase()); + } + case "enum": { + if (typeof value !== "string") return false; + return filter.values.includes(value); + } + case "number": { + if (typeof value !== "number") return false; + if (value < filter.gte) return false; + if (filter.lte !== undefined && value > filter.lte) return false; + return true; + } + case "date": { + const date = value instanceof Date ? value : new Date(value); + if (isNaN(date.getTime())) return false; + const gteDate = new Date(filter.gte); + if (date < gteDate) return false; + if (filter.lte) { + const lteDate = new Date(filter.lte); + if (date > lteDate) return false; + } + return true; + } + default: + return false; + } + } + + private static async buildShippingStatusResponse( + clauses: Prisma.ItemWhereInput[], + statusFilters?: Filters, + page?: number, + pageSize?: number, + ): Promise { + const where: Prisma.ItemWhereInput = clauses.length > 1 ? { AND: clauses } : clauses[0]; + + const shippingNumberPairs = await db.item.findMany({ + where, distinct: ["donorShippingNumber", "hfhShippingNumber"], + select: { + donorShippingNumber: true, + hfhShippingNumber: true, + }, }); - const itemMap: Item[][] = []; - const statuses: ShippingStatus[] = []; - let i = 0; + const validPairs = shippingNumberPairs.filter( + (pair): pair is { donorShippingNumber: string; hfhShippingNumber: string } => + Boolean(pair.donorShippingNumber && pair.hfhShippingNumber), + ); - for (const pair of shippingNumberPairs) { - const donorShippingNumber = pair.donorShippingNumber as string; - const hfhShippingNumber = pair.hfhShippingNumber as string; + if (validPairs.length === 0) { + return { shippingStatuses: [], items: [], total: 0 }; + } - const status = await db.shippingStatus.findFirst({ - where: { - donorShippingNumber: donorShippingNumber, - hfhShippingNumber: hfhShippingNumber, - }, - }); + const statusRecords = await db.shippingStatus.findMany({ + where: { + OR: validPairs.map((pair) => ({ + donorShippingNumber: pair.donorShippingNumber, + hfhShippingNumber: pair.hfhShippingNumber, + })), + }, + }); + + const statusMap = new Map(); + statusRecords.forEach((status) => { + const key = `${status.donorShippingNumber}|${status.hfhShippingNumber}`; + statusMap.set(key, status); + }); + + const pairsWithStatuses = validPairs.map((pair) => { + const key = `${pair.donorShippingNumber}|${pair.hfhShippingNumber}`; + const status = statusMap.get(key); if (status) { - statuses.push({ ...status, id: i }); - } else { - statuses.push({ - id: i, - donorShippingNumber: donorShippingNumber, - hfhShippingNumber: hfhShippingNumber, - value: "WAITING_ARRIVAL_FROM_DONOR", - }); + return { pair, status }; } - const items = await db.item.findMany({ - where: { - donorShippingNumber: donorShippingNumber, - hfhShippingNumber: hfhShippingNumber, - }, - }); - itemMap.push(items); - i++; + const defaultStatus: ShippingStatus = { + id: 0, + donorShippingNumber: pair.donorShippingNumber, + hfhShippingNumber: pair.hfhShippingNumber, + value: "WAITING_ARRIVAL_FROM_DONOR", + }; + + return { pair, status: defaultStatus }; + }); + + const filteredPairs = statusFilters + ? pairsWithStatuses.filter(({ status }) => + Object.entries(statusFilters).every(([field, filter]) => + this.matchesFilterValue((status as Record)[field], filter), + ), + ) + : pairsWithStatuses; + + const total = filteredPairs.length; + + let offset = 0; + let paginatedPairs = filteredPairs; + if (page && pageSize) { + offset = (page - 1) * pageSize; + paginatedPairs = filteredPairs.slice(offset, offset + pageSize); } + const items: Item[][] = []; + const shippingStatuses: ShippingStatus[] = []; + + await Promise.all( + paginatedPairs.map(async ({ pair, status }, index) => { + const lineItems = await db.item.findMany({ + where: { + donorShippingNumber: pair.donorShippingNumber, + hfhShippingNumber: pair.hfhShippingNumber, + }, + }); + + items[index] = lineItems; + shippingStatuses[index] = { + ...status, + id: offset + index, + }; + }), + ); + return { - shippingStatuses: statuses.filter( - (s) => s.donorShippingNumber && s.hfhShippingNumber - ), - items: itemMap, + shippingStatuses, + items, + total, }; } diff --git a/src/services/signOffService.ts b/src/services/signOffService.ts index 01a0d985..efe25e14 100644 --- a/src/services/signOffService.ts +++ b/src/services/signOffService.ts @@ -1,10 +1,14 @@ import { db } from "@/db"; +import { Prisma } from "@prisma/client"; import { CreateSignOffData, SignOffSummary, SignOffDetails, DistributionItem, + SignOffSummaryResponse, } from "@/types/api/signOff.types"; +import { Filters } from "@/types/api/filter.types"; +import { buildQueryWithPagination, buildWhereFromFilters } from "@/util/table"; export class SignOffService { static async createSignOff(data: CreateSignOffData) { @@ -33,9 +37,24 @@ export class SignOffService { }); } - static async getSignOffsByPartner(partnerId: number): Promise { - const signOffs = await db.signOff.findMany({ - where: { partnerId }, + static async getSignOffsByPartner( + partnerId: number, + filters?: Filters, + page?: number, + pageSize?: number, + ): Promise { + const filterWhere = buildWhereFromFilters( + Object.keys(Prisma.SignOffScalarFieldEnum), + filters, + ); + + const where: Prisma.SignOffWhereInput = { + ...filterWhere, + partnerId, + }; + + const query: Prisma.SignOffFindManyArgs = { + where, include: { distributions: true, _count: { @@ -44,15 +63,24 @@ export class SignOffService { }, }, }, - }); + }; + + buildQueryWithPagination(query, page, pageSize); - return signOffs.map((signOff) => ({ + const [signOffs, total] = await Promise.all([ + db.signOff.findMany(query), + db.signOff.count({ where }), + ]); + + const mapped: SignOffSummary[] = signOffs.map((signOff) => ({ staffName: signOff.staffMemberName, numberOfItems: signOff._count.distributions, dateCreated: signOff.createdAt, signOffDate: signOff.createdAt, status: "-", })); + + return { signOffs: mapped, total }; } static async getSignOffById(signOffId: number): Promise { diff --git a/src/services/unallocatedItemService.ts b/src/services/unallocatedItemService.ts index 42d7a7bd..d3b99156 100644 --- a/src/services/unallocatedItemService.ts +++ b/src/services/unallocatedItemService.ts @@ -1,6 +1,6 @@ import { db } from "@/db"; import { NotFoundError } from "@/util/errors"; -import { UserType } from "@prisma/client"; +import { Prisma, UserType } from "@prisma/client"; import { isEqual } from "date-fns"; import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; import { @@ -11,9 +11,16 @@ import { UnallocatedItem, CreateMultipleUnallocatedItemRequestsData } from "@/types/api/unallocatedItem.types"; +import { Filters } from "@/types/api/filter.types"; +import { buildQueryWithPagination, buildWhereFromFilters } from "@/util/table"; export class UnallocatedItemService { - static async getUnallocatedItems(params: GetUnallocatedItemsParams) { + static async getUnallocatedItems( + params: GetUnallocatedItemsParams, + filters?: Filters, + page?: number, + pageSize?: number, + ) { const scopeVisibility = params.userType === UserType.PARTNER ? { visible: true } : {}; @@ -37,30 +44,59 @@ export class UnallocatedItemService { }, }); - const tableItems = ( - await db.item.groupBy({ - by: ["title", "type", "expirationDate", "unitType", "quantityPerUnit"], - _sum: { - quantity: true, - }, - where: { - ...scopeVisibility, - ...(params.expirationDateAfter && !params.expirationDateBefore - ? { - OR: [ - { expirationDate: { gt: params.expirationDateAfter } }, - { expirationDate: null }, - ], - } - : { - expirationDate: { - ...(params.expirationDateAfter && { gt: params.expirationDateAfter }), - ...(params.expirationDateBefore && { lt: params.expirationDateBefore }), - }, - }), - }, - }) - ).map((item) => { + const itemFilters = buildWhereFromFilters( + Object.keys(Prisma.ItemScalarFieldEnum), + filters, + ); + + const expirationFilter = params.expirationDateAfter && !params.expirationDateBefore + ? { + OR: [ + { expirationDate: { gt: params.expirationDateAfter } }, + { expirationDate: null }, + ], + } + : { + expirationDate: { + ...(params.expirationDateAfter && { gt: params.expirationDateAfter }), + ...(params.expirationDateBefore && { lt: params.expirationDateBefore }), + }, + }; + + const where: Prisma.ItemWhereInput = { + ...scopeVisibility, + ...itemFilters, + ...expirationFilter, + }; + + const groupByFields: (keyof Prisma.ItemGroupByOutputType)[] = [ + "title", + "type", + "expirationDate", + "unitType", + "quantityPerUnit", + ]; + + const query: Prisma.ItemGroupByArgs = { + by: groupByFields as string[], + _sum: { + quantity: true, + }, + where, + }; + + buildQueryWithPagination(query as unknown as Record, page, pageSize); + + const [groupedItems, totalGroups] = await Promise.all([ + db.item.groupBy(query), + db.item.groupBy({ + by: groupByFields as string[], + _sum: { quantity: true }, + where, + }), + ]); + + const tableItems = groupedItems.map((item) => { const requestedItem = uniqueUnallocatedItemRequest.find( (unallocatedItem) => { return ( @@ -84,11 +120,11 @@ export class UnallocatedItemService { }; delete copy._sum; return copy; - }); + }); - const donorNames = await db.item.findMany({ - distinct: "donorName", - select: { + const donorNames = await db.item.findMany({ + distinct: "donorName", + select: { donorName: true, }, orderBy: { @@ -121,6 +157,7 @@ export class UnallocatedItemService { unitTypes: unitTypes.map((item) => item.unitType), donorNames: donorNames.map((item) => item.donorName), itemTypes: itemTypes.map((item) => item.type), + total: totalGroups.length, }; } @@ -162,9 +199,24 @@ export class UnallocatedItemService { }); } - static async getPartnerUnallocatedItemRequests(partnerId: number) { - const requests = await db.unallocatedItemRequest.findMany({ - where: { partnerId }, + static async getPartnerUnallocatedItemRequests( + partnerId: number, + filters?: Filters, + page?: number, + pageSize?: number, + ) { + const filterWhere = buildWhereFromFilters( + Object.keys(Prisma.UnallocatedItemRequestScalarFieldEnum), + filters, + ); + + const where: Prisma.UnallocatedItemRequestWhereInput = { + ...filterWhere, + partnerId, + }; + + const query: Prisma.UnallocatedItemRequestFindManyArgs = { + where, select: { id: true, title: true, @@ -179,27 +231,56 @@ export class UnallocatedItemService { orderBy: { id: "asc", }, - }); + }; + + buildQueryWithPagination(query, page, pageSize); - return requests.map((req) => ({ + const [requests, total] = await Promise.all([ + db.unallocatedItemRequest.findMany(query), + db.unallocatedItemRequest.count({ where }), + ]); + + const mappedRequests = requests.map((req) => ({ ...req, expirationDate: req.expirationDate?.toLocaleDateString(), createdAt: req.createdAt.toLocaleDateString(), })); + + return { requests: mappedRequests, total }; } - static async getLineItems(params: GetLineItemsParams) { - const items = await db.item.findMany({ - where: { - title: params.title, - type: params.type, - expirationDate: params.expirationDate, - unitType: params.unitType, - quantityPerUnit: params.quantityPerUnit, - }, - }); + static async getLineItems( + params: GetLineItemsParams, + filters?: Filters, + page?: number, + pageSize?: number, + ) { + const filterWhere = buildWhereFromFilters( + Object.keys(Prisma.ItemScalarFieldEnum), + filters, + ); + + const where: Prisma.ItemWhereInput = { + ...filterWhere, + title: params.title, + type: params.type, + expirationDate: params.expirationDate, + unitType: params.unitType, + quantityPerUnit: params.quantityPerUnit, + }; + + const query: Prisma.ItemFindManyArgs = { + where, + }; + + buildQueryWithPagination(query, page, pageSize); + + const [items, total] = await Promise.all([ + db.item.findMany(query), + db.item.count({ where }), + ]); - return { items }; + return { items, total }; } static async deleteItem(itemId: number) { @@ -215,15 +296,28 @@ export class UnallocatedItemService { } } - static async getUnallocatedItemRequests(params: GetUnallocatedItemRequestsParams) { - const requests = await db.unallocatedItemRequest.findMany({ - where: { - title: params.title, - type: params.type, - expirationDate: params.expirationDate, - unitType: params.unitType, - quantityPerUnit: params.quantityPerUnit, - }, + static async getUnallocatedItemRequests( + params: GetUnallocatedItemRequestsParams, + filters?: Filters, + page?: number, + pageSize?: number, + ) { + const filterWhere = buildWhereFromFilters( + Object.keys(Prisma.UnallocatedItemRequestScalarFieldEnum), + filters, + ); + + const where: Prisma.UnallocatedItemRequestWhereInput = { + ...filterWhere, + title: params.title, + type: params.type, + expirationDate: params.expirationDate, + unitType: params.unitType, + quantityPerUnit: params.quantityPerUnit, + }; + + const query: Prisma.UnallocatedItemRequestFindManyArgs = { + where, include: { partner: { select: { @@ -231,7 +325,14 @@ export class UnallocatedItemService { }, }, }, - }); + }; + + buildQueryWithPagination(query, page, pageSize); + + const [requests, total] = await Promise.all([ + db.unallocatedItemRequest.findMany(query), + db.unallocatedItemRequest.count({ where }), + ]); const allocations = await Promise.all( requests.map(async (request) => { @@ -280,6 +381,7 @@ export class UnallocatedItemService { allocations: allocations[index], })), items: modifiedItems, + total, }; } } diff --git a/src/services/userService.ts b/src/services/userService.ts index 95fdddd3..40bc827f 100644 --- a/src/services/userService.ts +++ b/src/services/userService.ts @@ -1,5 +1,5 @@ import { db } from "@/db"; -import { UserType } from "@prisma/client"; +import { Prisma, UserType } from "@prisma/client"; import { ArgumentError, NotFoundError, ConflictError } from "@/util/errors"; import * as argon2 from "argon2"; import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; @@ -10,10 +10,22 @@ import { CreateUserInviteData, UpdateUserData, } from "@/types/api/user.types"; +import { Filters } from "@/types/api/filter.types"; +import { + buildQueryWithPagination, + buildWhereFromFilters, +} from "@/util/table"; export default class UserService { - static async getUsers() { - const users = await db.user.findMany({ + static async getUsers(filters?: Filters, page?: number, pageSize?: number) { + + const where = buildWhereFromFilters( + Object.keys(Prisma.UserScalarFieldEnum), + filters, + ); + + const query: Prisma.UserFindManyArgs = { + where, select: { id: true, email: true, @@ -22,8 +34,16 @@ export default class UserService { tag: true, enabled: true, }, - }); - return users; + }; + + buildQueryWithPagination(query, page, pageSize); + + const [users, total] = await Promise.all([ + db.user.findMany(query), + db.user.count({ where }), + ]); + + return { users, total }; } static async getUserById(userId: number) { diff --git a/src/types/api/distribution.types.ts b/src/types/api/distribution.types.ts index 41813574..4e05c8f6 100644 --- a/src/types/api/distribution.types.ts +++ b/src/types/api/distribution.types.ts @@ -69,6 +69,11 @@ export interface CompletedSignOff { distributionCount: number; } +export interface CompletedSignOffResponse { + signoffs: CompletedSignOff[]; + total: number; +} + export interface PartnerAllocationSummary { partnerId: number; partnerName: string; @@ -77,6 +82,11 @@ export interface PartnerAllocationSummary { pendingSignOffCount: number; } +export interface PartnerAllocationSummaryResponse { + data: PartnerAllocationSummary[]; + total: number; +} + export interface SignOff { staffMemberName: string; partnerName: string; diff --git a/src/types/api/donorOffer.types.ts b/src/types/api/donorOffer.types.ts index daa940c7..ef6214e3 100644 --- a/src/types/api/donorOffer.types.ts +++ b/src/types/api/donorOffer.types.ts @@ -29,6 +29,16 @@ export interface AdminDonorOffer { }[]; } +export interface PartnerDonorOffersResponse { + donorOffers: PartnerDonorOffer[]; + total: number; +} + +export interface AdminDonorOffersResponse { + donorOffers: AdminDonorOffer[]; + total: number; +} + export interface ItemRequestWithAllocations { id: number; donorOfferItemId: number; @@ -50,6 +60,16 @@ export interface ItemRequestWithAllocations { }[]; } +export interface ItemRequestsWithTotal { + requests: ItemRequestWithAllocations[]; + total: number; +} + +export interface DonorOfferItemLineItemsResponse { + items: Item[]; + total: number; +} + export interface CreateDonorOfferResult { success: boolean; errors?: string[]; @@ -79,6 +99,7 @@ export interface DonorOfferItemsRequestsDTO { export interface DonorOfferItemsRequestsResponse { donorOfferName: string; donorOfferItemsRequests: DonorOfferItemsRequestsDTO[]; + total: number; } export interface UpdateRequestItem { @@ -112,4 +133,5 @@ export interface DonorOfferEditDetails { donorResponseDeadline: Date; items: DonorOfferItem[]; partners: { id: number; name: string }[]; + total: number; } diff --git a/src/types/api/filter.types.ts b/src/types/api/filter.types.ts new file mode 100644 index 00000000..f3a2ab15 --- /dev/null +++ b/src/types/api/filter.types.ts @@ -0,0 +1,7 @@ +export type FilterValue = + | { type: "string"; value: string } + | { type: "number"; gte: number; lte?: number } + | { type: "date"; gte: string; lte?: string } // ISO strings + | { type: "enum"; values: string[] }; + +export type Filters = Partial> \ No newline at end of file diff --git a/src/types/api/shippingStatus.types.ts b/src/types/api/shippingStatus.types.ts index eece8fab..70997719 100644 --- a/src/types/api/shippingStatus.types.ts +++ b/src/types/api/shippingStatus.types.ts @@ -3,6 +3,7 @@ import { Item, ShippingStatus, ShipmentStatus } from "@prisma/client"; export interface ShippingStatusWithItems { shippingStatuses: ShippingStatus[]; items: Item[][]; + total: number; } export interface UpdateShippingStatusData { diff --git a/src/types/api/signOff.types.ts b/src/types/api/signOff.types.ts index 63d785f8..d712d492 100644 --- a/src/types/api/signOff.types.ts +++ b/src/types/api/signOff.types.ts @@ -21,6 +21,11 @@ export interface SignOffSummary { status: string; } +export interface SignOffSummaryResponse { + signOffs: SignOffSummary[]; + total: number; +} + export interface SignOffDetails { id: number; date: Date; diff --git a/src/util/table.ts b/src/util/table.ts new file mode 100644 index 00000000..ce70f7cc --- /dev/null +++ b/src/util/table.ts @@ -0,0 +1,82 @@ +import type { Filters, FilterValue } from "@/types/api/filter.types"; + +export type FilterFieldHandler = ( + where: TWhere, + filter: FilterValue +) => void; + +export type FilterFieldHandlers = Record>; + +export function buildWhereFromFilters>( + keys: string[], + filters?: Filters, +){ + const where: TWhere = {} as TWhere; + + if (!filters) { + return where; + } + + for (const key of keys) { + const filter = filters[key]; + + if (!filter) { + continue; + } + + let condition; + switch (filter.type) { + case "string": { + condition = { + contains: filter.value, + mode: "insensitive" + }; + break; + } + case "enum": { + condition = { + + in: filter.values, + }; + break; + } + case "number": { + const numberCondition: { gte: number; lte?: number; } = { + gte: filter.gte + }; + if (filter.lte !== undefined) { + numberCondition.lte = filter.lte; + } + condition = numberCondition + break; + } + case "date": { + const dateCondition: { gte: string; lte?: string; } = { + gte: filter.gte + }; + if (filter.lte !== undefined) { + dateCondition.lte = filter.lte; + } + condition = dateCondition + break; + } + } + + (where as Record)[key] = condition; + } + + return where; +}; + +export function buildQueryWithPagination>( + query: Query, + page?: number, + pageSize?: number +){ + if (!page || !pageSize) { + return query; + } + + (query as Record)["take"] = pageSize; + (query as Record)["skip"] = (page - 1) * pageSize; +}; \ No newline at end of file