Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
36 changes: 36 additions & 0 deletions services/hardware/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -90,6 +125,7 @@ model User {
haveID Boolean @default(false)
name String @default("")
requests Request[]
checkouts Checkout[]

@@map("user")
}
6 changes: 6 additions & 0 deletions services/hardware/src/permission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,23 @@ 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");
}

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");
Expand Down
99 changes: 99 additions & 0 deletions services/hardware/src/routes/checkout.ts
Original file line number Diff line number Diff line change
@@ -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);
})
);
4 changes: 4 additions & 0 deletions services/hardware/src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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);
85 changes: 85 additions & 0 deletions services/hardware/src/routes/inventory.ts
Original file line number Diff line number Diff line change
@@ -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);
})
);
18 changes: 11 additions & 7 deletions services/hexathons/src/common/util.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -78,6 +81,7 @@ export const getHexathonUserWithUpdatedPoints = async (
},
{
new: true,
session,
}
);

Expand Down
Loading
Loading