diff --git a/.gitignore b/.gitignore index 2b24613..bb6af5d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ node_modules/ .next/ .env.local +.env +.env.test .DS_Store .env \ No newline at end of file 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..c67bd71 --- /dev/null +++ b/app/api/inventory/[id]/route.ts @@ -0,0 +1,137 @@ +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"); + +const zEnumFromConst = (values: T) => + z.enum(values as unknown as [T[number], ...T[number][]]); + +// GET: get an 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 } + ); + } + + 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( + { success: false, message: "Error occured while retrieving items" }, + { status: 500 } + ); + } +} + +// 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 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); + if (!parsedUpdate.success) { + return NextResponse.json( + { + message: "Update doesn't follow schema", + issues: parsedUpdate.error.flatten(), + }, + { status: 400 } + ); + } + + 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 } + ); + } +} + +// In the future, check for auth before usage to prevent unauthorized deletes +// 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 }); + } + + 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( + { success: false, message: "Error while deleting data" }, + { status: 500 } + ); + } +} diff --git a/app/api/inventory/route.ts b/app/api/inventory/route.ts new file mode 100644 index 0000000..3611096 --- /dev/null +++ b/app/api/inventory/route.ts @@ -0,0 +1,70 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { + categoryValues, + notificationAudienceValues, + notificationEventValues, +} from "@/models/Item"; +import { addItem, filteredGet } from "@/services/items"; + +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() { + try { + const items = await filteredGet({ + page: 1, + limit: 10, + }); // swap to getItems() if needed + 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 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 created = await addItem(parsedBody.data); + return NextResponse.json(created, { status: 201 }); + } catch { + return NextResponse.json( + { message: "Error occured while creating item" }, + { status: 500 } + ); + } +} diff --git a/app/api/tests/route-id.test.ts b/app/api/tests/route-id.test.ts new file mode 100644 index 0000000..d3123d3 --- /dev/null +++ b/app/api/tests/route-id.test.ts @@ -0,0 +1,196 @@ +import { GET, PUT, DELETE } from "../inventory/[id]/route"; +import { getItem, updateItem, deleteItem } from "@/services/items"; + +// GET by id 200 success +// GET id 404 not found +// PUT 200 by id success +// PUT 400 invalid input +// DELETE 200 success +// DELETE 404 not found failure + +jest.mock("@/services/items", () => ({ + getItem: jest.fn(), + updateItem: jest.fn(), + deleteItem: jest.fn(), +})); + +describe("api/inventory/[id]/", () => { + const mockItem = { + id: "1234abcd1234abcd1234abcd", + labId: "lab1", + name: "Gloves", + category: "consumable", + quantity: 10, + threshold: { + minQuantity: 2, + enabled: true, + lastAlertSentAt: "2026-03-09", + }, + notificationPolicy: { + event: "LOW_STOCK", + audience: "LAB_ADMINS", + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("GET /api/inventory/[id]/", () => { + it("GET by id returns with item and status 200", async () => { + (getItem as jest.Mock).mockResolvedValue(mockItem); + + const request = new Request( + "http://localhost:3000/api/inventory/1234abcd1234abcd1234abcd" + ); + const response = await GET(request, { + params: { id: "1234abcd1234abcd1234abcd" }, + }); + + const body = await response.json(); + + expect(getItem).toHaveBeenCalledWith("1234abcd1234abcd1234abcd"); + expect(response.status).toBe(200); + expect(body).toEqual(mockItem); + }); + + it("GET by id returns with status 404 when item not found", async () => { + (getItem as jest.Mock).mockResolvedValue(null); + + const request = new Request( + "http://localhost:3000/api/inventory/22223333444455556666aaaa" + ); + const response = await GET(request, { + params: { id: "22223333444455556666aaaa" }, + }); + + const body = await response.json(); + + expect(getItem).toHaveBeenCalledWith("22223333444455556666aaaa"); + expect(response.status).toBe(404); + expect(body).toEqual({ + message: "Item not found", + }); + }); + }); + + describe("PUT /api/inventory/[id]/", () => { + it("PUT successfully updates item and returns 200", async () => { + const updatedItem = { + ...mockItem, + name: "Gloves 2", + quantity: 20, + }; + + (updateItem as jest.Mock).mockResolvedValue(updatedItem); + + const request = new Request( + "http://localhost:3000/api/inventory/1234abcd1234abcd1234abcd", + { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: "Gloves 2", + quantity: 20, + category: "consumable", + threshold: { + minQuantity: 3, + enabled: true, + lastAlertSentAt: "2026-03-09", + }, + notificationPolicy: { + event: "LOW_STOCK", + audience: "LAB_MANAGER", + }, + }), + } + ); + + const response = await PUT(request, { + params: { id: "1234abcd1234abcd1234abcd" }, + }); + + const body = await response.json(); + + expect(updateItem).toHaveBeenCalled(); + expect(updateItem).toHaveBeenCalledWith( + "1234abcd1234abcd1234abcd", + expect.objectContaining({ + name: "Gloves 2", + quantity: 20, + }) + ); + expect(response.status).toBe(200); + expect(body).toEqual(updatedItem); + }); + + it("PUT returns 400 on invalid input", async () => { + const request = new Request( + "http://localhost:3000/api/inventory/1234abcd1234abcd1234abcd", + { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + quantity: -10, + }), + } + ); + + const response = await PUT(request, { + params: { id: "1234abcd1234abcd1234abcd" }, + }); + + const body = await response.json(); + + expect(updateItem).not.toHaveBeenCalled(); + expect(response.status).toBe(400); + expect(body).toHaveProperty("message"); + }); + }); + + describe("DELETE /api/inventory/[id]/", () => { + it("DELETE successfully deletes item and returns 200", async () => { + (deleteItem as jest.Mock).mockResolvedValue(mockItem); + + const request = new Request( + "http://localhost:3000/api/inventory/1234abcd1234abcd1234abcd", + { method: "DELETE" } + ); + + const response = await DELETE(request, { + params: { id: "1234abcd1234abcd1234abcd" }, + }); + + const body = await response.json(); + + expect(deleteItem).toHaveBeenCalledWith("1234abcd1234abcd1234abcd"); + expect(response.status).toBe(200); + expect(body).toEqual(mockItem); + }); + + it("DELETE returns 404 when item not found", async () => { + (deleteItem as jest.Mock).mockResolvedValue(null); + + const request = new Request( + "http://localhost:3000/api/inventory/2234abcd1234abcd1234abcd", + { method: "DELETE" } + ); + + const response = await DELETE(request, { + params: { id: "2234abcd1234abcd1234abcd" }, + }); + + const body = await response.json(); + + expect(deleteItem).toHaveBeenCalledWith("2234abcd1234abcd1234abcd"); + expect(response.status).toBe(404); + expect(body).toEqual({ + message: "Item not found", + }); + }); + }); +}); diff --git a/app/api/tests/route.test.ts b/app/api/tests/route.test.ts new file mode 100644 index 0000000..a20882d --- /dev/null +++ b/app/api/tests/route.test.ts @@ -0,0 +1,161 @@ +import { GET, POST } from "../inventory/route"; +import { filteredGet, addItem } from "@/services/items"; + +// GET 200 success +// GET 500 failure +// POST 201 success +// POST 400 invalid input +// POST 500 server error + +jest.mock("@/services/items", () => ({ + filteredGet: jest.fn(), + addItem: jest.fn(), +})); + +// Helper function to make post tests cleaner +function makePostRequest(body: unknown) { + return new Request("http://localhost:3000/api/inventory", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); +} + +describe("/api/inventory/", () => { + describe("GET /api/inventory/", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("GET returns items successfully with status 200", async () => { + const mockItems = { + data: [ + { id: "1", name: "Gloves", quantity: 10 }, + { id: "2", name: "Goggles", quantity: 5 }, + ], + pagination: { + page: 1, + limit: 10, + total: 2, + totalPages: 1, + }, + }; + + (filteredGet as jest.Mock).mockResolvedValue(mockItems); + + const response = await GET(); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toEqual(mockItems); + + expect(filteredGet).toHaveBeenCalledWith({ + page: 1, + limit: 10, + }); + }); + + it("GET returns with status 500 on failure", async () => { + (filteredGet as jest.Mock).mockRejectedValue( + new Error("DB failure") + ); + + const response = await GET(); + const body = await response.json(); + + expect(response.status).toBe(500); + expect(body).toEqual({ message: "Failed to fetch items" }); + }); + }); + + describe("POST /api/inventory/", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("POST returns 201 when item successfully created", async () => { + const validBody = { + labId: "test-lab", + name: "Tubes", + category: "consumable", + quantity: 5, + threshold: { + minQuantity: 2, + enabled: true, + lastAlertSentAt: "2026-03-09", + }, + notificationPolicy: { + event: "LOW_STOCK", + audience: "LAB_MANAGER", + }, + }; + + const createdItem = { + id: "1", + ...validBody, + }; + + (addItem as jest.Mock).mockResolvedValue(createdItem); + + const request = makePostRequest(validBody); + const response = await POST(request); + const body = await response.json(); + + expect(response.status).toBe(201); + expect(body).toEqual(createdItem); + + expect(addItem).toHaveBeenCalledTimes(1); + }); + + it("POST returns 400 when item input is invalid", async () => { + const invalidBody = { + labId: "", + }; + + const request = makePostRequest(invalidBody); + const response = await POST(request); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body).toEqual({ + success: false, + message: "Invalid request body.", + }); + + expect(addItem).not.toHaveBeenCalled(); + }); + + it("POST returns 500 when service throws error", async () => { + const validBody = { + labId: "test-lab", + name: "Tubes", + category: "consumable", + quantity: 5, + threshold: { + minQuantity: 2, + enabled: true, + lastAlertSentAt: "2026-03-09", + }, + notificationPolicy: { + event: "LOW_STOCK", + audience: "LAB_MANAGER", + }, + }; + + (addItem as jest.Mock).mockRejectedValue(new Error("DB failed")); + + const request = makePostRequest(validBody); + const response = await POST(request); + const body = await response.json(); + + expect(response.status).toBe(500); + expect(body).toEqual({ + message: "Error occured while creating item", + }); + + expect(addItem).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..f80c0ec --- /dev/null +++ b/jest.config.js @@ -0,0 +1,18 @@ +const { createDefaultPreset } = require("ts-jest"); + +const tsJestTransformCfg = createDefaultPreset().transform; + +/** @type {import("jest").Config} **/ +module.exports = { + testEnvironment: "node", + transform: { + ...tsJestTransformCfg, + }, + setupFiles: ["/jest.setup.ts"], + moduleNameMapper: { + "^@/(.*)$": "/$1", + }, + testPathIgnorePatterns: [ + "/tests/e2e/", + ], +}; \ No newline at end of file diff --git a/jest.setup.ts b/jest.setup.ts index 7f44275..9a66333 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -56,4 +56,4 @@ global.console = { // debug: jest.fn(), // info: jest.fn(), // warn: jest.fn(), -}; \ No newline at end of file +}; diff --git a/lib/db.ts b/lib/db.ts new file mode 100644 index 0000000..b518a8d --- /dev/null +++ b/lib/db.ts @@ -0,0 +1,14 @@ +import { connectToDatabase } from "./mongoose"; +import { NextResponse } from "next/server"; + +export async function connect() { + try { + console.log(process.env.DATABASE_URL); + await connectToDatabase(); + } catch { + return NextResponse.json( + { success: false, message: "Error connecting to database" }, + { status: 500 } + ); + } +} diff --git a/models/Item.ts b/models/Item.ts index 42f729e..1953397 100644 --- a/models/Item.ts +++ b/models/Item.ts @@ -1,57 +1,87 @@ -import { HydratedDocument, InferSchemaType, Model, Schema, model, models } from "mongoose"; +import { + HydratedDocument, + InferSchemaType, + Model, + Schema, + model, + models, + FlattenMaps, + Types, +} from "mongoose"; // Fill enums with more items when more info is provided export const categoryValues = ["consumable"] as const; export const notificationEventValues = ["LOW_STOCK"] as const; -export const notificationAudienceValues = ["LAB_ADMINS"] as const; +export const notificationAudienceValues = ["PI", "LAB_MANAGER"] as const; 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}, - 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, + }, +}); +// itemSchema holds information, previously defined schemas, and conversion information 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 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 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 default ItemModel; \ No newline at end of file +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/package-lock.json b/package-lock.json index 737f487..db9c65b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@radix-ui/react-slot": "^1.1.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "dotenv": "^17.3.1", "lucide-react": "^0.464.0", "mongoose": "^8.9.0", "next": "^16.1.6", @@ -4321,6 +4322,18 @@ "license": "MIT", "peer": true }, + "node_modules/dotenv": { + "version": "17.4.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz", + "integrity": "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -10458,10 +10471,11 @@ } }, "node_modules/typescript": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", - "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, + "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index b1ed7a6..e361ffb 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@radix-ui/react-slot": "^1.1.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "dotenv": "^17.3.1", "lucide-react": "^0.464.0", "mongoose": "^8.9.0", "next": "^16.1.6", 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..efc40d1 --- /dev/null +++ b/services/inventory/package.json @@ -0,0 +1,4 @@ +{ + "name": "inventory", + "private": true + } \ No newline at end of file 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..c01dbd2 --- /dev/null +++ b/services/items.ts @@ -0,0 +1,121 @@ +import { connect } from "@/lib/db"; +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 { + await connect(); + const items = await ItemModel.find().lean().exec(); + return items.map(item => toItemFromLean(item)); +} + +/** + * 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 connect(); + 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 + * @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 { + await connect(); + const item = await ItemModel.findById(id).lean().exec(); + return item ? toItemFromLean(item) : null; +} + +/** + * 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 connect(); + 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 connect(); + const updated = await ItemModel.findByIdAndUpdate(id, data, { + new: true, + runValidators: true, + }).exec(); + return updated ? toItem(updated) : null; +} + +/** + * 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 connect(); + const deleted = await ItemModel.findByIdAndDelete(id).exec(); + return Boolean(deleted); +}