diff --git a/src/app.ts b/src/app.ts index 6da05cb..8625c6b 100644 --- a/src/app.ts +++ b/src/app.ts @@ -15,6 +15,7 @@ import eventsRouter from "./services/events/events-router"; import notificationsRouter from "./services/notifications/notifications-router"; import registrationRouter from "./services/registration/registration-router"; import s3Router from "./services/s3/s3-router"; +import statsRouter from "./services/stats/stats-router"; import subscriptionRouter from "./services/subscription/subscription-router"; const app = express(); @@ -39,6 +40,7 @@ app.use("/events", eventsRouter); app.use("/notifications", notificationsRouter); app.use("/registration", registrationRouter); app.use("/s3", s3Router); +app.use("/stats", statsRouter); app.use("/subscription", subscriptionRouter); app.get("/status", (_, res) => { diff --git a/src/database.ts b/src/database.ts index 42fdb60..efb5041 100644 --- a/src/database.ts +++ b/src/database.ts @@ -4,7 +4,10 @@ import { AttendeeValidator, } from "./services/attendees/attendee-schema"; import { RoleValidator, RoleSchema } from "./services/auth/auth-schema"; -import { EventSchema, EventValidator } from "./services/events/events-schema"; +import { + EventSchema, + privateEventValidator, +} from "./services/events/events-schema"; import { RegistrationSchema, RegistrationValidator, @@ -51,7 +54,7 @@ function initializeModel( // Example usage export const Database = { ROLES: initializeModel("roles", RoleSchema, RoleValidator), - EVENTS: initializeModel("events", EventSchema, EventValidator), + EVENTS: initializeModel("events", EventSchema, privateEventValidator), SUBSCRIPTIONS: initializeModel( "subscriptions", SubscriptionSchema, diff --git a/src/services/attendees/attendee-schema.ts b/src/services/attendees/attendee-schema.ts index 2b7b0db..443572f 100644 --- a/src/services/attendees/attendee-schema.ts +++ b/src/services/attendees/attendee-schema.ts @@ -6,8 +6,18 @@ const AttendeeValidator = z.object({ userId: z.string(), name: z.string(), email: z.string().email(), + events: z.array(z.string()), dietaryRestrictions: z.string().array(), + allergies: z.string().array(), + hasCheckedIn: z.boolean().default(false), points: z.number().min(0).default(0), + hasPriority: z.object({ + dayOne: z.boolean(), + dayTwo: z.boolean(), + dayThree: z.boolean(), + dayFour: z.boolean(), + dayFive: z.boolean(), + }), }); // Mongoose schema for attendee @@ -15,8 +25,24 @@ const AttendeeSchema = new mongoose.Schema({ userId: { type: String, required: true, unique: true }, name: { type: String, required: true }, email: { type: String, required: true, unique: true }, - dietaryRestrictions: [{ type: String, required: true }], + events: [{ type: mongoose.Schema.Types.ObjectId, ref: "Event" }], + dietaryRestrictions: { type: [String], required: true }, + allergies: { type: [String], required: true }, + hasCheckedIn: { type: Boolean, default: false }, points: { type: Number, default: 0 }, + hasPriority: { + type: new mongoose.Schema( + { + dayOne: { type: Boolean, default: false }, + dayTwo: { type: Boolean, default: false }, + dayThree: { type: Boolean, default: false }, + dayFour: { type: Boolean, default: false }, + dayFive: { type: Boolean, default: false }, + }, + { _id: false } + ), + default: () => ({}), + }, }); export { AttendeeSchema, AttendeeValidator }; diff --git a/src/services/events/events-router.ts b/src/services/events/events-router.ts index 4da7327..6deaeca 100644 --- a/src/services/events/events-router.ts +++ b/src/services/events/events-router.ts @@ -1,6 +1,6 @@ import { Router } from "express"; import { StatusCodes } from "http-status-codes"; -import { EventValidator } from "./events-schema"; +import { publicEventValidator } from "./events-schema"; import { Database } from "../../database"; const eventsRouter = Router(); @@ -28,7 +28,7 @@ eventsRouter.get("/currentOrNext", async (req, res, next) => { eventsRouter.post("/", async (req, res, next) => { try { - const validatedData = EventValidator.parse(req.body); + const validatedData = publicEventValidator.parse(req.body); const event = new Database.EVENTS(validatedData); await event.save(); return res.sendStatus(StatusCodes.CREATED); @@ -40,15 +40,19 @@ eventsRouter.post("/", async (req, res, next) => { eventsRouter.get("/:EVENTID", async (req, res, next) => { const eventId = req.params.EVENTID; try { - const event = await Database.EVENTS.findOne({ eventId: eventId }); + const unfiltered_event = await Database.EVENTS.findOne({ + eventId: eventId, + }); - if (!event) { + if (!unfiltered_event) { return res .status(StatusCodes.NOT_FOUND) .json({ error: "DoesNotExist" }); } - return res.status(StatusCodes.OK).json(event.toObject()); + const filtered_event = publicEventValidator.parse(unfiltered_event); + + return res.status(StatusCodes.OK).json(filtered_event); } catch (error) { next(error); } @@ -57,8 +61,11 @@ eventsRouter.get("/:EVENTID", async (req, res, next) => { // Get all events eventsRouter.get("/", async (req, res, next) => { try { - const events = await Database.EVENTS.find(); - return res.status(StatusCodes.OK).json(events); + const unfiltered_events = await Database.EVENTS.find(); + const filtered_events = unfiltered_events.map((unfiltered_event) => { + return publicEventValidator.parse(unfiltered_event.toJSON()); + }); + return res.status(StatusCodes.OK).json(filtered_events); } catch (error) { next(error); } diff --git a/src/services/events/events-schema.ts b/src/services/events/events-schema.ts index 0d447f7..92ca48f 100644 --- a/src/services/events/events-schema.ts +++ b/src/services/events/events-schema.ts @@ -4,8 +4,8 @@ import { v4 as uuidv4 } from "uuid"; export const EventType = z.enum(["A", "B", "C"]); -export const EventValidator = z.object({ - eventId: z.coerce.string().optional(), +export const publicEventValidator = z.object({ + eventId: z.coerce.string(), name: z.string(), startTime: z.coerce.date(), endTime: z.coerce.date(), @@ -17,6 +17,10 @@ export const EventValidator = z.object({ eventType: EventType, }); +export const privateEventValidator = publicEventValidator.extend({ + attendanceCount: z.number(), +}); + export const EventSchema = new Schema({ eventId: { type: String, @@ -56,6 +60,10 @@ export const EventSchema = new Schema({ type: Boolean, default: false, }, + attendanceCount: { + type: Number, + default: 0, + }, eventType: { type: String, required: true, diff --git a/src/services/registration/registration-schema.ts b/src/services/registration/registration-schema.ts index bc5b381..5c9e4e7 100644 --- a/src/services/registration/registration-schema.ts +++ b/src/services/registration/registration-schema.ts @@ -10,6 +10,7 @@ const RegistrationValidator = z.object({ graduation: z.string().nullable().optional(), major: z.string().nullable().optional(), dietaryRestrictions: z.string().array(), + allergies: z.string().array(), age: z.number().nullable().optional(), gender: z.string().nullable().optional(), race: z.array(z.string()).nullable().optional(), @@ -33,6 +34,7 @@ const RegistrationSchema = new mongoose.Schema({ graduation: { type: String, default: null }, major: { type: String, default: null }, dietaryRestrictions: [{ type: String, required: true }], + allergies: [{ type: String, required: true }], age: { type: Number, default: null }, gender: { type: String, default: null }, race: [{ type: String }], diff --git a/src/services/stats/stats-router.ts b/src/services/stats/stats-router.ts new file mode 100644 index 0000000..84af89a --- /dev/null +++ b/src/services/stats/stats-router.ts @@ -0,0 +1,195 @@ +import { Router } from "express"; +import { StatusCodes } from "http-status-codes"; +import { Database } from "../../database"; +import RoleChecker from "../../middleware/role-checker"; +import { Role } from "../auth/auth-models"; + +const statsRouter = Router(); + +// Get the number of people checked in (staff only) +statsRouter.get( + "/check-in", + RoleChecker([Role.enum.STAFF], false), + async (req, res, next) => { + try { + const attendees = await Database.ATTENDEES.find({ + events: { $ne: [] }, + }); + + return res.status(StatusCodes.OK).json({ count: attendees.length }); + } catch (error) { + next(error); + } + } +); + +// Get the number of people eligible for merch item (staff only) +statsRouter.get( + "/merch-item/:PRICE", + RoleChecker([Role.enum.STAFF], false), + async (req, res, next) => { + try { + const price = req.params.PRICE; + if (!price) { + return res + .status(StatusCodes.BAD_REQUEST) + .json({ error: "MissingPriceParameter" }); + } + const attendees = await Database.ATTENDEES.find({ + points: { $gte: price }, + }); + + return res.status(StatusCodes.OK).json({ count: attendees.length }); + } catch (error) { + next(error); + } + } +); + +// Get the number of priority attendees (staff only) +statsRouter.get( + "/priority-attendee", + RoleChecker([Role.enum.STAFF], false), + async (req, res, next) => { + try { + const attendees = await Database.ATTENDEES.find({ + hasCheckedIn: true, + }); + + return res.status(StatusCodes.OK).json({ count: attendees.length }); + } catch (error) { + next(error); + } + } +); + +// Get the attendance of the past n events (staff only) +statsRouter.get( + "/attendance/:N", + RoleChecker([Role.enum.STAFF], false), + async (req, res, next) => { + try { + const numEvents = req.params.N; + if (!numEvents) { + return res + .status(StatusCodes.BAD_REQUEST) + .json({ error: "MissingNParameter" }); + } + const currentTime = new Date(); + const events = await Database.EVENTS.find({ + endTime: { $lt: currentTime }, + }) + .sort({ endTime: -1 }) + .limit(parseInt(numEvents)); + + const attendanceCounts = events.map( + (event) => event.attendanceCount + ); + + return res + .status(StatusCodes.OK) + .json({ attendanceCounts: attendanceCounts }); + } catch (error) { + next(error); + } + } +); + +// Get the dietary restriction breakdown/counts (staff only) +statsRouter.get( + "/dietary-restrictions", + RoleChecker([Role.enum.STAFF], true), + async (req, res, next) => { + try { + const results = await Promise.allSettled([ + Database.ATTENDEES.countDocuments({ + allergies: { $size: 0 }, + dietaryRestrictions: { $size: 0 }, + }), + Database.ATTENDEES.countDocuments({ + allergies: { $size: 0 }, + dietaryRestrictions: { $ne: [] }, + }), + Database.ATTENDEES.countDocuments({ + allergies: { $ne: [] }, + dietaryRestrictions: { $size: 0 }, + }), + Database.ATTENDEES.countDocuments({ + allergies: { $ne: [] }, + dietaryRestrictions: { $ne: [] }, + }), + Database.ATTENDEES.aggregate([ + { + $unwind: "$allergies", + }, + { + $group: { + _id: "$allergies", + count: { $sum: 1 }, + }, + }, + ]), + Database.ATTENDEES.aggregate([ + { + $unwind: "$dietaryRestrictions", + }, + { + $group: { + _id: "$dietaryRestrictions", + count: { $sum: 1 }, + }, + }, + ]), + ]); + + for (let i = 0; i < results.length; i++) { + if (results[i].status === "rejected") { + return res + .status(StatusCodes.INTERNAL_SERVER_ERROR) + .send({ error: "InternalError" }); + } + } + + type mongoQueryType = { + _id: string; + count: number; + }; + const allergyCounts: { [key: string]: number } = {}; + const unprocessedAllergyCounts = ( + results[4] as PromiseFulfilledResult + ).value; + for (let i = 0; i < unprocessedAllergyCounts.length; i++) { + allergyCounts[unprocessedAllergyCounts[i]._id as string] = + unprocessedAllergyCounts[i].count; + } + const dietaryRestrictionCounts: { [key: string]: number } = {}; + const unprocessedDietaryRestrictionCountss = ( + results[5] as PromiseFulfilledResult + ).value; + for ( + let i = 0; + i < unprocessedDietaryRestrictionCountss.length; + i++ + ) { + dietaryRestrictionCounts[ + unprocessedDietaryRestrictionCountss[i]._id as string + ] = unprocessedDietaryRestrictionCountss[i].count; + } + + return res.status(StatusCodes.OK).json({ + none: (results[0] as PromiseFulfilledResult).value, + dietaryRestrictions: ( + results[1] as PromiseFulfilledResult + ).value, + allergies: (results[2] as PromiseFulfilledResult).value, + both: (results[3] as PromiseFulfilledResult).value, + allergyCounts: allergyCounts, + dietaryRestrictionCounts: dietaryRestrictionCounts, + }); + } catch (error) { + next(error); + } + } +); + +export default statsRouter;