From 12341f6fb7dbc9267588d0b167e448fdf1a07eef Mon Sep 17 00:00:00 2001 From: Jasmine Date: Tue, 28 Oct 2025 04:06:19 -0500 Subject: [PATCH 1/7] Created Team table, created Staff info table that builds on existing user info table, and all associated routes and tests --- src/app.ts | 2 + src/common/models.ts | 13 +- src/services/staff/staff-router.test.ts | 330 +++++++++++++++++++++++- src/services/staff/staff-router.ts | 204 ++++++++++++++- src/services/staff/staff-schemas.ts | 46 +++- src/services/team/team-router.test.ts | 141 ++++++++++ src/services/team/team-router.ts | 167 ++++++++++++ src/services/team/team-schemas.ts | 22 ++ src/services/user/user-router.ts | 12 +- src/services/user/user-schemas.ts | 17 +- 10 files changed, 947 insertions(+), 7 deletions(-) create mode 100644 src/services/team/team-router.test.ts create mode 100644 src/services/team/team-router.ts create mode 100644 src/services/team/team-schemas.ts diff --git a/src/app.ts b/src/app.ts index a7cd5ee4..e5dce439 100644 --- a/src/app.ts +++ b/src/app.ts @@ -21,6 +21,7 @@ import versionRouter from "./services/version/version-router"; import userRouter from "./services/user/user-router"; import sponsorRouter from "./services/sponsor/sponsor-router"; import statisticRouter from "./services/statistic/statistic-router"; +import teamRouter from "./services/team/team-router"; // import { InitializeConfigReader } from "./middleware/config-reader"; import { ErrorHandler } from "./middleware/error-handler"; @@ -80,6 +81,7 @@ app.use("/shop/", shopRouter); app.use("/sponsor/", sponsorRouter); app.use("/staff/", staffRouter); app.use("/statistic/", statisticRouter); +app.use("/team/", teamRouter); app.use("/version/", versionRouter); app.use("/user/", userRouter); diff --git a/src/common/models.ts b/src/common/models.ts index 4c1e5a72..7c8f1f52 100644 --- a/src/common/models.ts +++ b/src/common/models.ts @@ -15,11 +15,12 @@ import { import { ShopHistory, ShopItem, ShopOrder } from "../services/shop/shop-schemas"; import { UserAttendance, UserFollowing, UserInfo } from "../services/user/user-schemas"; import { AnyParamConstructor, IModelOptions } from "@typegoose/typegoose/lib/types"; -import { StaffShift } from "../services/staff/staff-schemas"; +import { StaffShift, StaffInfo } from "../services/staff/staff-schemas"; import { NotificationMappings, NotificationMessages } from "../services/notification/notification-schemas"; import { PuzzleItem, PuzzleAnswer } from "../services/puzzle/puzzle-schemas"; import { Sponsor } from "../services/sponsor/sponsor-schemas"; import { StatisticLog } from "../services/statistic/statistic-schemas"; +import { Team } from "../services/team/team-schemas"; import Config from "./config"; import { RuntimeConfigModel } from "./runtimeConfig"; @@ -39,6 +40,7 @@ export enum Group { SPONSOR = "sponsor", STAFF = "staff", STATISTIC = "statistic", + TEAM = "team", USER = "user", } @@ -102,6 +104,11 @@ enum SponsorCollection { enum StaffCollection { SHIFT = "shift", + INFO = "info", +} + +enum TeamCollection { + TEAMS = "teams", } enum StatisticCollection { @@ -204,10 +211,14 @@ export default class Models { // Staff static StaffShift: Model = getModel(StaffShift, Group.STAFF, StaffCollection.SHIFT); + static StaffInfo: Model = getModel(StaffInfo, Group.STAFF, StaffCollection.INFO); // Statistic static StatisticLog: Model = getModel(StatisticLog, Group.STATISTIC, StatisticCollection.LOGS); + // Team + static Team: Model = getModel(Team, Group.TEAM, TeamCollection.TEAMS); + // User static UserInfo: Model = getModel(UserInfo, Group.USER, UserCollection.INFO); static UserAttendance: Model = getModel(UserAttendance, Group.USER, UserCollection.ATTENDANCE); diff --git a/src/services/staff/staff-router.test.ts b/src/services/staff/staff-router.test.ts index 951d6c2f..dfd7e361 100644 --- a/src/services/staff/staff-router.test.ts +++ b/src/services/staff/staff-router.test.ts @@ -1,5 +1,15 @@ import { beforeEach, describe, expect, it } from "@jest/globals"; -import { putAsAttendee, putAsStaff, TESTER } from "../../common/testTools"; +import { + putAsAttendee, + putAsStaff, + getAsAttendee, + postAsAttendee, + postAsAdmin, + putAsAdmin, + delAsAdmin, + delAsAttendee, + TESTER, +} from "../../common/testTools"; import { Event, EventAttendance, EventType } from "../event/event-schemas"; import { StatusCode } from "status-code-enum"; @@ -14,6 +24,8 @@ import { } from "../registration/registration-schemas"; import { AttendeeProfile } from "../profile/profile-schemas"; import { generateQRCode } from "../user/user-lib"; +import { UserInfo } from "../user/user-schemas"; +import { Team } from "../team/team-schemas"; const TESTER_EVENT_ATTENDANCE = { eventId: "some-event", @@ -80,12 +92,328 @@ const TEST_EVENT = { isPro: false, } satisfies Event; +const TESTER_USER_INFO = { + userId: "testuser123", + name: "Test User", + email: "test@example.com", +} satisfies UserInfo; + +const TESTER_STAFF_INFO = { + title: "Systems Lead", + team: "Systems", + emoji: "💻", + profilePictureUrl: "https://example.com/profile.jpg", + quote: "Code is poetry", + isActive: true, +}; + +const TESTER_TEAM = { + name: "Systems", +} satisfies Team; + +const INACTIVE_STAFF_USER = { + userId: "inactivestaff456", + name: "Inactive Staff", + email: "inactive@example.com", +} satisfies UserInfo; + +const INACTIVE_STAFF_INFO = { + title: "Former Lead", + team: "Systems", + emoji: "👋", + profilePictureUrl: "https://example.com/inactive.jpg", + quote: "Goodbye", + isActive: false, +}; + // Before each test, initialize database with Event in EventAttendance beforeEach(async () => { await Models.EventAttendance.create(TESTER_EVENT_ATTENDANCE); await Models.RegistrationApplicationSubmitted.create(TESTER_REGISTRATION); await Models.AttendeeProfile.create(TESTER_PROFILE); await Models.Event.create(TEST_EVENT); + await Models.Team.create(TESTER_TEAM); + await Models.UserInfo.create(TESTER_USER_INFO); + await Models.UserInfo.create(INACTIVE_STAFF_USER); +}); + +describe("GET /staff/info/", () => { + it("returns all active staff members for public access", async () => { + const userInfo = await Models.UserInfo.findOne({ userId: TESTER_USER_INFO.userId }); + const team = await Models.Team.findOne({ name: TESTER_TEAM.name }); + + const staffInfo = await Models.StaffInfo.create({ + user: userInfo!._id, + title: TESTER_STAFF_INFO.title, + team: team!._id, + emoji: TESTER_STAFF_INFO.emoji, + profilePictureUrl: TESTER_STAFF_INFO.profilePictureUrl, + quote: TESTER_STAFF_INFO.quote, + isActive: true, + }); + + await Models.UserInfo.updateOne({ userId: TESTER_USER_INFO.userId }, { staffInfo: staffInfo._id }); + + const response = await getAsAttendee("/staff/info/").expect(StatusCode.SuccessOK); + + const data = JSON.parse(response.text); + expect(data.staff).toHaveLength(1); + expect(data.staff[0]).toMatchObject({ + userId: TESTER_USER_INFO.userId, + name: TESTER_USER_INFO.name, + title: TESTER_STAFF_INFO.title, + team: TESTER_TEAM.name, + emoji: TESTER_STAFF_INFO.emoji, + profilePictureUrl: TESTER_STAFF_INFO.profilePictureUrl, + quote: TESTER_STAFF_INFO.quote, + isActive: true, + }); + }); + + it("excludes inactive staff members", async () => { + const activeUser = await Models.UserInfo.findOne({ userId: TESTER_USER_INFO.userId }); + const inactiveUser = await Models.UserInfo.findOne({ userId: INACTIVE_STAFF_USER.userId }); + const team = await Models.Team.findOne({ name: TESTER_TEAM.name }); + + const activeStaff = await Models.StaffInfo.create({ + user: activeUser!._id, + title: TESTER_STAFF_INFO.title, + team: team!._id, + isActive: true, + }); + + const inactiveStaff = await Models.StaffInfo.create({ + user: inactiveUser!._id, + title: INACTIVE_STAFF_INFO.title, + team: team!._id, + isActive: false, + }); + + await Models.UserInfo.updateOne({ userId: TESTER_USER_INFO.userId }, { staffInfo: activeStaff._id }); + await Models.UserInfo.updateOne({ userId: INACTIVE_STAFF_USER.userId }, { staffInfo: inactiveStaff._id }); + + const response = await getAsAttendee("/staff/info/").expect(StatusCode.SuccessOK); + + const data = JSON.parse(response.text); + expect(data.staff).toHaveLength(1); + expect(data.staff[0].userId).toBe(TESTER_USER_INFO.userId); + }); + + it("returns empty array when no active staff exists", async () => { + const response = await getAsAttendee("/staff/info/").expect(StatusCode.SuccessOK); + + const data = JSON.parse(response.text); + expect(data.staff).toHaveLength(0); + }); +}); + +describe("POST /staff/info/", () => { + it("successfully creates staff info for a user", async () => { + const response = await postAsAdmin("/staff/info/") + .send({ + userId: TESTER_USER_INFO.userId, + ...TESTER_STAFF_INFO, + }) + .expect(StatusCode.SuccessOK); + + expect(JSON.parse(response.text)).toMatchObject({ success: true }); + + const userInfo = await Models.UserInfo.findOne({ userId: TESTER_USER_INFO.userId }).populate("staffInfo"); + expect(userInfo?.staffInfo).toBeDefined(); + + const staffInfo = await Models.StaffInfo.findOne({ user: userInfo!._id }).populate("team"); + expect(staffInfo).toBeDefined(); + expect(staffInfo?.title).toBe(TESTER_STAFF_INFO.title); + expect((staffInfo?.team as unknown as Team)?.name).toBe(TESTER_STAFF_INFO.team); + }); + + it("creates staff info without optional fields", async () => { + await postAsAdmin("/staff/info/") + .send({ + userId: TESTER_USER_INFO.userId, + title: "Co-Director", + team: "Systems", + }) + .expect(StatusCode.SuccessOK); + + const userInfo = await Models.UserInfo.findOne({ userId: TESTER_USER_INFO.userId }); + const staffInfo = await Models.StaffInfo.findOne({ user: userInfo!._id }); + + expect(staffInfo?.title).toBe("Co-Director"); + expect(staffInfo?.emoji).toBeUndefined(); + expect(staffInfo?.quote).toBeUndefined(); + expect(staffInfo?.isActive).toBe(true); // Default value + }); + + it("returns error when user does not exist", async () => { + const response = await postAsAdmin("/staff/info/") + .send({ + userId: "nonexistent123", + ...TESTER_STAFF_INFO, + }) + .expect(StatusCode.ClientErrorNotFound); + + expect(JSON.parse(response.text)).toMatchObject({ + error: "NotFound", + }); + }); + + it("rejects non-admin users", async () => { + await postAsAttendee("/staff/info/") + .send({ + userId: TESTER_USER_INFO.userId, + ...TESTER_STAFF_INFO, + }) + .expect(StatusCode.ClientErrorForbidden); + }); +}); + +describe("PUT /staff/info/", () => { + beforeEach(async () => { + const userInfo = await Models.UserInfo.findOne({ userId: TESTER_USER_INFO.userId }); + const team = await Models.Team.findOne({ name: TESTER_TEAM.name }); + + const staffInfo = await Models.StaffInfo.create({ + user: userInfo!._id, + title: TESTER_STAFF_INFO.title, + team: team!._id, + emoji: TESTER_STAFF_INFO.emoji, + profilePictureUrl: TESTER_STAFF_INFO.profilePictureUrl, + quote: TESTER_STAFF_INFO.quote, + isActive: true, + }); + + await Models.UserInfo.updateOne({ userId: TESTER_USER_INFO.userId }, { staffInfo: staffInfo._id }); + }); + + it("successfully updates staff info", async () => { + const updatedData = { + userId: TESTER_USER_INFO.userId, + title: "Lead Systems Engineer", + team: "Systems", + emoji: "🚀", + profilePictureUrl: "https://example.com/new-profile.jpg", + quote: "Updated quote", + isActive: true, + }; + + const response = await putAsAdmin("/staff/info/").send(updatedData).expect(StatusCode.SuccessOK); + + expect(JSON.parse(response.text)).toMatchObject({ success: true }); + + const userInfo = await Models.UserInfo.findOne({ userId: TESTER_USER_INFO.userId }); + const staffInfo = await Models.StaffInfo.findOne({ user: userInfo!._id }); + + expect(staffInfo?.title).toBe(updatedData.title); + expect(staffInfo?.emoji).toBe(updatedData.emoji); + expect(staffInfo?.quote).toBe(updatedData.quote); + }); + + it("can deactivate a staff member", async () => { + await putAsAdmin("/staff/info/") + .send({ + userId: TESTER_USER_INFO.userId, + title: TESTER_STAFF_INFO.title, + team: "Systems", + isActive: false, + }) + .expect(StatusCode.SuccessOK); + + const userInfo = await Models.UserInfo.findOne({ userId: TESTER_USER_INFO.userId }); + const staffInfo = await Models.StaffInfo.findOne({ user: userInfo!._id }); + + expect(staffInfo?.isActive).toBe(false); + }); + + it("returns error when user does not exist", async () => { + const response = await putAsAdmin("/staff/info/") + .send({ + userId: "nonexistent123", + ...TESTER_STAFF_INFO, + }) + .expect(StatusCode.ClientErrorNotFound); + + expect(JSON.parse(response.text)).toMatchObject({ + error: "NotFound", + }); + }); + + it("returns error when staff info does not exist", async () => { + const response = await putAsAdmin("/staff/info/") + .send({ + userId: INACTIVE_STAFF_USER.userId, + ...TESTER_STAFF_INFO, + }) + .expect(StatusCode.ClientErrorNotFound); + + expect(JSON.parse(response.text)).toMatchObject({ + error: "StaffNotFound", + }); + }); + + it("rejects non-admin users", async () => { + await putAsAttendee("/staff/info/") + .send({ + userId: TESTER_USER_INFO.userId, + ...TESTER_STAFF_INFO, + }) + .expect(StatusCode.ClientErrorForbidden); + }); +}); + +describe("DELETE /staff/info/", () => { + beforeEach(async () => { + const userInfo = await Models.UserInfo.findOne({ userId: TESTER_USER_INFO.userId }); + const team = await Models.Team.findOne({ name: TESTER_TEAM.name }); + + const staffInfo = await Models.StaffInfo.create({ + user: userInfo!._id, + title: TESTER_STAFF_INFO.title, + team: team!._id, + emoji: TESTER_STAFF_INFO.emoji, + profilePictureUrl: TESTER_STAFF_INFO.profilePictureUrl, + quote: TESTER_STAFF_INFO.quote, + isActive: true, + }); + + await Models.UserInfo.updateOne({ userId: TESTER_USER_INFO.userId }, { staffInfo: staffInfo._id }); + }); + + it("successfully deletes staff info", async () => { + const response = await delAsAdmin("/staff/info/").send({ userId: TESTER_USER_INFO.userId }).expect(StatusCode.SuccessOK); + + expect(JSON.parse(response.text)).toMatchObject({ success: true }); + + const userInfo = await Models.UserInfo.findOne({ userId: TESTER_USER_INFO.userId }); + expect(userInfo?.staffInfo).toBeUndefined(); + + const staffInfo = await Models.StaffInfo.findOne({ user: userInfo!._id }); + expect(staffInfo).toBeNull(); + }); + + it("returns error when user does not exist", async () => { + const response = await delAsAdmin("/staff/info/") + .send({ userId: "nonexistent123" }) + .expect(StatusCode.ClientErrorNotFound); + + expect(JSON.parse(response.text)).toMatchObject({ + error: "NotFound", + }); + }); + + it("returns error when staff info does not exist", async () => { + const response = await delAsAdmin("/staff/info/") + .send({ userId: INACTIVE_STAFF_USER.userId }) + .expect(StatusCode.ClientErrorNotFound); + + expect(JSON.parse(response.text)).toMatchObject({ + error: "StaffNotFound", + }); + }); + + it("rejects non-admin users", async () => { + await delAsAttendee("/staff/info/").send({ userId: TESTER_USER_INFO.userId }).expect(StatusCode.ClientErrorForbidden); + }); }); describe("PUT /staff/scan-attendee/", () => { diff --git a/src/services/staff/staff-router.ts b/src/services/staff/staff-router.ts index 9292645a..4f5bf30b 100644 --- a/src/services/staff/staff-router.ts +++ b/src/services/staff/staff-router.ts @@ -9,6 +9,9 @@ import { ShiftsAddRequestSchema, ShiftsSchema, StaffAttendanceRequestSchema, + StaffInfoSchema, + StaffNotFoundError, + StaffNotFoundErrorSchema, } from "./staff-schemas"; import Config from "../../common/config"; import Models from "../../common/models"; @@ -16,9 +19,12 @@ import { StatusCode } from "status-code-enum"; import { Event } from "../event/event-schemas"; import { performCheckIn, PerformCheckInErrors } from "./staff-lib"; import specification, { Tag } from "../../middleware/specification"; -import { SuccessResponseSchema } from "../../common/schemas"; +import { SuccessResponseSchema, UserIdSchema } from "../../common/schemas"; import { EventNotFoundError, EventNotFoundErrorSchema } from "../event/event-schemas"; import { decryptQRCode } from "../user/user-lib"; +import { UserInfo, UserNotFoundError, UserNotFoundErrorSchema } from "../user/user-schemas"; +import { Team } from "../team/team-schemas"; +import { z } from "zod"; import { AlreadyCheckedInError, AlreadyCheckedInErrorSchema, @@ -30,6 +36,202 @@ import { const staffRouter = Router(); +staffRouter.get( + "/info/", + specification({ + method: "get", + path: "/staff/info/", + tag: Tag.STAFF, + role: null, + summary: "Gets all active staff members with their information for the team page", + responses: { + [StatusCode.SuccessOK]: { + description: "List of active staff members", + schema: z + .object({ + staff: z.array(StaffInfoSchema), + }) + .openapi("StaffTeam"), + }, + }, + }), + async (_req, res) => { + const staffMembers = await Models.StaffInfo.find({ isActive: true }).populate("user").populate("team"); + + const staffData = staffMembers.map((staff) => { + const userInfo = staff.user as unknown as UserInfo; + const team = staff.team as unknown as Team; + + return { + userId: userInfo.userId, + name: userInfo.name, + title: staff.title, + team: team?.name, + emoji: staff.emoji, + profilePictureUrl: staff.profilePictureUrl, + quote: staff.quote, + isActive: staff.isActive, + }; + }); + + return res.status(StatusCode.SuccessOK).json({ staff: staffData }); + }, +); + +staffRouter.post( + "/info/", + specification({ + method: "post", + path: "/staff/info/", + tag: Tag.STAFF, + role: Role.ADMIN, + summary: "Creates staff information for a specified user", + body: StaffInfoSchema, + responses: { + [StatusCode.SuccessOK]: { + description: "Successfully created staff info", + schema: SuccessResponseSchema, + }, + [StatusCode.ClientErrorNotFound]: { + description: "User not found", + schema: UserNotFoundErrorSchema, + }, + }, + }), + async (req, res) => { + const { userId, title, team, emoji, profilePictureUrl, quote, isActive } = req.body; + + const userInfo = await Models.UserInfo.findOne({ userId }); + if (!userInfo) { + return res.status(StatusCode.ClientErrorNotFound).json(UserNotFoundError); + } + + const teamDoc = team ? await Models.Team.findOne({ name: team }) : undefined; + + const staffInfo = await Models.StaffInfo.create({ + user: userInfo._id, + title, + team: teamDoc?._id, + emoji, + profilePictureUrl, + quote, + isActive: isActive ?? true, + }); + + await Models.UserInfo.updateOne({ userId }, { staffInfo: staffInfo._id }); + + return res.status(StatusCode.SuccessOK).json({ success: true }); + }, +); + +staffRouter.put( + "/info/", + specification({ + method: "put", + path: "/staff/info/", + tag: Tag.STAFF, + role: Role.ADMIN, + summary: "Updates staff information for a specified user", + body: StaffInfoSchema, + responses: { + [StatusCode.SuccessOK]: { + description: "Successfully updated staff info", + schema: SuccessResponseSchema, + }, + [StatusCode.ClientErrorNotFound]: [ + { + id: UserNotFoundError.error, + description: "User not found", + schema: UserNotFoundErrorSchema, + }, + { + id: StaffNotFoundError.error, + description: "Staff info not found", + schema: StaffNotFoundErrorSchema, + }, + ], + }, + }), + async (req, res) => { + const { userId, title, team, emoji, profilePictureUrl, quote, isActive } = req.body; + + const userInfo = await Models.UserInfo.findOne({ userId }); + if (!userInfo) { + return res.status(StatusCode.ClientErrorNotFound).json(UserNotFoundError); + } + + const teamDoc = team ? await Models.Team.findOne({ name: team }) : undefined; + + const staffInfo = await Models.StaffInfo.findOneAndUpdate( + { user: userInfo._id }, + { + title, + team: teamDoc?._id, + emoji, + profilePictureUrl, + quote, + isActive: isActive ?? true, + }, + { new: true }, + ); + + if (!staffInfo) { + return res.status(StatusCode.ClientErrorNotFound).json(StaffNotFoundError); + } + + return res.status(StatusCode.SuccessOK).json({ success: true }); + }, +); + +staffRouter.delete( + "/info/", + specification({ + method: "delete", + path: "/staff/info/", + tag: Tag.STAFF, + role: Role.ADMIN, + summary: "Deletes staff information for a specified user", + body: z.object({ + userId: UserIdSchema, + }), + responses: { + [StatusCode.SuccessOK]: { + description: "Successfully deleted staff info", + schema: SuccessResponseSchema, + }, + [StatusCode.ClientErrorNotFound]: [ + { + id: UserNotFoundError.error, + description: "User not found", + schema: UserNotFoundErrorSchema, + }, + { + id: StaffNotFoundError.error, + description: "Staff info not found", + schema: StaffNotFoundErrorSchema, + }, + ], + }, + }), + async (req, res) => { + const { userId } = req.body; + + const userInfo = await Models.UserInfo.findOne({ userId }); + if (!userInfo) { + return res.status(StatusCode.ClientErrorNotFound).json(UserNotFoundError); + } + + const staffInfo = await Models.StaffInfo.findOneAndDelete({ user: userInfo._id }); + if (!staffInfo) { + return res.status(StatusCode.ClientErrorNotFound).json(StaffNotFoundError); + } + + await Models.UserInfo.updateOne({ userId }, { $unset: { staffInfo: "" } }); + + return res.status(StatusCode.SuccessOK).json({ success: true }); + }, +); + staffRouter.post( "/attendance/", specification({ diff --git a/src/services/staff/staff-schemas.ts b/src/services/staff/staff-schemas.ts index bd130e20..70dd54f9 100644 --- a/src/services/staff/staff-schemas.ts +++ b/src/services/staff/staff-schemas.ts @@ -1,9 +1,35 @@ -import { prop } from "@typegoose/typegoose"; +import { prop, Ref } from "@typegoose/typegoose"; import { UserIdSchema, EventIdSchema } from "../../common/schemas"; import { z } from "zod"; import { CreateErrorAndSchema, SuccessResponseSchema } from "../../common/schemas"; +import { UserInfo } from "../user/user-schemas"; +import { Team } from "../team/team-schemas"; import { EventSchema } from "../event/event-schemas"; +export class StaffInfo { + // Reference to base UserInfo + @prop({ ref: () => UserInfo, required: true, index: true }) + public user!: Ref; + + @prop({ required: true }) + public title!: string; + + @prop({ ref: () => Team, required: false }) + public team?: Ref; + + @prop({ required: false }) + public emoji?: string; + + @prop({ required: false }) + public profilePictureUrl?: string; + + @prop({ required: false }) + public quote?: string; + + @prop({ required: false, default: true }) + public isActive!: boolean; +} + export class StaffShift { @prop({ required: true, index: true }) public userId: string; @@ -45,6 +71,18 @@ export const ShiftsAddRequestSchema = z.object({ shifts: z.array(EventIdSchema), }); +export const StaffInfoSchema = z + .object({ + userId: UserIdSchema, + title: z.string().openapi({ example: "Systems Lead" }), + team: z.string().openapi({ example: "Systems" }), + emoji: z.string().optional().openapi({ example: "💻" }), + profilePictureUrl: z.string().url().optional().openapi({ example: "https://example.com/hackillinois.png" }), + quote: z.string().optional().openapi({ example: "Yippee" }), + isActive: z.boolean().default(true), + }) + .openapi("StaffInfo"); + export const [CodeExpiredError, CodeExpiredErrorSchema] = CreateErrorAndSchema({ error: "CodeExpired", message: "The code for this event has expired", @@ -54,3 +92,9 @@ export const [QRExpiredError, QRExpiredErrorSchema] = CreateErrorAndSchema({ error: "QRExpired", message: "Your QR code has expired", }); + +// For later use if we ever need to fetch a specific staff member +export const [StaffNotFoundError, StaffNotFoundErrorSchema] = CreateErrorAndSchema({ + error: "StaffNotFound", + message: "The specified staff member was not found", +}); diff --git a/src/services/team/team-router.test.ts b/src/services/team/team-router.test.ts new file mode 100644 index 00000000..007d3eca --- /dev/null +++ b/src/services/team/team-router.test.ts @@ -0,0 +1,141 @@ +import { beforeEach, describe, expect, it } from "@jest/globals"; +import { getAsAttendee, postAsStaff, putAsStaff, delAsStaff } from "../../common/testTools"; +import { StatusCode } from "status-code-enum"; +import Models from "../../common/models"; + +const TEST_TEAM = { + name: "Systems", +}; + +const UPDATED_TEAM = { + name: "Design", +}; + +const TEST_USER = { + userId: "user1", + name: "Alice", + email: "alice@example.com", +}; + +const TEST_STAFF = { + title: "API", + isActive: true, +}; + +beforeEach(async () => { + await Models.Team.deleteMany({}); + await Models.UserInfo.deleteMany({}); +}); + +describe("GET /team/", () => { + it("returns an empty list when no teams exist", async () => { + const response = await getAsAttendee("/team/").expect(StatusCode.SuccessOK); + expect(JSON.parse(response.text)).toEqual([]); + }); + + it("returns all existing teams", async () => { + const createdTeam = await Models.Team.create(TEST_TEAM); + + const response = await getAsAttendee("/team/").expect(StatusCode.SuccessOK); + const data = JSON.parse(response.text); + + expect(Array.isArray(data)).toBe(true); + expect(data[0]).toMatchObject({ + _id: createdTeam.id, + name: TEST_TEAM.name, + }); + }); +}); + +describe("GET /team/:id/", () => { + it("returns 404 if team does not exist", async () => { + const response = await getAsAttendee("/team/invalidId/").expect(StatusCode.ClientErrorNotFound); + expect(JSON.parse(response.text)).toMatchObject({ + error: "NotFound", + message: "Failed to find team", + }); + }); + + it("returns a team and its associated users", async () => { + const createdTeam = await Models.Team.create(TEST_TEAM); + const createdUser = await Models.UserInfo.create(TEST_USER); + + await Models.StaffInfo.create({ + ...TEST_STAFF, + user: createdUser._id, + team: createdTeam.id, + }); + + const response = await getAsAttendee(`/team/${createdTeam.id}/`).expect(StatusCode.SuccessOK); + const data = JSON.parse(response.text); + + expect(data.team).toMatchObject({ + _id: createdTeam.id, + name: TEST_TEAM.name, + }); + + expect(data.users.length).toBe(1); + expect(data.users[0]).toMatchObject({ + team: createdTeam.id, + title: TEST_STAFF.title, + }); + expect(data.users[0].user).toMatchObject({ + userId: TEST_USER.userId, + name: TEST_USER.name, + }); + }); +}); + +describe("POST /team/", () => { + it("creates a new team successfully", async () => { + const response = await postAsStaff("/team/").send(TEST_TEAM).expect(StatusCode.SuccessCreated); + const created = JSON.parse(response.text); + + expect(created).toHaveProperty("_id"); + expect(created.name).toBe(TEST_TEAM.name); + + const dbTeam = await Models.Team.findById(created._id); + expect(dbTeam?.toObject()).toMatchObject(TEST_TEAM); + }); +}); + +describe("PUT /team/:id/", () => { + it("returns 404 for non-existent team", async () => { + const response = await putAsStaff("/team/invalidId/").send(UPDATED_TEAM).expect(StatusCode.ClientErrorNotFound); + expect(JSON.parse(response.text)).toMatchObject({ + error: "NotFound", + message: "Failed to find team", + }); + }); + + it("updates an existing team successfully", async () => { + const createdTeam = await Models.Team.create(TEST_TEAM); + + const response = await putAsStaff(`/team/${createdTeam.id}/`).send(UPDATED_TEAM).expect(StatusCode.SuccessOK); + + const updated = JSON.parse(response.text); + expect(updated.name).toBe(UPDATED_TEAM.name); + + const dbTeam = await Models.Team.findById(createdTeam.id); + expect(dbTeam?.name).toBe(UPDATED_TEAM.name); + }); +}); + +describe("DELETE /team/:id/", () => { + it("returns 404 for non-existent team", async () => { + const response = await delAsStaff("/team/invalidId/").expect(StatusCode.ClientErrorNotFound); + expect(JSON.parse(response.text)).toMatchObject({ + error: "NotFound", + message: "Failed to find team", + }); + }); + + it("deletes an existing team successfully", async () => { + const createdTeam = await Models.Team.create(TEST_TEAM); + + await delAsStaff(`/team/${createdTeam.id}/`).expect(StatusCode.SuccessNoContent); + + const deleted = await Models.Team.findById(createdTeam.id); + expect(deleted).toBeNull(); + }); +}); diff --git a/src/services/team/team-router.ts b/src/services/team/team-router.ts new file mode 100644 index 00000000..9e364a48 --- /dev/null +++ b/src/services/team/team-router.ts @@ -0,0 +1,167 @@ +import { Router } from "express"; +import { StatusCode } from "status-code-enum"; +import { z } from "zod"; + +import { Role } from "../auth/auth-schemas"; +import specification, { Tag } from "../../middleware/specification"; +import Models from "../../common/models"; +import { isValidObjectId } from "mongoose"; + +import { TeamSchema, TeamNotFoundError, TeamNotFoundErrorSchema } from "./team-schemas"; + +const teamRouter = Router(); + +teamRouter.get( + "/", + specification({ + method: "get", + path: "/team/", + tag: Tag.USER, + role: Role.USER, + summary: "Gets all teams", + responses: { + [StatusCode.SuccessOK]: { + description: "List of all teams", + schema: z.array(TeamSchema), + }, + }, + }), + async (_req, res) => { + const teams = await Models.Team.find(); + return res.status(StatusCode.SuccessOK).json(teams); + }, +); + +teamRouter.get( + "/:id/", + specification({ + method: "get", + path: "/team/{id}/", + tag: Tag.USER, + role: Role.USER, + summary: "Gets a team and its users", + parameters: z.object({ + id: z.string(), + }), + responses: { + [StatusCode.SuccessOK]: { + description: "Team and its users", + schema: z.object({ + team: TeamSchema, + users: z.array(z.any()).openapi({ description: "List of users in the team" }), + }), + }, + [StatusCode.ClientErrorNotFound]: { + description: "Could not find the team", + schema: TeamNotFoundErrorSchema, + }, + }, + }), + async (req, res) => { + const { id } = req.params; + if (!isValidObjectId(id)) { + return res.status(StatusCode.ClientErrorNotFound).json(TeamNotFoundError); + } + const team = await Models.Team.findById(id); + if (!team) { + return res.status(StatusCode.ClientErrorNotFound).json(TeamNotFoundError); + } + + const staffMembers = await Models.StaffInfo.find({ team: id, isActive: true }).populate("user"); + return res.status(StatusCode.SuccessOK).json({ team, users: staffMembers }); + }, +); + +teamRouter.post( + "/", + specification({ + method: "post", + path: "/team/", + tag: Tag.USER, + role: Role.STAFF, + summary: "Creates a new team", + responses: { + [StatusCode.SuccessCreated]: { + description: "The created team", + schema: TeamSchema, + }, + }, + }), + async (req, res) => { + const team = await Models.Team.create(req.body); + return res.status(StatusCode.SuccessCreated).json(team); + }, +); + +teamRouter.put( + "/:id/", + specification({ + method: "put", + path: "/team/{id}/", + tag: Tag.USER, + role: Role.STAFF, + summary: "Updates a team by ID", + parameters: z.object({ + id: z.string(), + }), + responses: { + [StatusCode.SuccessOK]: { + description: "Updated team", + schema: TeamSchema, + }, + [StatusCode.ClientErrorNotFound]: { + description: "Could not find the team", + schema: TeamNotFoundErrorSchema, + }, + }, + }), + async (req, res) => { + const { id } = req.params; + if (!isValidObjectId(id)) { + return res.status(StatusCode.ClientErrorNotFound).json(TeamNotFoundError); + } + const updateData = TeamSchema.parse(req.body); + const team = await Models.Team.findByIdAndUpdate(id, updateData, { new: true }); + if (!team) { + return res.status(StatusCode.ClientErrorNotFound).json(TeamNotFoundError); + } + return res.status(StatusCode.SuccessOK).json(team); + }, +); + +teamRouter.delete( + "/:id/", + specification({ + method: "delete", + path: "/team/{id}/", + tag: Tag.USER, + role: Role.STAFF, + summary: "Deletes a team by ID", + parameters: z.object({ + id: z.string(), + }), + responses: { + [StatusCode.SuccessNoContent]: { + description: "Successfully deleted team", + schema: z.object({}).openapi({ description: "Empty response" }), + }, + [StatusCode.ClientErrorNotFound]: { + description: "Could not find the team", + schema: TeamNotFoundErrorSchema, + }, + }, + }), + async (req, res) => { + const { id } = req.params; + if (!isValidObjectId(id)) { + return res.status(StatusCode.ClientErrorNotFound).json(TeamNotFoundError); + } + const team = await Models.Team.findByIdAndDelete(id); + if (!team) { + return res.status(StatusCode.ClientErrorNotFound).json(TeamNotFoundError); + } + return res.status(StatusCode.SuccessNoContent).send(); + }, +); + +export default teamRouter; diff --git a/src/services/team/team-schemas.ts b/src/services/team/team-schemas.ts new file mode 100644 index 00000000..3aa98cd5 --- /dev/null +++ b/src/services/team/team-schemas.ts @@ -0,0 +1,22 @@ +import { prop } from "@typegoose/typegoose"; +import { z } from "zod"; +import { CreateErrorAndSchema } from "../../common/schemas"; + +export class Team { + @prop({ required: true, unique: true }) + public name: string; +} + +export const TeamSchema = z + .object({ + id: z.string().optional().openapi({ example: "6717efb83b5d4c1a2e47a7e1" }), + name: z.string().openapi({ example: "Systems" }), + }) + .openapi("Team", { + description: "Represents a team within the organization.", + }); + +export const [TeamNotFoundError, TeamNotFoundErrorSchema] = CreateErrorAndSchema({ + error: "NotFound", + message: "Failed to find team", +}); diff --git a/src/services/user/user-router.ts b/src/services/user/user-router.ts index 6d9ae8e3..c843dcba 100644 --- a/src/services/user/user-router.ts +++ b/src/services/user/user-router.ts @@ -115,7 +115,10 @@ userRouter.get( const user = await Models.UserInfo.findOne({ userId }); if (user) { - return res.status(StatusCode.SuccessOK).json(user); + return res.status(StatusCode.SuccessOK).json({ + ...user.toObject(), + staffInfo: user.staffInfo?.toString(), + }); } return res.status(StatusCode.ClientErrorNotFound).json(UserNotFoundError); @@ -266,7 +269,12 @@ userRouter.get( const { id: userId } = req.params; const userInfo: UserInfo | null = await Models.UserInfo.findOne({ userId }); if (userInfo) { - return res.status(StatusCode.SuccessOK).send(userInfo); + return res.status(StatusCode.SuccessOK).send({ + userId: userInfo.userId, + name: userInfo.name, + email: userInfo.email, + staffInfo: userInfo.staffInfo?.toString(), + }); } else { return res.status(StatusCode.ClientErrorNotFound).send(UserNotFoundError); } diff --git a/src/services/user/user-schemas.ts b/src/services/user/user-schemas.ts index fd3a7a39..9487d137 100644 --- a/src/services/user/user-schemas.ts +++ b/src/services/user/user-schemas.ts @@ -1,7 +1,8 @@ -import { prop } from "@typegoose/typegoose"; +import { prop, Ref } from "@typegoose/typegoose"; import { z } from "zod"; import { CreateErrorAndSchema, UserIdSchema } from "../../common/schemas"; import { EventIdSchema } from "../../common/schemas"; +import { StaffInfo } from "../staff/staff-schemas"; export class UserInfo { @prop({ required: true, index: true }) @@ -12,7 +13,11 @@ export class UserInfo { @prop({ required: true }) public email: string; + + @prop({ ref: () => StaffInfo }) + public staffInfo?: Ref; //check if user is staff with if (user.staffInfo) } + export class UserAttendance { @prop({ required: true, index: true }) public userId: string; @@ -22,6 +27,12 @@ export class UserAttendance { type: () => String, }) public attendance: string[]; + + @prop({ + required: false, + type: () => String, + }) + public excusedAttendance?: string[]; } export class UserFollowing { @@ -40,6 +51,10 @@ export const UserInfoSchema = z userId: UserIdSchema, name: z.string().openapi({ example: "John Doe" }), email: z.string().openapi({ example: "john@doe.com" }), + staffInfo: z.string().optional().openapi({ + description: "Reference ID to staff info (if user is a staff member)", + example: "65321af4f7b4b42b0d5a1e7b", + }), }) .openapi("UserInfo", { description: "A user's info", From 8428cc01897047fc26bc0c917dc2e0dca76b1b34 Mon Sep 17 00:00:00 2001 From: Jasmine Date: Tue, 28 Oct 2025 04:41:38 -0500 Subject: [PATCH 2/7] fixes --- src/services/auth/auth-router.ts | 7 ++++++- src/services/event/event-router.ts | 6 +++++- src/services/staff/staff-schemas.ts | 1 - 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/services/auth/auth-router.ts b/src/services/auth/auth-router.ts index 74e5d66a..6daa93d7 100644 --- a/src/services/auth/auth-router.ts +++ b/src/services/auth/auth-router.ts @@ -262,7 +262,12 @@ authRouter.get( const userInfo = await getUserInfoWithRole(role); - res.status(StatusCode.SuccessOK).send({ userInfo }); + res.status(StatusCode.SuccessOK).send({ + userInfo: userInfo.map(user => ({ + ...user, + staffInfo: user.staffInfo?.toString(), + })), + }); }, ); diff --git a/src/services/event/event-router.ts b/src/services/event/event-router.ts index b614dedd..d43b2824 100644 --- a/src/services/event/event-router.ts +++ b/src/services/event/event-router.ts @@ -178,7 +178,11 @@ eventsRouter.get( userId: { $in: event.attendees }, }).sort({ userId: 1 }); - return res.status(StatusCode.SuccessOK).send({ eventId, attendeesInfo }); + const transformedAttendeesInfo = attendeesInfo.map((attendee) => ({ + ...attendee.toObject(), + staffInfo: attendee.staffInfo?.toString(), + })); + return res.status(StatusCode.SuccessOK).send({ eventId, attendeesInfo: transformedAttendeesInfo }); }, ); diff --git a/src/services/staff/staff-schemas.ts b/src/services/staff/staff-schemas.ts index 70dd54f9..d7ccf937 100644 --- a/src/services/staff/staff-schemas.ts +++ b/src/services/staff/staff-schemas.ts @@ -93,7 +93,6 @@ export const [QRExpiredError, QRExpiredErrorSchema] = CreateErrorAndSchema({ message: "Your QR code has expired", }); -// For later use if we ever need to fetch a specific staff member export const [StaffNotFoundError, StaffNotFoundErrorSchema] = CreateErrorAndSchema({ error: "StaffNotFound", message: "The specified staff member was not found", From 846a71b329fa5a0a634bc794fd2d9bc77f33d4d1 Mon Sep 17 00:00:00 2001 From: Jasmine Date: Tue, 28 Oct 2025 04:44:01 -0500 Subject: [PATCH 3/7] prettier --- src/services/auth/auth-router.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/auth/auth-router.ts b/src/services/auth/auth-router.ts index 6daa93d7..00f21ae0 100644 --- a/src/services/auth/auth-router.ts +++ b/src/services/auth/auth-router.ts @@ -263,7 +263,7 @@ authRouter.get( const userInfo = await getUserInfoWithRole(role); res.status(StatusCode.SuccessOK).send({ - userInfo: userInfo.map(user => ({ + userInfo: userInfo.map((user) => ({ ...user, staffInfo: user.staffInfo?.toString(), })), From 79b9c6401383acccf75d52f9fb5a2436bd8a06be Mon Sep 17 00:00:00 2001 From: Jasmine Date: Wed, 29 Oct 2025 14:31:54 -0500 Subject: [PATCH 4/7] fix1 --- src/services/auth/auth-router.ts | 5 +++-- src/services/user/user-schemas.ts | 4 ---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/services/auth/auth-router.ts b/src/services/auth/auth-router.ts index 00f21ae0..81182e11 100644 --- a/src/services/auth/auth-router.ts +++ b/src/services/auth/auth-router.ts @@ -264,8 +264,9 @@ authRouter.get( res.status(StatusCode.SuccessOK).send({ userInfo: userInfo.map((user) => ({ - ...user, - staffInfo: user.staffInfo?.toString(), + name: user.name, + userId: user.userId, + email: user.email, })), }); }, diff --git a/src/services/user/user-schemas.ts b/src/services/user/user-schemas.ts index 9487d137..546128ea 100644 --- a/src/services/user/user-schemas.ts +++ b/src/services/user/user-schemas.ts @@ -51,10 +51,6 @@ export const UserInfoSchema = z userId: UserIdSchema, name: z.string().openapi({ example: "John Doe" }), email: z.string().openapi({ example: "john@doe.com" }), - staffInfo: z.string().optional().openapi({ - description: "Reference ID to staff info (if user is a staff member)", - example: "65321af4f7b4b42b0d5a1e7b", - }), }) .openapi("UserInfo", { description: "A user's info", From 8068a0bbaa73e252523523765c6d54737d85b904 Mon Sep 17 00:00:00 2001 From: Jasmine Date: Wed, 29 Oct 2025 14:38:20 -0500 Subject: [PATCH 5/7] fix2 --- src/services/user/user-schemas.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/services/user/user-schemas.ts b/src/services/user/user-schemas.ts index 546128ea..9487d137 100644 --- a/src/services/user/user-schemas.ts +++ b/src/services/user/user-schemas.ts @@ -51,6 +51,10 @@ export const UserInfoSchema = z userId: UserIdSchema, name: z.string().openapi({ example: "John Doe" }), email: z.string().openapi({ example: "john@doe.com" }), + staffInfo: z.string().optional().openapi({ + description: "Reference ID to staff info (if user is a staff member)", + example: "65321af4f7b4b42b0d5a1e7b", + }), }) .openapi("UserInfo", { description: "A user's info", From e88b3210fcdcc19924b17a3b748606b676759b00 Mon Sep 17 00:00:00 2001 From: Jasmine Date: Wed, 29 Oct 2025 15:49:55 -0500 Subject: [PATCH 6/7] figured out the specs bug --- src/services/event/event-router.ts | 6 +----- src/services/event/event-schemas.ts | 8 +++++++- src/services/staff/staff-router.ts | 2 +- src/services/user/user-schemas.ts | 2 +- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/services/event/event-router.ts b/src/services/event/event-router.ts index d43b2824..b614dedd 100644 --- a/src/services/event/event-router.ts +++ b/src/services/event/event-router.ts @@ -178,11 +178,7 @@ eventsRouter.get( userId: { $in: event.attendees }, }).sort({ userId: 1 }); - const transformedAttendeesInfo = attendeesInfo.map((attendee) => ({ - ...attendee.toObject(), - staffInfo: attendee.staffInfo?.toString(), - })); - return res.status(StatusCode.SuccessOK).send({ eventId, attendeesInfo: transformedAttendeesInfo }); + return res.status(StatusCode.SuccessOK).send({ eventId, attendeesInfo }); }, ); diff --git a/src/services/event/event-schemas.ts b/src/services/event/event-schemas.ts index c3eae74a..d25a49be 100644 --- a/src/services/event/event-schemas.ts +++ b/src/services/event/event-schemas.ts @@ -225,7 +225,13 @@ export const EventAttendeesSchema = z export const EventAttendeesInfoSchema = z .object({ eventId: EventIdSchema, - attendeesInfo: z.array(UserInfoSchema), + attendeesInfo: z.array( + z.object({ + userId: UserIdSchema, + name: z.string(), + email: z.string(), + }), + ), }) .openapi("EventAttendeesInfo"); diff --git a/src/services/staff/staff-router.ts b/src/services/staff/staff-router.ts index 4f5bf30b..55664dcb 100644 --- a/src/services/staff/staff-router.ts +++ b/src/services/staff/staff-router.ts @@ -42,7 +42,7 @@ staffRouter.get( method: "get", path: "/staff/info/", tag: Tag.STAFF, - role: null, + role: Role.USER, summary: "Gets all active staff members with their information for the team page", responses: { [StatusCode.SuccessOK]: { diff --git a/src/services/user/user-schemas.ts b/src/services/user/user-schemas.ts index 9487d137..26b7881c 100644 --- a/src/services/user/user-schemas.ts +++ b/src/services/user/user-schemas.ts @@ -14,7 +14,7 @@ export class UserInfo { @prop({ required: true }) public email: string; - @prop({ ref: () => StaffInfo }) + @prop({ required: false, ref: () => StaffInfo }) public staffInfo?: Ref; //check if user is staff with if (user.staffInfo) } From 517df0507ea654752d0638e51c4ff194c711b48d Mon Sep 17 00:00:00 2001 From: Jasmine Date: Wed, 29 Oct 2025 15:52:26 -0500 Subject: [PATCH 7/7] forgot to remove import --- src/services/event/event-schemas.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/services/event/event-schemas.ts b/src/services/event/event-schemas.ts index d25a49be..112985ea 100644 --- a/src/services/event/event-schemas.ts +++ b/src/services/event/event-schemas.ts @@ -2,7 +2,6 @@ import { modelOptions, prop } from "@typegoose/typegoose"; import { CreateErrorAndSchema, EventIdSchema } from "../../common/schemas"; import { z } from "zod"; import { UserIdSchema } from "../../common/schemas"; -import { UserInfoSchema } from "../user/user-schemas"; export enum EventType { MEAL = "MEAL",