diff --git a/services/hardware/prisma/migrations/20260918120000_inventory_checkouts/migration.sql b/services/hardware/prisma/migrations/20260918120000_inventory_checkouts/migration.sql new file mode 100644 index 00000000..57b833d6 --- /dev/null +++ b/services/hardware/prisma/migrations/20260918120000_inventory_checkouts/migration.sql @@ -0,0 +1,33 @@ +-- CreateTable +CREATE TABLE "inventory" ( + "id" SERIAL NOT NULL, + "name" TEXT NOT NULL, + "event" TEXT, + "size" TEXT, + "quantity" INTEGER NOT NULL, + "availableQuantity" INTEGER NOT NULL, + "notes" TEXT, + "createdAt" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(6) NOT NULL, + CONSTRAINT "inventory_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "checkout" ( + "id" SERIAL NOT NULL, + "quantity" INTEGER NOT NULL, + "checkedOutAt" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "returnedAt" TIMESTAMPTZ(6), + "inventoryId" INTEGER NOT NULL, + "userId" TEXT NOT NULL, + "itemId" INTEGER, + CONSTRAINT "checkout_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "inventory_event_size_idx" ON "inventory"("event", "size"); +CREATE INDEX "checkout_inventoryId_returnedAt_idx" ON "checkout"("inventoryId", "returnedAt"); +CREATE INDEX "checkout_userId_returnedAt_idx" ON "checkout"("userId", "returnedAt"); + +ALTER TABLE "checkout" ADD CONSTRAINT "checkout_inventoryId_fkey" FOREIGN KEY ("inventoryId") REFERENCES "inventory"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "checkout" ADD CONSTRAINT "checkout_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("userId") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "checkout" ADD CONSTRAINT "checkout_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "item"("id") ON DELETE SET NULL ON UPDATE CASCADE; \ No newline at end of file diff --git a/services/hardware/prisma/schema.prisma b/services/hardware/prisma/schema.prisma index 88326bde..02c72730 100644 --- a/services/hardware/prisma/schema.prisma +++ b/services/hardware/prisma/schema.prisma @@ -43,6 +43,7 @@ model Item { category Category @relation(fields: [categoryId], references: [id]) location Location @relation(fields: [locationId], references: [id]) requests Request[] + checkouts Checkout[] categoryId Int locationId Int @@ -78,6 +79,40 @@ model Request { @@map("request") } +model Inventory { + id Int @id @default(autoincrement()) + name String + event String? + size String? + quantity Int + availableQuantity Int + notes String? + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt @db.Timestamptz(6) + checkouts Checkout[] + + @@index([event, size]) + @@map("inventory") +} + +model Checkout { + id Int @id @default(autoincrement()) + quantity Int + checkedOutAt DateTime @default(now()) @db.Timestamptz(6) + returnedAt DateTime? @db.Timestamptz(6) + inventory Inventory @relation(fields: [inventoryId], references: [id]) + user User @relation(fields: [userId], references: [userId]) + item Item? @relation(fields: [itemId], references: [id]) + + inventoryId Int + userId String + itemId Int? + + @@index([inventoryId, returnedAt]) + @@index([userId, returnedAt]) + @@map("checkout") +} + model Setting { id Int @id @default(autoincrement()) isHardwareRequestsAllowed Boolean @default(false) @@ -90,6 +125,7 @@ model User { haveID Boolean @default(false) name String @default("") requests Request[] + checkouts Checkout[] @@map("user") } diff --git a/services/hardware/src/permission.ts b/services/hardware/src/permission.ts index 33fdaf12..4665ed6a 100644 --- a/services/hardware/src/permission.ts +++ b/services/hardware/src/permission.ts @@ -13,10 +13,14 @@ export const addAbilities = (): RequestHandler => (req, res, next) => { if (req.user.roles.admin) { can("manage", "Location"); can("manage", "Category"); + can("manage", "Inventory"); + can("manage", "Checkout"); } if (req.user.roles.member) { can("manage", "Item"); + can("manage", "Inventory"); + can("manage", "Checkout"); can("read", "Location"); can("manage", "HardwareRequest"); can("manage", "HardwareSetting"); @@ -24,6 +28,8 @@ export const addAbilities = (): RequestHandler => (req, res, next) => { can("read", "Item"); can("read", "Category"); + can("read", "Inventory"); + can("read", "Checkout"); can(["read", "create"], "HardwareRequest"); can("delete", "HardwareRequest", { userId: req.user.uid }); can("read", "HardwareSetting"); diff --git a/services/hardware/src/routes/checkout.ts b/services/hardware/src/routes/checkout.ts new file mode 100644 index 00000000..907294a0 --- /dev/null +++ b/services/hardware/src/routes/checkout.ts @@ -0,0 +1,99 @@ +import { asyncHandler, BadRequestError, checkAbility } from "@api/common"; +import express from "express"; + +import { prisma } from "../common"; + +export const checkoutRouter = express.Router(); + +checkoutRouter.route("/").get( + checkAbility("read", "Checkout"), + asyncHandler(async (req, res) => { + const requestedUserId = req.query.userId ? String(req.query.userId) : undefined; + const userId = req.user?.roles.admin || req.user?.roles.member ? requestedUserId : req.user?.uid; + const checkouts = await prisma.checkout.findMany({ + where: { userId }, + include: { inventory: true, user: true, item: true }, + orderBy: { checkedOutAt: "desc" }, + }); + + res.status(200).send(checkouts); + }) +); + +checkoutRouter.route("/").post( + checkAbility("create", "Checkout"), + asyncHandler(async (req, res) => { + const inventoryId = Number(req.body.inventoryId); + const quantity = Number(req.body.quantity); + const userId = typeof req.body.userId === "string" ? req.body.userId : req.user?.uid; + + if (!Number.isInteger(inventoryId) || inventoryId < 1) { + throw new BadRequestError("A valid inventory ID is required"); + } + if (!Number.isInteger(quantity) || quantity < 1) { + throw new BadRequestError("Checkout quantity must be a positive integer"); + } + if (!userId) { + throw new BadRequestError("A user ID is required"); + } + + const checkout = await prisma.$transaction(async transaction => { + const updated = await transaction.inventory.updateMany({ + where: { id: inventoryId, availableQuantity: { gte: quantity } }, + data: { availableQuantity: { decrement: quantity } }, + }); + if (updated.count !== 1) { + throw new BadRequestError("Not enough inventory is available"); + } + + await transaction.user.upsert({ + where: { userId }, + update: { name: typeof req.body.name === "string" ? req.body.name : undefined }, + create: { userId, name: typeof req.body.name === "string" ? req.body.name : "" }, + }); + + return transaction.checkout.create({ + data: { inventoryId, quantity, userId, itemId: req.body.itemId ? Number(req.body.itemId) : undefined }, + include: { inventory: true, user: true, item: true }, + }); + }); + + res.status(201).send(checkout); + }) +); + +checkoutRouter.route("/:id/return").post( + checkAbility("update", "Checkout"), + asyncHandler(async (req, res) => { + const id = Number(req.params.id); + if (!Number.isInteger(id) || id < 1) { + throw new BadRequestError("A valid checkout ID is required"); + } + + const checkout = await prisma.$transaction(async transaction => { + const current = await transaction.checkout.findUnique({ where: { id } }); + if (!current || current.returnedAt) { + throw new BadRequestError("Checkout does not exist or has already been returned"); + } + + await transaction.inventory.update({ + where: { id: current.inventoryId }, + data: { availableQuantity: { increment: current.quantity } }, + }); + const closed = await transaction.checkout.updateMany({ + where: { id, returnedAt: null }, + data: { returnedAt: new Date() }, + }); + if (closed.count !== 1) { + throw new BadRequestError("Checkout does not exist or has already been returned"); + } + + return transaction.checkout.findUniqueOrThrow({ + where: { id }, + include: { inventory: true, user: true, item: true }, + }); + }); + + res.status(200).send(checkout); + }) +); \ No newline at end of file diff --git a/services/hardware/src/routes/index.ts b/services/hardware/src/routes/index.ts index 3e2db7b1..474c76a6 100644 --- a/services/hardware/src/routes/index.ts +++ b/services/hardware/src/routes/index.ts @@ -6,6 +6,8 @@ import { locationRouter } from "./location"; import { hardwareRequestRouter } from "./hardware-request"; import { userRoutes } from "./user"; import { hardwareSettingRoutes } from "./hardware-setting"; +import { inventoryRouter } from "./inventory"; +import { checkoutRouter } from "./checkout"; export const defaultRouter = express.Router(); @@ -15,3 +17,5 @@ defaultRouter.use("/locations", locationRouter); defaultRouter.use("/hardware-requests", hardwareRequestRouter); defaultRouter.use("/users", userRoutes); defaultRouter.use("/hardware-settings", hardwareSettingRoutes); +defaultRouter.use("/inventory", inventoryRouter); +defaultRouter.use("/checkouts", checkoutRouter); diff --git a/services/hardware/src/routes/inventory.ts b/services/hardware/src/routes/inventory.ts new file mode 100644 index 00000000..d1386ed8 --- /dev/null +++ b/services/hardware/src/routes/inventory.ts @@ -0,0 +1,85 @@ +import { asyncHandler, BadRequestError, checkAbility } from "@api/common"; +import express from "express"; + +import { prisma } from "../common"; + +export const inventoryRouter = express.Router(); + +inventoryRouter.route("/").get( + checkAbility("read", "Inventory"), + asyncHandler(async (_req, res) => { + const inventory = await prisma.inventory.findMany({ + orderBy: [{ event: "asc" }, { name: "asc" }, { size: "asc" }], + }); + + res.status(200).send(inventory); + }) +); + +inventoryRouter.route("/").post( + checkAbility("create", "Inventory"), + asyncHandler(async (req, res) => { + const { name, event, size, quantity, notes } = req.body; + const parsedQuantity = Number(quantity); + + if (typeof name !== "string" || !name.trim()) { + throw new BadRequestError("Inventory name is required"); + } + if (!Number.isInteger(parsedQuantity) || parsedQuantity < 0) { + throw new BadRequestError("Inventory quantity must be a non-negative integer"); + } + + const inventory = await prisma.inventory.create({ + data: { + name: name.trim(), + event: typeof event === "string" && event.trim() ? event.trim() : null, + size: typeof size === "string" && size.trim() ? size.trim() : null, + quantity: parsedQuantity, + availableQuantity: parsedQuantity, + notes: typeof notes === "string" ? notes.trim() : null, + }, + }); + + res.status(201).send(inventory); + }) +); + +inventoryRouter.route("/:id").patch( + checkAbility("update", "Inventory"), + asyncHandler(async (req, res) => { + const id = Number(req.params.id); + const quantity = req.body.quantity === undefined ? undefined : Number(req.body.quantity); + + if (!Number.isInteger(id) || id < 1) { + throw new BadRequestError("A valid inventory ID is required"); + } + if (quantity !== undefined && (!Number.isInteger(quantity) || quantity < 0)) { + throw new BadRequestError("Inventory quantity must be a non-negative integer"); + } + + const current = await prisma.inventory.findUnique({ where: { id } }); + if (!current) { + throw new BadRequestError("Inventory item does not exist"); + } + if (quantity !== undefined && quantity < current.quantity - current.availableQuantity) { + throw new BadRequestError("Quantity cannot be less than the amount currently checked out"); + } + + const inventory = await prisma.inventory.update({ + where: { id }, + data: { + name: typeof req.body.name === "string" ? req.body.name.trim() : undefined, + event: typeof req.body.event === "string" ? req.body.event.trim() : undefined, + size: typeof req.body.size === "string" ? req.body.size.trim() : undefined, + notes: typeof req.body.notes === "string" ? req.body.notes.trim() : undefined, + quantity, + availableQuantity: + quantity === undefined + ? undefined + : quantity - (current.quantity - current.availableQuantity), + }, + }); + + res.status(200).send(inventory); + }) +); \ No newline at end of file diff --git a/services/hexathons/src/common/util.ts b/services/hexathons/src/common/util.ts index 0ed91f51..30d51008 100644 --- a/services/hexathons/src/common/util.ts +++ b/services/hexathons/src/common/util.ts @@ -1,5 +1,6 @@ import { BadRequestError } from "@api/common"; import express from "express"; +import { ClientSession } from "mongoose"; import { EventType } from "../models/event"; import { HexathonUserModel } from "../models/hexathonUser"; @@ -28,23 +29,25 @@ export const EVENT_TYPE_POINTS: { [key in EventType]: number } = { export const getHexathonUserWithUpdatedPoints = async ( req: express.Request, userId: string, - hexathon: string + hexathon: string, + session?: ClientSession ) => { - const hexathonUser = await HexathonUserModel.accessibleBy(req.ability).findOne({ + const userQuery = HexathonUserModel.accessibleBy(req.ability).findOne({ userId, hexathon, }); + if (session) userQuery.session(session); + const hexathonUser = await userQuery; if (!hexathonUser) { throw new BadRequestError("You do not have access or invalid params provided."); } // Load user events - const interactions = await InteractionModel.accessibleBy(req.ability) - .find({ - userId, - hexathon, - }) + const interactionQuery = InteractionModel.accessibleBy(req.ability) + .find({ userId, hexathon }) .populate("event"); + if (session) interactionQuery.session(session); + const interactions = await interactionQuery; // Calculate points const points = interactions.reduce((prev, interaction) => { @@ -78,6 +81,7 @@ export const getHexathonUserWithUpdatedPoints = async ( }, { new: true, + session, } ); diff --git a/services/hexathons/src/routes/hexathon-users.ts b/services/hexathons/src/routes/hexathon-users.ts index 6bc7d54d..eb717663 100644 --- a/services/hexathons/src/routes/hexathon-users.ts +++ b/services/hexathons/src/routes/hexathon-users.ts @@ -8,7 +8,7 @@ import { } from "@api/common"; import _ from "lodash"; import { Service } from "@api/config"; -import { FilterQuery, isValidObjectId, Types } from "mongoose"; +import mongoose, { FilterQuery, isValidObjectId, Types } from "mongoose"; import { CommitmentType, HexathonUser, HexathonUserModel } from "../models/hexathonUser"; import { getHexathonUserWithUpdatedPoints } from "../common/util"; @@ -235,55 +235,78 @@ hexathonUserRouter.route("/:hexathonId/users/:userId/actions/check-valid-user"). hexathonUserRouter.route("/:hexathonId/users/:userId/actions/purchase-swag-item").post( checkAbility("manage", "HexathonUser"), asyncHandler(async (req, res) => { - const { swagItemId } = req.body; - const quantity = parseInt(req.body.quantity); - - const hexathonUser = await getHexathonUserWithUpdatedPoints( - req, - req.params.userId, - req.params.hexathonId - ); - - const swagItem = await SwagItemModel.findOne({ - hexathon: req.params.hexathonId, - _id: swagItemId, - }); - - if (!swagItem) { - throw new BadRequestError("Invalid swag item id provided."); + if (!req.user?.roles?.admin) { + throw new BadRequestError("Only admins can check out swag items."); } - if (swagItem.purchased + quantity > swagItem.capacity) { - throw new BadRequestError("Swag item is full."); + const { swagItemId } = req.body; + if (typeof swagItemId !== "string" || !isValidObjectId(swagItemId)) { + throw new BadRequestError("Invalid swag item id provided."); } + const safeSwagItemId = new Types.ObjectId(swagItemId); - if (swagItem.points * quantity > hexathonUser.points.currentTotal) { - throw new BadRequestError("User does not have enough points to purchase this swag item."); + const quantity = Number(req.body.quantity); + if (!Number.isInteger(quantity) || quantity < 1) { + throw new BadRequestError("Quantity must be a positive integer."); } - await HexathonUserModel.findOneAndUpdate( - { - userId: req.params.userId, - hexathon: req.params.hexathonId, - }, - { - "points.numSpent": hexathonUser.points.numSpent + swagItem.points * quantity, - "$push": { - purchasedSwagItems: { - swagItemId, - quantity, - timestamp: new Date(), + const session = await mongoose.startSession(); + try { + await session.withTransaction(async () => { + const hexathonUser = await getHexathonUserWithUpdatedPoints( + req, + req.params.userId, + req.params.hexathonId, + session + ); + const swagItem = await SwagItemModel.findOne({ + hexathon: req.params.hexathonId, + _id: safeSwagItemId, + }).session(session); + + if (!swagItem) { + throw new BadRequestError("Invalid swag item id provided."); + } + + const pointsCost = swagItem.points * quantity; + if (pointsCost > hexathonUser.points.currentTotal) { + throw new BadRequestError("User does not have enough points to purchase this swag item."); + } + + const itemUpdate = await SwagItemModel.updateOne( + { + _id: swagItem._id, + hexathon: req.params.hexathonId, + purchased: { $lte: swagItem.capacity - quantity }, }, - }, - }, - { - new: true, - } - ); - - await SwagItemModel.findByIdAndUpdate(swagItem.id, { - purchased: (swagItem.purchased || 0) + quantity, - }); + { $inc: { purchased: quantity } }, + { session } + ); + if (itemUpdate.modifiedCount !== 1) { + throw new BadRequestError("Swag item is full."); + } + + const userUpdate = await HexathonUserModel.updateOne( + { _id: hexathonUser._id }, + { + $inc: { "points.numSpent": pointsCost }, + $push: { + purchasedSwagItems: { + swagItemId: safeSwagItemId, + quantity, + timestamp: new Date(), + }, + }, + }, + { session } + ); + if (userUpdate.modifiedCount !== 1) { + throw new BadRequestError("There was an error recording the swag purchase."); + } + }); + } finally { + await session.endSession(); + } return res.sendStatus(204); })