From b82c503c2a1aff73e1a30d4e2b2e1284b89ddb33 Mon Sep 17 00:00:00 2001 From: davdwan21 <141950513+davdwan21@users.noreply.github.com> Date: Sat, 14 Feb 2026 11:32:45 -0800 Subject: [PATCH 1/6] Added general services for GET all and POST + Created given folder structure, unsure about use case + Created items.ts in services for generic services similar to the demo + route.ts mimics demo routing for cleaner API management + multiple schemas to enforce typing in items Next steps: - Create id specific routes for GET, PUT, DELETE by id --- .prettierrc | 10 +++++ .vscode/settings.json | 11 ++++++ app/api/demo/route.ts | 29 ++++++++++++--- app/api/inventory/[id]/route.ts | 0 app/api/inventory/route.ts | 43 ++++++++++++++++++++++ models/Item.ts | 65 +++++++++++++++++++++------------ services/demo.ts | 40 ++++++++++---------- services/inventory/package.json | 0 services/inventory/src/index.ts | 5 +++ services/items.ts | 50 +++++++++++++++++++++++++ 10 files changed, 203 insertions(+), 50 deletions(-) create mode 100644 .prettierrc create mode 100644 .vscode/settings.json create mode 100644 app/api/inventory/[id]/route.ts create mode 100644 app/api/inventory/route.ts create mode 100644 services/inventory/package.json create mode 100644 services/inventory/src/index.ts create mode 100644 services/items.ts diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..430559f --- /dev/null +++ b/.prettierrc @@ -0,0 +1,10 @@ +{ + "tabWidth": 4, + "useTabs": false, + "semi": true, + "singleQuote": false, + "quoteProps": "as-needed", + "trailingComma": "es5", + "bracketSpacing": true, + "arrowParens": "avoid" + } \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..c1484af --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "editor.tabSize": 4, + "editor.insertSpaces": true, + "editor.detectIndentation": false, + "[typescript]": { + "editor.tabSize": 4 + }, + "[typescriptreact]": { + "editor.tabSize": 4 + } +} \ No newline at end of file diff --git a/app/api/demo/route.ts b/app/api/demo/route.ts index 8255b37..23a2a05 100644 --- a/app/api/demo/route.ts +++ b/app/api/demo/route.ts @@ -4,9 +4,17 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { statusValues } from "@/models/Product"; -import { addProduct, deleteProduct, getProduct, getProducts, updateProduct } from "@/services/demo"; +import { + addProduct, + deleteProduct, + getProduct, + getProducts, + updateProduct, +} from "@/services/demo"; -const objectIdSchema = z.string().regex(/^[0-9a-fA-F]{24}$/, "Invalid MongoDB ObjectId"); +const objectIdSchema = z + .string() + .regex(/^[0-9a-fA-F]{24}$/, "Invalid MongoDB ObjectId"); const productBaseSchema = z.object({ image_url: z.url(), name: z.string().min(1), @@ -28,14 +36,17 @@ export async function GET(request: Request) { if (!parsedId.success) { return NextResponse.json( { message: parsedId.error.issues[0]?.message ?? "Invalid id" }, - { status: 400 }, + { status: 400 } ); } // There was an id in the search, so find the product that corresponds to it const product = await getProduct(parsedId.data); if (!product) { - return NextResponse.json({ message: "Product not found" }, { status: 404 }); + return NextResponse.json( + { message: "Product not found" }, + { status: 404 } + ); } return NextResponse.json(product, { status: 200 }); } else { @@ -63,7 +74,10 @@ export async function PUT(request: Request) { const updatedData = parsedRequest.update; const updatedProduct = await updateProduct(id, updatedData); if (!updatedProduct) { - return NextResponse.json({ message: "Product not found" }, { status: 404 }); + return NextResponse.json( + { message: "Product not found" }, + { status: 404 } + ); } return NextResponse.json(updatedProduct, { status: 200 }); } @@ -74,7 +88,10 @@ export async function DELETE(request: Request) { const { id } = validator.parse(await request.json()); const deleted = await deleteProduct(id); if (!deleted) { - return NextResponse.json({ message: "Product not found" }, { status: 404 }); + return NextResponse.json( + { message: "Product not found" }, + { status: 404 } + ); } return NextResponse.json({ message: "Product deleted" }, { status: 200 }); } diff --git a/app/api/inventory/[id]/route.ts b/app/api/inventory/[id]/route.ts new file mode 100644 index 0000000..e69de29 diff --git a/app/api/inventory/route.ts b/app/api/inventory/route.ts new file mode 100644 index 0000000..af771fb --- /dev/null +++ b/app/api/inventory/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { + categoryValues, + notificationAudienceValues, + notificationEventValues, +} from "@/models/Item"; +import { addItem, getItems } from "@/services/items"; + +// REST APIs split among route.ts and [id]/route.ts for cleaner management + +const thresholdSchema = z.object({ + minQuantity: z.number().int().nonnegative(), + enabled: z.boolean(), + lastAlertSentAt: z.coerce.date(), +}); + +const notificationPolicySchema = z.object({ + event: z.enum(notificationEventValues), + audience: z.enum(notificationAudienceValues), +}); + +const itemCreateSchema = z.object({ + labId: z.string().min(1), + name: z.string().min(1), + category: z.enum(categoryValues), + quantity: z.number().int().nonnegative(), + threshold: thresholdSchema, + notificationPolicy: notificationPolicySchema, +}); + +// GET: fetch all items +export async function GET() { + const items = await getItems(); + return NextResponse.json(items, { status: 200 }); +} + +// POST: add a new item +export async function POST(request: Request) { + const newItem = itemCreateSchema.parse(await request.json()); + const created = await addItem(newItem); + return NextResponse.json(created, { status: 201 }); +} diff --git a/models/Item.ts b/models/Item.ts index 42f729e..655d34a 100644 --- a/models/Item.ts +++ b/models/Item.ts @@ -1,4 +1,11 @@ -import { HydratedDocument, InferSchemaType, Model, Schema, model, models } from "mongoose"; +import { + HydratedDocument, + InferSchemaType, + Model, + Schema, + model, + models, +} from "mongoose"; // Fill enums with more items when more info is provided export const categoryValues = ["consumable"] as const; @@ -12,39 +19,49 @@ const transformDocument = (_: unknown, ret: Record) => { }; // Making many assumptions how Item Schemas should work since only one example is provided -const thresholdSchema = new Schema( - { - minQuantity: { type: Number, required: true, min: 0}, - enabled: { type: Boolean, required: true, default: true}, - lastAlertSentAt: { type: Date, required: true}, - } -) +const thresholdSchema = new Schema({ + minQuantity: { type: Number, required: true, min: 0 }, + enabled: { type: Boolean, required: true, default: true }, + lastAlertSentAt: { type: Date, required: true }, +}); -const notificationSchema = new Schema( - { - event: { type: String, enum: notificationEventValues, required: true}, - audience: { type: String, enum: notificationAudienceValues, required: true}, - } -) +const notificationSchema = new Schema({ + event: { type: String, enum: notificationEventValues, required: true }, + audience: { + type: String, + enum: notificationAudienceValues, + required: true, + }, +}); const itemSchema = new Schema( { - labId: { type: String, required: true, index: true}, - name: { type: String, required: true, trim: true}, - category: { type: String, enum: categoryValues, required: true}, - quantity: { type: Number, required: true, min: 0}, + labId: { type: String, required: true, index: true }, + name: { type: String, required: true, trim: true }, + category: { type: String, enum: categoryValues, required: true }, + quantity: { type: Number, required: true, min: 0 }, - threshold: { type: thresholdSchema, required: true}, - notificationPolicy: { type: notificationSchema, required: true}, + threshold: { type: thresholdSchema, required: true }, + notificationPolicy: { type: notificationSchema, required: true }, }, { timestamps: true, - toJSON: { virtuals: true, versionKey: false, transform: transformDocument }, - toObject: { virtuals: true, versionKey: false, transform: transformDocument }, + toJSON: { + virtuals: true, + versionKey: false, + transform: transformDocument, + }, + toObject: { + virtuals: true, + versionKey: false, + transform: transformDocument, + }, } -) +); export type ItemInput = InferSchemaType; +export type ItemCreateInput = Omit; +export type ItemUpdateInput = Partial; export type ItemCategory = (typeof categoryValues)[number]; export type NotificationEvent = (typeof notificationEventValues)[number]; export type NotificationAudience = (typeof notificationAudienceValues)[number]; @@ -54,4 +71,4 @@ export type ItemDocument = HydratedDocument; const ItemModel: Model = (models.Item as Model) || model("Item", itemSchema); -export default ItemModel; \ No newline at end of file +export default ItemModel; diff --git a/services/demo.ts b/services/demo.ts index 277f97f..62ae975 100644 --- a/services/demo.ts +++ b/services/demo.ts @@ -8,39 +8,39 @@ type ProductDocument = HydratedDocument; const toProduct = (doc: ProductDocument): Product => doc.toObject(); export async function getProducts(): Promise { - await connectToDatabase(); - const products = await ProductModel.find().exec(); - return products.map((product) => toProduct(product)); + await connectToDatabase(); + const products = await ProductModel.find().exec(); + return products.map((product) => toProduct(product)); } export async function getProduct(id: string): Promise { - await connectToDatabase(); - const product = await ProductModel.findById(id).exec(); - return product ? toProduct(product) : null; + await connectToDatabase(); + const product = await ProductModel.findById(id).exec(); + return product ? toProduct(product) : null; } export async function updateProduct( - id: string, - data: Partial, + id: string, + data: Partial ): Promise { - await connectToDatabase(); - const updatedProduct = await ProductModel.findByIdAndUpdate(id, data, { - new: true, - runValidators: true, - }).exec(); - return updatedProduct ? toProduct(updatedProduct) : null; + await connectToDatabase(); + const updatedProduct = await ProductModel.findByIdAndUpdate(id, data, { + new: true, + runValidators: true, + }).exec(); + return updatedProduct ? toProduct(updatedProduct) : null; } export async function addProduct(newProduct: ProductInput): Promise { - await connectToDatabase(); - const createdProduct = await ProductModel.create(newProduct); - return toProduct(createdProduct); + await connectToDatabase(); + const createdProduct = await ProductModel.create(newProduct); + return toProduct(createdProduct); } // DON'T create this for tables that you don't actually need to potentially delete things from // Could be used accidentally or misused maliciously to get rid of important data export async function deleteProduct(id: string): Promise { - await connectToDatabase(); - const deleted = await ProductModel.findByIdAndDelete(id).exec(); - return Boolean(deleted); + await connectToDatabase(); + const deleted = await ProductModel.findByIdAndDelete(id).exec(); + return Boolean(deleted); } diff --git a/services/inventory/package.json b/services/inventory/package.json new file mode 100644 index 0000000..e69de29 diff --git a/services/inventory/src/index.ts b/services/inventory/src/index.ts new file mode 100644 index 0000000..831bf92 --- /dev/null +++ b/services/inventory/src/index.ts @@ -0,0 +1,5 @@ +/* +Ask about what the proposed file structure is supposed to entail, +since the extra paths are likely for DB and backend rigor but the +demos we're given use a much simpler structure +*/ diff --git a/services/items.ts b/services/items.ts new file mode 100644 index 0000000..93d59f6 --- /dev/null +++ b/services/items.ts @@ -0,0 +1,50 @@ +import type { HydratedDocument } from "mongoose"; + +import { connectToDatabase } from "@/lib/mongoose"; +import ItemModel, { + Item, + ItemInput, + ItemCreateInput, + ItemUpdateInput, +} from "@/models/Item"; + +type ItemDocument = HydratedDocument; + +const toItem = (doc: ItemDocument): Item => doc.toObject(); + +export async function getItems(): Promise { + await connectToDatabase(); + const items = await ItemModel.find().exec(); + return items.map(item => toItem(item)); +} + +export async function getItem(id: string): Promise { + await connectToDatabase(); + const item = await ItemModel.findById(id).exec(); + return item ? toItem(item) : null; +} + +export async function addItem(newItem: ItemCreateInput): Promise { + await connectToDatabase(); + const created = await ItemModel.create(newItem); + return toItem(created); +} + +export async function updateItem( + id: string, + data: ItemUpdateInput +): Promise { + await connectToDatabase(); + const updated = await ItemModel.findByIdAndUpdate(id, data, { + new: true, + runValidators: true, + }).exec(); + return updated ? toItem(updated) : null; +} + +// deleteItem left out due to demo.ts warning +/* export async function deleteItem(id: string): Promise { + await connectToDatabase(); + const deleted = await ItemModel.findByIdAndDelete(id).exec(); + return Boolean(deleted); +} */ From 417482420a242e256a09fb3100e8092b95b23b1a Mon Sep 17 00:00:00 2001 From: davdwan21 <141950513+davdwan21@users.noreply.github.com> Date: Sun, 15 Feb 2026 11:11:06 -0800 Subject: [PATCH 2/6] Implemented APIs for id specific requests + Added GET, PUT, DELETE by id --- app/api/inventory/[id]/route.ts | 71 +++++++++++++++++++++++++++++++++ services/items.ts | 5 +-- 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/app/api/inventory/[id]/route.ts b/app/api/inventory/[id]/route.ts index e69de29..a8181fb 100644 --- a/app/api/inventory/[id]/route.ts +++ b/app/api/inventory/[id]/route.ts @@ -0,0 +1,71 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { getItem, updateItem, deleteItem } from "@/services/items"; + +const objectIdSchema = z + .string() + .regex(/^[0-9a-fA-F]{24}$/, "Invalid MongoDB ObjectId"); + +// GET: fetch single item by id +export async function GET(_: Request, { params }: { params: { id: string } }) { + const parsedId = objectIdSchema.safeParse(params.id); + if (!parsedId.success) { + return NextResponse.json( + { message: parsedId.error.issues[0]?.message ?? "Invalid id" }, + { status: 400 } + ); + } + + const item = await getItem(parsedId.data); + if (!item) { + return NextResponse.json( + { message: "Item not found" }, + { status: 404 } + ); + } + return NextResponse.json(item, { status: 202 }); +} + +// PUT: update an item by id +export async function PUT( + request: Request, + { params }: { params: { id: string } } +) { + const parsedId = objectIdSchema.safeParse(params.id); + if (!parsedId.success) { + return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + } + + const updateData = await request.json(); + + const updated = await updateItem(parsedId.data, updateData); + if (!updated) { + return NextResponse.json( + { message: "Item not found" }, + { status: 404 } + ); + } + + return NextResponse.json(updated, { status: 200 }); +} + +// DELETE: Delete a product by id +export async function DELETE( + _: Request, + { params }: { params: { id: string } } +) { + const parsedId = objectIdSchema.safeParse(params.id); + if (!parsedId.success) { + return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + } + + const deleted = await deleteItem(params.id); + if (!deleted) { + return NextResponse.json( + { message: "Item not found" }, + { status: 404 } + ); + } + + return NextResponse.json(deleted, { status: 200 }); +} diff --git a/services/items.ts b/services/items.ts index 93d59f6..36d4ec9 100644 --- a/services/items.ts +++ b/services/items.ts @@ -42,9 +42,8 @@ export async function updateItem( return updated ? toItem(updated) : null; } -// deleteItem left out due to demo.ts warning -/* export async function deleteItem(id: string): Promise { +export async function deleteItem(id: string): Promise { await connectToDatabase(); const deleted = await ItemModel.findByIdAndDelete(id).exec(); return Boolean(deleted); -} */ +} From 546df811e092fc09ac9cce1c4dafa3aa75982cc9 Mon Sep 17 00:00:00 2001 From: davdwan21 <141950513+davdwan21@users.noreply.github.com> Date: Sun, 15 Feb 2026 11:17:23 -0800 Subject: [PATCH 3/6] DELETE may be unsafe --- app/api/inventory/[id]/route.ts | 1 + services/items.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/app/api/inventory/[id]/route.ts b/app/api/inventory/[id]/route.ts index a8181fb..ff5abd4 100644 --- a/app/api/inventory/[id]/route.ts +++ b/app/api/inventory/[id]/route.ts @@ -49,6 +49,7 @@ export async function PUT( return NextResponse.json(updated, { status: 200 }); } +// Should probably check for auth to prevent unauthorized deletes // DELETE: Delete a product by id export async function DELETE( _: Request, diff --git a/services/items.ts b/services/items.ts index 36d4ec9..475b568 100644 --- a/services/items.ts +++ b/services/items.ts @@ -42,6 +42,7 @@ export async function updateItem( return updated ? toItem(updated) : null; } +// Concerned about potential unauthorized deletes as warned by demo.ts. export async function deleteItem(id: string): Promise { await connectToDatabase(); const deleted = await ItemModel.findByIdAndDelete(id).exec(); From bf5de368fb354f3f1385f50e5e59b496f5db7567 Mon Sep 17 00:00:00 2001 From: davdwan21 <141950513+davdwan21@users.noreply.github.com> Date: Sun, 22 Feb 2026 16:22:45 -0800 Subject: [PATCH 4/6] Exception handling and DB - item linkage + Added exception handling to services and APIs + Adjusted typing to handle ItemInput (DB) vs Item (query) Next steps: - Implement pagination and search queries --- app/api/inventory/[id]/route.ts | 79 ++++++++++++++++++++++++++------- app/api/inventory/route.ts | 25 ++++++++--- models/Item.ts | 6 ++- services/items.ts | 36 ++++++++++----- 4 files changed, 111 insertions(+), 35 deletions(-) diff --git a/app/api/inventory/[id]/route.ts b/app/api/inventory/[id]/route.ts index ff5abd4..ef1f188 100644 --- a/app/api/inventory/[id]/route.ts +++ b/app/api/inventory/[id]/route.ts @@ -7,6 +7,7 @@ const objectIdSchema = z .regex(/^[0-9a-fA-F]{24}$/, "Invalid MongoDB ObjectId"); // GET: fetch single item by id +// TODO ** try catch export async function GET(_: Request, { params }: { params: { id: string } }) { const parsedId = objectIdSchema.safeParse(params.id); if (!parsedId.success) { @@ -16,17 +17,25 @@ export async function GET(_: Request, { params }: { params: { id: string } }) { ); } - const item = await getItem(parsedId.data); - if (!item) { + try { + const item = await getItem(parsedId.data); + if (!item) { + return NextResponse.json( + { message: "Item not found" }, + { status: 404 } + ); + } + return NextResponse.json(item, { status: 200 }); + } catch { return NextResponse.json( - { message: "Item not found" }, - { status: 404 } + { success: false, message: "Error occured while retrieving items" }, + { status: 500 } ); } - return NextResponse.json(item, { status: 202 }); } // PUT: update an item by id +// TODO ** Ensure proper item update with Zod schema export async function PUT( request: Request, { params }: { params: { id: string } } @@ -36,17 +45,46 @@ export async function PUT( return NextResponse.json({ message: "Invalid id" }, { status: 400 }); } - const updateData = await request.json(); + const updateSchema = z.object({ + name: z.string().optional(), + quantity: z.number().min(0).optional(), + threshold: z + .object({ + minQuantity: z.number().min(0), + enabled: z.boolean(), + }) + .optional(), + }); - const updated = await updateItem(parsedId.data, updateData); - if (!updated) { + // Assuming updateSchema + const json = await request.json(); + const parsedUpdate = updateSchema.safeParse(json); + if (!parsedUpdate.success) { return NextResponse.json( - { message: "Item not found" }, - { status: 404 } + { + message: "Update doesn't follow schema", + issues: parsedUpdate.error.flatten(), + }, + { status: 400 } ); } - return NextResponse.json(updated, { status: 200 }); + try { + const updated = await updateItem(parsedId.data, parsedUpdate.data); + if (!updated) { + return NextResponse.json( + { message: "Item not found" }, + { status: 404 } + ); + } + + return NextResponse.json(updated, { status: 200 }); + } catch { + return NextResponse.json( + { success: false, message: "Error while updating data" }, + { status: 500 } + ); + } } // Should probably check for auth to prevent unauthorized deletes @@ -60,13 +98,20 @@ export async function DELETE( return NextResponse.json({ message: "Invalid id" }, { status: 400 }); } - const deleted = await deleteItem(params.id); - if (!deleted) { + try { + const deleted = await deleteItem(parsedId.data); + if (!deleted) { + return NextResponse.json( + { message: "Item not found" }, + { status: 404 } + ); + } + + return NextResponse.json(deleted, { status: 200 }); + } catch { return NextResponse.json( - { message: "Item not found" }, - { status: 404 } + { success: false, message: "Error while deleting data" }, + { status: 500 } ); } - - return NextResponse.json(deleted, { status: 200 }); } diff --git a/app/api/inventory/route.ts b/app/api/inventory/route.ts index af771fb..092e8fd 100644 --- a/app/api/inventory/route.ts +++ b/app/api/inventory/route.ts @@ -30,14 +30,29 @@ const itemCreateSchema = z.object({ }); // GET: fetch all items +// implement page, limit, labid search params export async function GET() { - const items = await getItems(); - return NextResponse.json(items, { status: 200 }); + try { + const items = await getItems(); + return NextResponse.json(items, { status: 200 }); + } catch { + return NextResponse.json( + { message: "Failed to fetch items" }, + { status: 500 } + ); + } } // POST: add a new item export async function POST(request: Request) { - const newItem = itemCreateSchema.parse(await request.json()); - const created = await addItem(newItem); - return NextResponse.json(created, { status: 201 }); + try { + const newItem = itemCreateSchema.parse(await request.json()); + const created = await addItem(newItem); + return NextResponse.json(created, { status: 201 }); + } catch { + return NextResponse.json( + { message: "Failed to fetch items" }, + { status: 500 } + ); + } } diff --git a/models/Item.ts b/models/Item.ts index 655d34a..eea5b88 100644 --- a/models/Item.ts +++ b/models/Item.ts @@ -65,9 +65,13 @@ export type ItemUpdateInput = Partial; export type ItemCategory = (typeof categoryValues)[number]; export type NotificationEvent = (typeof notificationEventValues)[number]; export type NotificationAudience = (typeof notificationAudienceValues)[number]; -export type Item = ItemInput & { id: string }; + +export type Item = { id: string } & Omit; +// ? export type ItemDocument = HydratedDocument; +export const toItem = (doc: ItemDocument): Item => doc.toObject(); + const ItemModel: Model = (models.Item as Model) || model("Item", itemSchema); diff --git a/services/items.ts b/services/items.ts index 475b568..ba981b6 100644 --- a/services/items.ts +++ b/services/items.ts @@ -1,29 +1,31 @@ -import type { HydratedDocument } from "mongoose"; - import { connectToDatabase } from "@/lib/mongoose"; import ItemModel, { Item, - ItemInput, ItemCreateInput, ItemUpdateInput, + toItem, } from "@/models/Item"; -type ItemDocument = HydratedDocument; - -const toItem = (doc: ItemDocument): Item => doc.toObject(); - +// TODO ** Paginate and limit to 10 per page +// Build query with filters export async function getItems(): Promise { await connectToDatabase(); const items = await ItemModel.find().exec(); return items.map(item => toItem(item)); } -export async function getItem(id: string): Promise { +export async function getItem(id: string): Promise { await connectToDatabase(); const item = await ItemModel.findById(id).exec(); - return item ? toItem(item) : null; + + if (item === null) { + throw new Error("Item not found"); + } + + return toItem(item); } +// Check for perms once RBAC has been implemented export async function addItem(newItem: ItemCreateInput): Promise { await connectToDatabase(); const created = await ItemModel.create(newItem); @@ -39,12 +41,22 @@ export async function updateItem( new: true, runValidators: true, }).exec(); - return updated ? toItem(updated) : null; + + if (updated === null) { + throw new Error("Item not found"); + } + return toItem(updated); } // Concerned about potential unauthorized deletes as warned by demo.ts. export async function deleteItem(id: string): Promise { await connectToDatabase(); - const deleted = await ItemModel.findByIdAndDelete(id).exec(); - return Boolean(deleted); + + const item = await ItemModel.findById(id).exec(); + if (item === null) { + throw new Error("Item not found"); + } + + const result = await item.deleteOne(); + return result.deletedCount === 1; } From ce2d4960cde969193a88652b51c261be71007012 Mon Sep 17 00:00:00 2001 From: davdwan21 <141950513+davdwan21@users.noreply.github.com> Date: Wed, 25 Feb 2026 16:12:56 -0800 Subject: [PATCH 5/6] Adjustments to models, services, and general APIs + General route.ts no longer uses a cached connection. Also returns NextResponse upon failed connection attempt. + GET and POST now use new connection attempt + POST utilizes safeParse instead of parse to validate body with schema. + Model exports cleaned up and condensed. + Services no longer attempt to connect to database + Comment descriptor for each method + Methods no longer throw errors, now simply return null upon nonexistent item Next steps: - implement getFiltered service and API - fix id specific routing --- app/api/inventory/route.ts | 44 +++++++++++++++++++++++--- models/Item.ts | 17 ++++------ services/items.ts | 65 ++++++++++++++++++++------------------ 3 files changed, 80 insertions(+), 46 deletions(-) diff --git a/app/api/inventory/route.ts b/app/api/inventory/route.ts index 092e8fd..0f2e8f6 100644 --- a/app/api/inventory/route.ts +++ b/app/api/inventory/route.ts @@ -6,8 +6,19 @@ import { notificationEventValues, } from "@/models/Item"; import { addItem, getItems } from "@/services/items"; +import { connectToDatabase } from "@/lib/mongoose"; -// REST APIs split among route.ts and [id]/route.ts for cleaner management +// Only returns a Next response upon failed connection +async function connect() { + try { + await connectToDatabase(); + } catch { + return NextResponse.json( + { success: false, message: "Error connecting to database" }, + { status: 500 } + ); + } +} const thresholdSchema = z.object({ minQuantity: z.number().int().nonnegative(), @@ -30,8 +41,12 @@ const itemCreateSchema = z.object({ }); // GET: fetch all items -// implement page, limit, labid search params export async function GET() { + const connectionResponse = await connect(); + if (connectionResponse) { + return connectionResponse; + } + try { const items = await getItems(); return NextResponse.json(items, { status: 200 }); @@ -43,15 +58,34 @@ export async function GET() { } } +// GET FILTERED should go here + // POST: add a new item export async function POST(request: Request) { + const connectionResponse = await connect(); + if (connectionResponse) { + return connectionResponse; + } + + const body = await request.json(); + const parsedBody = itemCreateSchema.safeParse(body); + + if (!parsedBody.success) { + return NextResponse.json( + { + success: false, + message: "Invalid request body.", + }, + { status: 400 } + ); + } + try { - const newItem = itemCreateSchema.parse(await request.json()); - const created = await addItem(newItem); + const created = await addItem(parsedBody.data); return NextResponse.json(created, { status: 201 }); } catch { return NextResponse.json( - { message: "Failed to fetch items" }, + { message: "Error occured while creating item" }, { status: 500 } ); } diff --git a/models/Item.ts b/models/Item.ts index eea5b88..8bd9700 100644 --- a/models/Item.ts +++ b/models/Item.ts @@ -16,9 +16,8 @@ const transformDocument = (_: unknown, ret: Record) => { ret.id = ret._id?.toString(); delete ret._id; return ret; -}; +}; // Handling document to JSON / Object conversions -// Making many assumptions how Item Schemas should work since only one example is provided const thresholdSchema = new Schema({ minQuantity: { type: Number, required: true, min: 0 }, enabled: { type: Boolean, required: true, default: true }, @@ -34,6 +33,7 @@ const notificationSchema = new Schema({ }, }); +// itemSchema holds information, previously defined schemas, and conversion information const itemSchema = new Schema( { labId: { type: String, required: true, index: true }, @@ -60,19 +60,14 @@ const itemSchema = new Schema( ); export type ItemInput = InferSchemaType; +export type Item = { id: string } & Omit; + export type ItemCreateInput = Omit; export type ItemUpdateInput = Partial; -export type ItemCategory = (typeof categoryValues)[number]; -export type NotificationEvent = (typeof notificationEventValues)[number]; -export type NotificationAudience = (typeof notificationAudienceValues)[number]; - -export type Item = { id: string } & Omit; -// ? export type ItemDocument = HydratedDocument; -export const toItem = (doc: ItemDocument): Item => doc.toObject(); - const ItemModel: Model = (models.Item as Model) || model("Item", itemSchema); - export default ItemModel; + +export const toItem = (doc: ItemDocument): Item => doc.toObject(); diff --git a/services/items.ts b/services/items.ts index ba981b6..38f66d8 100644 --- a/services/items.ts +++ b/services/items.ts @@ -1,4 +1,3 @@ -import { connectToDatabase } from "@/lib/mongoose"; import ItemModel, { Item, ItemCreateInput, @@ -6,57 +5,63 @@ import ItemModel, { toItem, } from "@/models/Item"; -// TODO ** Paginate and limit to 10 per page -// Build query with filters +/** + * Returns all items (Likely unused in favor of filteredGet) + * @returns all items in the form of a JS Object + */ export async function getItems(): Promise { - await connectToDatabase(); const items = await ItemModel.find().exec(); return items.map(item => toItem(item)); } -export async function getItem(id: string): Promise { - await connectToDatabase(); - const item = await ItemModel.findById(id).exec(); - - if (item === null) { - throw new Error("Item not found"); - } +// filteredGet here - return toItem(item); +/** + * Returns an item by id + * @param id the ID of the listing to get + * @returns the listing in the form of a JS Object + */ +export async function getItem(id: string): Promise { + const item = await ItemModel.findById(id).exec(); + return item ? toItem(item) : null; } -// Check for perms once RBAC has been implemented +/** + * Adds an item. Should check for perms once RBAC has been implemented + * @param newItem the new Item to add + * @returns the added item in the form of a JS Object + */ export async function addItem(newItem: ItemCreateInput): Promise { - await connectToDatabase(); const created = await ItemModel.create(newItem); return toItem(created); } +/** + * Update listing item by id. Runs loose item schema validation + * @param id the ID of the listing to update + * @param data the data to update the listing with + * @returns the updated listing or null if not found + */ +// If strict validation is wanted, use an upsert instead export async function updateItem( id: string, data: ItemUpdateInput ): Promise { - await connectToDatabase(); const updated = await ItemModel.findByIdAndUpdate(id, data, { new: true, runValidators: true, }).exec(); - - if (updated === null) { - throw new Error("Item not found"); - } - return toItem(updated); + return updated ? toItem(updated) : null; } -// Concerned about potential unauthorized deletes as warned by demo.ts. +/** + * Delete an item entry by ID + * @param id the ID of the item to delete + * @returns true if the item was deleted, false otherwise + */ +// Don't use this for tables where nothing needs to be deleted +// Could be accidentally or maliciously used to get rid of important data export async function deleteItem(id: string): Promise { - await connectToDatabase(); - - const item = await ItemModel.findById(id).exec(); - if (item === null) { - throw new Error("Item not found"); - } - - const result = await item.deleteOne(); - return result.deletedCount === 1; + const deleted = await ItemModel.findByIdAndDelete(id).exec(); + return Boolean(deleted); } From f471bb948cf00f0abfdf6f9d76c70ea756d3adb0 Mon Sep 17 00:00:00 2001 From: davdwan21 <141950513+davdwan21@users.noreply.github.com> Date: Sun, 1 Mar 2026 18:57:43 -0800 Subject: [PATCH 6/6] filteredGet, lean, and schema enforcement + Created filteredGet in services + Implemented .lean() so services return plain objects rather than documents + Proper item update is enforced with Zod schema --- app/api/inventory/[id]/route.ts | 45 +++++++++++++++++-------- app/api/inventory/route.ts | 9 +---- models/Item.ts | 14 ++++++++ services/items.ts | 59 ++++++++++++++++++++++++++++++--- 4 files changed, 101 insertions(+), 26 deletions(-) diff --git a/app/api/inventory/[id]/route.ts b/app/api/inventory/[id]/route.ts index ef1f188..e365386 100644 --- a/app/api/inventory/[id]/route.ts +++ b/app/api/inventory/[id]/route.ts @@ -1,13 +1,19 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { getItem, updateItem, deleteItem } from "@/services/items"; +import { + categoryValues, + notificationAudienceValues, + notificationEventValues, +} from "@/models/Item"; const objectIdSchema = z .string() .regex(/^[0-9a-fA-F]{24}$/, "Invalid MongoDB ObjectId"); -// GET: fetch single item by id -// TODO ** try catch +const zEnumFromConst = (values: T) => + z.enum(values as unknown as [T[number], ...T[number][]]); + export async function GET(_: Request, { params }: { params: { id: string } }) { const parsedId = objectIdSchema.safeParse(params.id); if (!parsedId.success) { @@ -35,7 +41,6 @@ export async function GET(_: Request, { params }: { params: { id: string } }) { } // PUT: update an item by id -// TODO ** Ensure proper item update with Zod schema export async function PUT( request: Request, { params }: { params: { id: string } } @@ -45,17 +50,31 @@ export async function PUT( return NextResponse.json({ message: "Invalid id" }, { status: 400 }); } - const updateSchema = z.object({ - name: z.string().optional(), - quantity: z.number().min(0).optional(), - threshold: z - .object({ - minQuantity: z.number().min(0), - enabled: z.boolean(), - }) - .optional(), + const thresholdFullSchema = z.object({ + minQuantity: z.number().min(0), + enabled: z.boolean(), + lastAlertSentAt: z.coerce.date(), + }); + + const notificationPolicyFullSchema = z.object({ + event: zEnumFromConst(notificationEventValues), + audience: zEnumFromConst(notificationAudienceValues), }); + const updateSchema = z + .object({ + name: z.string().trim().min(1).optional(), + category: zEnumFromConst(categoryValues).optional(), + quantity: z.number().min(0).optional(), + + threshold: thresholdFullSchema.optional(), + notificationPolicy: notificationPolicyFullSchema.optional(), + }) + .strict() + .refine(obj => Object.keys(obj).length > 0, { + message: "Body must include at least one field to update", + }); + // Assuming updateSchema const json = await request.json(); const parsedUpdate = updateSchema.safeParse(json); @@ -87,7 +106,7 @@ export async function PUT( } } -// Should probably check for auth to prevent unauthorized deletes +// In the future check for auth to prevent unauthorized deletes // DELETE: Delete a product by id export async function DELETE( _: Request, diff --git a/app/api/inventory/route.ts b/app/api/inventory/route.ts index 0f2e8f6..e83baf1 100644 --- a/app/api/inventory/route.ts +++ b/app/api/inventory/route.ts @@ -43,9 +43,7 @@ const itemCreateSchema = z.object({ // GET: fetch all items export async function GET() { const connectionResponse = await connect(); - if (connectionResponse) { - return connectionResponse; - } + // if (connectionResponse) return connectionResponse; ? try { const items = await getItems(); @@ -58,14 +56,9 @@ export async function GET() { } } -// GET FILTERED should go here - // POST: add a new item export async function POST(request: Request) { const connectionResponse = await connect(); - if (connectionResponse) { - return connectionResponse; - } const body = await request.json(); const parsedBody = itemCreateSchema.safeParse(body); diff --git a/models/Item.ts b/models/Item.ts index 8bd9700..f96c053 100644 --- a/models/Item.ts +++ b/models/Item.ts @@ -5,6 +5,8 @@ import { Schema, model, models, + FlattenMaps, + Types, } from "mongoose"; // Fill enums with more items when more info is provided @@ -64,10 +66,22 @@ export type Item = { id: string } & Omit; export type ItemCreateInput = Omit; export type ItemUpdateInput = Partial; + export type ItemDocument = HydratedDocument; +export type ItemLean = FlattenMaps & { _id: Types.ObjectId }; + const ItemModel: Model = (models.Item as Model) || model("Item", itemSchema); export default ItemModel; export const toItem = (doc: ItemDocument): Item => doc.toObject(); + +export const toItemFromLean = (obj: ItemLean): Item => { + const { _id, ...rest } = obj as any; + + return { + ...(rest as Omit), + id: String(_id), + }; +}; diff --git a/services/items.ts b/services/items.ts index 38f66d8..50631f9 100644 --- a/services/items.ts +++ b/services/items.ts @@ -1,20 +1,69 @@ +import { connectToDatabase } from "@/lib/mongoose"; import ItemModel, { Item, ItemCreateInput, ItemUpdateInput, toItem, + toItemFromLean, } from "@/models/Item"; +type getItemOptions = { + page?: number; + limit?: number; + labId?: string; + name?: string; +}; + /** * Returns all items (Likely unused in favor of filteredGet) * @returns all items in the form of a JS Object */ export async function getItems(): Promise { - const items = await ItemModel.find().exec(); - return items.map(item => toItem(item)); + const items = await ItemModel.find().lean().exec(); + return items.map(item => toItemFromLean(item)); } -// filteredGet here +/** + * Get items based on filter params + * @param options options for filtering (page number, entries per page, labId, and name) + * @returns filtered items in the form of a JS object + */ +export async function filteredGet(options: getItemOptions) { + await connectToDatabase(); + const page = Math.max(1, Math.floor(options?.page ?? 1)); + const limit = Math.max(1, Math.min(Math.floor(options?.limit ?? 10), 50)); + const skip = (page - 1) * limit; + + const query: any = {}; + + if (options?.labId) { + query.labId = options.labId; + } + + if (options?.name?.trim()) { + query.name = { $regex: options.name.trim(), $options: "i" }; + } + + const [items, total] = await Promise.all([ + ItemModel.find(query) + .sort({ createdAt: -1, _id: -1 }) // sorts by ID if same createdAt + .skip(skip) + .limit(limit) + .lean() + .exec(), + ItemModel.countDocuments(query).exec(), + ]); + + return { + data: items, + pagination: { + page, + limit, + total, + totalPages: Math.max(1, Math.ceil(total / limit)), + }, + }; +} /** * Returns an item by id @@ -22,8 +71,8 @@ export async function getItems(): Promise { * @returns the listing in the form of a JS Object */ export async function getItem(id: string): Promise { - const item = await ItemModel.findById(id).exec(); - return item ? toItem(item) : null; + const item = await ItemModel.findById(id).lean().exec(); + return item ? toItemFromLean(item) : null; } /**