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..e365386 --- /dev/null +++ b/app/api/inventory/[id]/route.ts @@ -0,0 +1,136 @@ +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][]]); + +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 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..e83baf1 --- /dev/null +++ b/app/api/inventory/route.ts @@ -0,0 +1,85 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { + categoryValues, + notificationAudienceValues, + notificationEventValues, +} from "@/models/Item"; +import { addItem, getItems } from "@/services/items"; +import { connectToDatabase } from "@/lib/mongoose"; + +// 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(), + 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 connectionResponse = await connect(); + // if (connectionResponse) return connectionResponse; ? + + 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 connectionResponse = await connect(); + + 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/models/Item.ts b/models/Item.ts index 42f729e..f96c053 100644 --- a/models/Item.ts +++ b/models/Item.ts @@ -1,4 +1,13 @@ -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; @@ -9,49 +18,70 @@ 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/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..50631f9 --- /dev/null +++ b/services/items.ts @@ -0,0 +1,116 @@ +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().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 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 + * @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).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 { + 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 { + 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 { + const deleted = await ItemModel.findByIdAndDelete(id).exec(); + return Boolean(deleted); +}