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..74245cef 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, + TESTER, + delAsAdmin, + getAsAttendee, + postAsAdmin, + postAsAttendee, + putAsAdmin, + delAsAttendee, +} from "../../common/testTools"; import { Event, EventAttendance, EventType } from "../event/event-schemas"; import { StatusCode } from "status-code-enum"; @@ -14,6 +24,7 @@ import { } from "../registration/registration-schemas"; import { AttendeeProfile } from "../profile/profile-schemas"; import { generateQRCode } from "../user/user-lib"; +import { Team } from "../team/team-schemas"; const TESTER_EVENT_ATTENDANCE = { eventId: "some-event", @@ -80,12 +91,314 @@ const TEST_EVENT = { isPro: false, } satisfies Event; +const TESTER_TEAM = { + name: "Systems", +} satisfies Team; + +const TESTER_STAFF_INFO = { + name: "Test User", + title: "Systems Lead", + emoji: "💻", + profilePictureUrl: "https://example.com/profile.jpg", + quote: "six seven", + isActive: true, +}; + +const INACTIVE_STAFF_INFO = { + name: "Inactive Staff", + title: "Former Lead", + 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); +}); +describe("GET /staff/info/", () => { + it("returns all active staff members for public access", async () => { + const team = await Models.Team.findOne({ name: TESTER_TEAM.name }); + + await Models.StaffInfo.create({ + name: TESTER_STAFF_INFO.name, + 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, + }); + + const response = await getAsAttendee("/staff/info/").expect(StatusCode.SuccessOK); + + const data = JSON.parse(response.text); + expect(data.staffInfo).toHaveLength(1); + expect(data.staffInfo[0]).toMatchObject({ + name: TESTER_STAFF_INFO.name, + title: TESTER_STAFF_INFO.title, + emoji: TESTER_STAFF_INFO.emoji, + profilePictureUrl: TESTER_STAFF_INFO.profilePictureUrl, + quote: TESTER_STAFF_INFO.quote, + isActive: true, + }); + expect(data.staffInfo[0].team).toBeDefined(); + }); + + it("excludes inactive staff members", async () => { + const team = await Models.Team.findOne({ name: TESTER_TEAM.name }); + + await Models.StaffInfo.create({ + name: TESTER_STAFF_INFO.name, + title: TESTER_STAFF_INFO.title, + team: team!._id, + isActive: true, + }); + + await Models.StaffInfo.create({ + name: INACTIVE_STAFF_INFO.name, + title: INACTIVE_STAFF_INFO.title, + team: team!._id, + isActive: false, + }); + + const response = await getAsAttendee("/staff/info/").expect(StatusCode.SuccessOK); + + const data = JSON.parse(response.text); + expect(data.staffInfo).toHaveLength(1); + expect(data.staffInfo[0].name).toBe(TESTER_STAFF_INFO.name); + }); + + 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.staffInfo).toHaveLength(0); + }); + + it("returns staff without team when team is not set", async () => { + await Models.StaffInfo.create({ + name: TESTER_STAFF_INFO.name, + title: TESTER_STAFF_INFO.title, + isActive: true, + }); + + const response = await getAsAttendee("/staff/info/").expect(StatusCode.SuccessOK); + + const data = JSON.parse(response.text); + expect(data.staffInfo).toHaveLength(1); + expect(data.staffInfo[0].team).toBeUndefined(); + }); +}); + +describe("POST /staff/info/", () => { + it("successfully creates staff info", async () => { + const team = await Models.Team.findOne({ name: TESTER_TEAM.name }); + + const response = await postAsAdmin("/staff/info/") + .send({ + name: TESTER_STAFF_INFO.name, + title: TESTER_STAFF_INFO.title, + team: team!._id.toString(), + emoji: TESTER_STAFF_INFO.emoji, + profilePictureUrl: TESTER_STAFF_INFO.profilePictureUrl, + quote: TESTER_STAFF_INFO.quote, + isActive: true, + }) + .expect(StatusCode.SuccessOK); + + expect(JSON.parse(response.text)).toMatchObject({ success: true }); + + const staffInfo = await Models.StaffInfo.findOne({ name: TESTER_STAFF_INFO.name }).populate("team"); + expect(staffInfo).toBeDefined(); + expect(staffInfo?.title).toBe(TESTER_STAFF_INFO.title); + expect((staffInfo?.team as unknown as Team)?.name).toBe(TESTER_TEAM.name); + }); + + it("creates staff info without optional fields", async () => { + await postAsAdmin("/staff/info/") + .send({ + name: "New Staff", + title: "Co-Director", + isActive: true, + }) + .expect(StatusCode.SuccessOK); + + const staffInfo = await Models.StaffInfo.findOne({ name: "New Staff" }); + + expect(staffInfo?.title).toBe("Co-Director"); + expect(staffInfo?.emoji).toBeUndefined(); + expect(staffInfo?.quote).toBeUndefined(); + expect(staffInfo?.isActive).toBe(true); + }); + + it("creates staff info with default isActive as true", async () => { + await postAsAdmin("/staff/info/") + .send({ + name: "Default Active Staff", + title: "Developer", + }) + .expect(StatusCode.SuccessOK); + + const staffInfo = await Models.StaffInfo.findOne({ name: "Default Active Staff" }); + expect(staffInfo?.isActive).toBe(true); + }); + + it("rejects non-admin users", async () => { + await postAsAttendee("/staff/info/") + .send({ + name: TESTER_STAFF_INFO.name, + title: TESTER_STAFF_INFO.title, + isActive: true, + }) + .expect(StatusCode.ClientErrorForbidden); + }); +}); + +describe("PUT /staff/info/", () => { + let staffId: string; + + beforeEach(async () => { + const team = await Models.Team.findOne({ name: TESTER_TEAM.name }); + + const staffInfo = await Models.StaffInfo.create({ + name: TESTER_STAFF_INFO.name, + 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, + }); + + staffId = staffInfo._id.toString(); + }); + + it("successfully updates staff info", async () => { + const updatedData = { + staffId: staffId, + name: "Updated Name", + title: "Lead Systems Engineer", + 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 staffInfo = await Models.StaffInfo.findById(staffId); + + expect(staffInfo?.name).toBe(updatedData.name); + 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({ + staffId: staffId, + name: TESTER_STAFF_INFO.name, + title: TESTER_STAFF_INFO.title, + isActive: false, + }) + .expect(StatusCode.SuccessOK); + + const staffInfo = await Models.StaffInfo.findById(staffId); + + expect(staffInfo?.isActive).toBe(false); + }); + + it("can update only specific fields", async () => { + await putAsAdmin("/staff/info/") + .send({ + staffId: staffId, + title: "New Title Only", + }) + .expect(StatusCode.SuccessOK); + + const staffInfo = await Models.StaffInfo.findById(staffId); + + expect(staffInfo?.title).toBe("New Title Only"); + expect(staffInfo?.name).toBe(TESTER_STAFF_INFO.name); + }); + + it("returns error when staff does not exist", async () => { + const response = await putAsAdmin("/staff/info/") + .send({ + staffId: "507f1f77bcf86cd799439011", + name: "Test", + title: "Test", + isActive: true, + }) + .expect(StatusCode.ClientErrorNotFound); + + expect(JSON.parse(response.text)).toMatchObject({ + error: "StaffNotFound", + }); + }); + + it("rejects non-admin users", async () => { + await putAsAttendee("/staff/info/") + .send({ + staffId: staffId, + name: TESTER_STAFF_INFO.name, + title: TESTER_STAFF_INFO.title, + isActive: true, + }) + .expect(StatusCode.ClientErrorForbidden); + }); +}); + +describe("DELETE /staff/info/", () => { + let staffId: string; + + beforeEach(async () => { + const team = await Models.Team.findOne({ name: TESTER_TEAM.name }); + + const staffInfo = await Models.StaffInfo.create({ + name: TESTER_STAFF_INFO.name, + 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, + }); + + staffId = staffInfo._id.toString(); + }); + + it("successfully deletes staff info", async () => { + const response = await delAsAdmin("/staff/info/").send({ staffId: staffId }).expect(StatusCode.SuccessOK); + + expect(JSON.parse(response.text)).toMatchObject({ success: true }); + + const staffInfo = await Models.StaffInfo.findById(staffId); + expect(staffInfo).toBeNull(); + }); + + it("returns error when staff does not exist", async () => { + const response = await delAsAdmin("/staff/info/") + .send({ staffId: "507f1f77bcf86cd799439011" }) + .expect(StatusCode.ClientErrorNotFound); + + expect(JSON.parse(response.text)).toMatchObject({ + error: "StaffNotFound", + }); + }); + + it("rejects non-admin users", async () => { + await delAsAttendee("/staff/info/").send({ staffId: staffId }).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..32c293b9 100644 --- a/src/services/staff/staff-router.ts +++ b/src/services/staff/staff-router.ts @@ -1,4 +1,5 @@ import { Router } from "express"; +import { z } from "zod"; import { Role } from "../auth/auth-schemas"; import { getAuthenticatedUser } from "../../common/auth"; import { @@ -9,6 +10,12 @@ import { ShiftsAddRequestSchema, ShiftsSchema, StaffAttendanceRequestSchema, + StaffInfoSchema, + StaffNotFoundError, + StaffNotFoundErrorSchema, + StaffListSchema, + StaffInfo, + StaffListParamSchema, } from "./staff-schemas"; import Config from "../../common/config"; import Models from "../../common/models"; @@ -30,6 +37,133 @@ import { const staffRouter = Router(); +staffRouter.get( + "/info/", + specification({ + method: "get", + path: "/staff/info/", + tag: Tag.STAFF, + role: Role.USER, + summary: "Gets all active staff information for the team page", + responses: { + [StatusCode.SuccessOK]: { + description: "Active staff information", + schema: z.object({ + staffInfo: z.array(StaffInfoSchema), + }), + }, + }, + }), + async (_req, res) => { + const staffInfo = await Models.StaffInfo.find({ isActive: true }).populate("team").lean(); + + const formattedStaffInfo = staffInfo.map((info) => { + let team; + if (info.team) { + team = typeof info.team === "string" ? info.team : info.team._id.toString(); + } + return { + ...info, + team, + }; + }); + + return res.status(StatusCode.SuccessOK).json({ staffInfo: formattedStaffInfo }); + }, +); + +staffRouter.post( + "/info/", + specification({ + method: "post", + path: "/staff/info/", + tag: Tag.STAFF, + role: Role.ADMIN, + summary: "Creates a new staff member profile", + body: StaffInfoSchema, + responses: { + [StatusCode.SuccessOK]: { + description: "Staff member created successfully", + schema: SuccessResponseSchema, + }, + }, + }), + async (req, res) => { + await Models.StaffInfo.create(req.body); + 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 an existing staff member profile", + body: StaffInfoSchema.partial().extend({ + staffId: z.string(), + }), + responses: { + [StatusCode.SuccessOK]: { + description: "Staff member updated successfully", + schema: SuccessResponseSchema, + }, + [StatusCode.ClientErrorNotFound]: { + description: "Staff member not found", + schema: StaffNotFoundErrorSchema, + }, + }, + }), + async (req, res) => { + const { staffId, ...updateData } = req.body; + + const staff = await Models.StaffInfo.findByIdAndUpdate(staffId, updateData, { new: true }); + + if (!staff) { + 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 a staff member profile", + body: z.object({ + staffId: z.string(), + }), + responses: { + [StatusCode.SuccessOK]: { + description: "Staff member deleted successfully", + schema: SuccessResponseSchema, + }, + [StatusCode.ClientErrorNotFound]: { + description: "Staff member not found", + schema: StaffNotFoundErrorSchema, + }, + }, + }), + async (req, res) => { + const { staffId } = req.body; + + const staff = await Models.StaffInfo.findByIdAndDelete(staffId); + + if (!staff) { + return res.status(StatusCode.ClientErrorNotFound).json(StaffNotFoundError); + } + + return res.status(StatusCode.SuccessOK).json({ success: true }); + }, +); + staffRouter.post( "/attendance/", specification({ @@ -226,4 +360,64 @@ staffRouter.post( }, ); +staffRouter.get( + "/list/", + specification({ + method: "get", + path: "/staff/list/", + tag: Tag.STAFF, + role: Role.STAFF, + summary: "Gets lit of staff members that match the params", + body: StaffListParamSchema, + responses: { + [StatusCode.SuccessOK]: { + description: "The list of staff members", + schema: StaffListSchema, + }, + ...PerformCheckInErrors, + }, + }), + async (req, res) => { + const { active, team } = req.body; + let staff: StaffInfo[] = []; + + if (active != null && team != null) { + staff = await Models.StaffInfo.find({ + isActive: active, + team: team, + }) + .populate("team") + .lean(); + } else if (active != null) { + staff = await Models.StaffInfo.find({ + isActive: active, + }) + .populate("team") + .lean(); + } else if (team != null) { + staff = await Models.StaffInfo.find({ + team: team, + }) + .populate("team") + .lean(); + } else { + staff = await Models.StaffInfo.find().populate("team").lean(); + } + + const formattedStaff = staff.map((info) => { + let team; + if (info.team) { + team = typeof info.team === "string" ? info.team : info.team._id.toString(); + } + + return { + ...info, + team, + }; + }); + + return res.status(StatusCode.SuccessOK).json({ staffList: formattedStaff }); + }, +); + export default staffRouter; diff --git a/src/services/staff/staff-schemas.ts b/src/services/staff/staff-schemas.ts index bd130e20..d750b7d5 100644 --- a/src/services/staff/staff-schemas.ts +++ b/src/services/staff/staff-schemas.ts @@ -1,8 +1,31 @@ -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 { EventSchema } from "../event/event-schemas"; +import { Team } from "../team/team-schemas"; +export class StaffInfo { + @prop({ required: true }) + public name: string; + + @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: true, default: true }) + public isActive!: boolean; +} export class StaffShift { @prop({ required: true, index: true }) @@ -45,6 +68,30 @@ export const ShiftsAddRequestSchema = z.object({ shifts: z.array(EventIdSchema), }); +export const StaffInfoSchema = z.object({ + name: z.string(), + title: z.string(), + team: z.string().optional(), + emoji: z.string().optional(), + profilePictureUrl: z.string().optional(), + quote: z.string().optional(), + isActive: z.boolean().default(true), +}); + +export const StaffListParamSchema = z.object({ + active: z.boolean().optional(), + team: z.string().optional(), +}); + +export const StaffListSchema = z.object({ + staffList: z.array(StaffInfoSchema), +}); + +export const [StaffNotFoundError, StaffNotFoundErrorSchema] = CreateErrorAndSchema({ + error: "StaffNotFound", + message: "The specified staff member was not found", +}); + export const [CodeExpiredError, CodeExpiredErrorSchema] = CreateErrorAndSchema({ error: "CodeExpired", message: "The code for this event has expired", diff --git a/src/services/team/team-router.test.ts b/src/services/team/team-router.test.ts new file mode 100644 index 00000000..75ebe25d --- /dev/null +++ b/src/services/team/team-router.test.ts @@ -0,0 +1,131 @@ +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_STAFF = { + name: "Shlong", + 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 staff", async () => { + const createdTeam = await Models.Team.create(TEST_TEAM); + + await Models.StaffInfo.create({ + ...TEST_STAFF, + 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.staff.length).toBe(1); + expect(data.staff[0]).toMatchObject({ + team: createdTeam.id, + title: TEST_STAFF.title, + name: TEST_STAFF.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..31df3143 --- /dev/null +++ b/src/services/team/team-router.ts @@ -0,0 +1,173 @@ +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"; + +import { StaffInfoSchema } from "../staff/staff-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 staff", + parameters: z.object({ + id: z.string(), + }), + responses: { + [StatusCode.SuccessOK]: { + description: "Team and its staff members", + schema: z.object({ + team: TeamSchema, + staff: z.array(StaffInfoSchema).openapi({ description: "List of staff 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 }); + const formattedStaffMembers = staffMembers.map((staff) => ({ + ...staff.toObject(), + team: staff.team?.toString(), + })); + return res.status(StatusCode.SuccessOK).json({ team, staff: formattedStaffMembers }); + }, +); + +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", +});