diff --git a/backend/index.ts b/backend/index.ts index 5b51c9eb..388c0e42 100644 --- a/backend/index.ts +++ b/backend/index.ts @@ -28,8 +28,9 @@ import { } from "./routes/application_info"; import { getMeetInfo, setMeetInfo } from "./routes/meet_info"; import { getUsedMeals, setUsedMeals } from "./routes/used_meals"; +import { getWorkshopList, setWorkshopList } from "./routes/workshop_info"; import { getCheckIn, setCheckIn } from "./routes/check_in"; -import { getTeamInfo, setTeamInfo } from "./routes/team_info"; +import { addTeammate, getTeamInfo, removeTeammate, setTeamInfo } from "./routes/team_info"; import { getSubmitInfo, setSubmitInfo } from "./routes/submit_info"; import { getUserDetail } from "./routes/user_detail"; import { getUserList, getUserStats, getMeetList } from "./routes/user_list"; @@ -185,6 +186,10 @@ authenticatedRoute.get("/users/:userId/forms/submit_info", getSubmitInfo); authenticatedRoute.put("/users/:userId/forms/submit_info", setSubmitInfo); authenticatedRoute.get("/users/:userId/forms/team_info", getTeamInfo); authenticatedRoute.put("/users/:userId/forms/team_info", setTeamInfo); +authenticatedRoute.get("/users/:userId/forms/workshop_info", getWorkshopList); +authenticatedRoute.put("/users/:userId/forms/workshop_info", setWorkshopList); +authenticatedRoute.put("/users/:userId/forms/add_teammate", addTeammate); +authenticatedRoute.put("/users/:userId/forms/remove_teammate", removeTeammate); // What permission should this one be? authenticatedRoute.get("/users/:userId/status", getApplicationStatus); diff --git a/backend/models/Application.d.ts b/backend/models/Application.d.ts index b1e0acad..4b6c48bd 100644 --- a/backend/models/Application.d.ts +++ b/backend/models/Application.d.ts @@ -58,6 +58,10 @@ export interface ITeamInfo { teamList?: String; } +export interface IWorkshopInfo { + workshopList?: String; +} + export interface ICheckIn { checkInStatus?: Boolean; } @@ -101,6 +105,7 @@ export interface IApplication extends Document { used_meals: IUsedMeals; check_in: ICheckIn; team_info: ITeamInfo; + workshop_info: IWorkshopInfo; // we can conceivably add additional forms here. }; admin_info: { diff --git a/backend/models/ApplicationAnyYear.ts b/backend/models/ApplicationAnyYear.ts index 3d25c577..b29851d6 100644 --- a/backend/models/ApplicationAnyYear.ts +++ b/backend/models/ApplicationAnyYear.ts @@ -7,6 +7,7 @@ import meetInfoSchema from "./meetInfoSchema"; import usedMealsSchema from "./usedMealsSchema"; import checkInSchema from "./checkInSchema"; import teamInfoSchema from "./teamInfoSchema"; +import workshopInfoSchema from "./workshopInfoSchema"; import submitInfoSchema from "./submitInfoSchema"; import reviewSchema from "./reviewSchema"; import { STATUS, TRANSPORTATION_STATUS } from "../constants"; @@ -22,6 +23,7 @@ export const applicationSchema: Schema = new mongoose.Schema({ "used_meals": usedMealsSchema, "check_in": checkInSchema, "team_info": teamInfoSchema, + "workshop_info": workshopInfoSchema, "submit_info": submitInfoSchema }, "admin_info": adminInfoSchema, // Only editable by admin. diff --git a/backend/models/workshopInfoSchema.ts b/backend/models/workshopInfoSchema.ts new file mode 100644 index 00000000..5b404e54 --- /dev/null +++ b/backend/models/workshopInfoSchema.ts @@ -0,0 +1,8 @@ +import mongoose from "mongoose"; +import { Schema } from "mongoose"; + +const workshopInfoSchema: Schema = new mongoose.Schema({ + workshopList: String, +}, { _id: false }); + +export default workshopInfoSchema; \ No newline at end of file diff --git a/backend/routes/common.ts b/backend/routes/common.ts index 89540941..09e70b85 100644 --- a/backend/routes/common.ts +++ b/backend/routes/common.ts @@ -12,12 +12,12 @@ import { prepopulateMeetInfo } from "./meet_info"; export function getDeadline(type) { switch (type) { case "is": - return new Date("2023-12-13:45:00.000Z"); + return new Date("2024-02-18:45:00.000Z"); case "stanford": - return new Date("2024-02-15T07:59:00.000Z"); + return new Date("2024-02-18T07:59:00.000Z"); case "oos": default: - return new Date("2023-12-13:45:00.000Z"); + return new Date("2024-02-18:45:00.000Z"); } } diff --git a/backend/routes/team_info.ts b/backend/routes/team_info.ts index 0c1913da..8e2a08ba 100644 --- a/backend/routes/team_info.ts +++ b/backend/routes/team_info.ts @@ -1,6 +1,9 @@ import { Request, Response } from 'express'; import { getApplicationAttribute, setApplicationAttribute } from "./common"; import { IApplication } from '../models/Application.d'; +import Application from '../models/Application'; +import { HACKATHON_YEAR } from '../constants'; +import { mergeWith, pickBy, without } from "lodash"; export function getTeamInfo(req: Request, res: Response) { return getApplicationAttribute(req, res, (e: IApplication) => { @@ -15,4 +18,208 @@ export function setTeamInfo(req: Request, res: Response) { }, e => e.forms.team_info ); -} \ No newline at end of file +} + +// parse existing teammate list, or create one with only user if doesn't exist +function parseList(list: string | null, email: string): Record { + try { + return JSON.parse(list); + } catch (_) { + return { [email]: 1 }; + } +} + +// filter out pending teammates from team list +function filterPending(list: Record) { + return pickBy(list, value => value !== 0); +} + +// filter out approved teammates from team list +function filterApproved(list: Record) { + return pickBy(list, value => value !== 1); +} + +// combine team lists, preferring a confirmed teammate (a number 1) over a pending teammate (a number 0) +function combineLists(one: Record, two: Record): Record { + return mergeWith(one, two, (oneValue, twoValue) => oneValue || twoValue); +} + +function applicationByEmail(email: string): PromiseLike { + return Application.findOne( + { "user.email": email, year: HACKATHON_YEAR }, + { __v: 0, reviews: 0 } + ); +} + +// add each teammate from `two` to the teamList of each user in `one` +async function combineTeams(one: Record, two: Record) { + for (const email of Object.keys(one)) { + const teammate = await applicationByEmail(email); + + if (!teammate) { + throw new Error("Teammate not found."); + } + + const teammateList = parseList(teammate.forms.team_info.teamList.toString(), email); + const newTeam = combineLists(teammateList, two); + + teammate.forms.team_info.teamList = JSON.stringify(newTeam); + await teammate.save(); + } +} + +export async function addTeammate(req: Request, res: Response) { + // find request user's application + const user: IApplication | null = await Application.findOne( + { "user.id": req.params.userId }, + { __v: 0, reviews: 0 } + ); + + if (!user) { + res.status(404).json({message: "User application not found."}); + return; + } + + // find requested teammate user's application + const teammate = await applicationByEmail(req.body.email); + + if (!teammate) { + res.status(404).json({message: "Teammate application not found."}); + return; + } + + // filter out user's pending teammates + const userList = parseList(user.forms.team_info.teamList.toString(), user.user.email); + const userConfirmed = filterPending(userList); + + // filter out teammate's pending teammates + const teammateList = parseList(teammate.forms.team_info.teamList.toString(), teammate.user.email); + const teammateConfirmed = filterPending(teammateList); + + // requested teammate hasn't added user + if (!teammateList.hasOwnProperty(user.user.email)) { + // don't allow user to have more than 8 pending teammates + if (Object.keys(filterApproved(userList)).length > 8) { + res.status(400).json({message: "Too many pending teammates."}); + return; + } + + userList[teammate.user.email] = 0; + user.forms.team_info.teamList = JSON.stringify(userList); + + await user.save(); + res.status(200).json(user.forms.team_info); + return; + } + + // check combined teams at most four people + const combinedConfirmed = combineLists(userConfirmed, teammateConfirmed); + if (Object.keys(combinedConfirmed).length > 4) { + res.status(400).json({message: "Too many combined teammates."}); + return; + } + + // for each of user's current teammate, combine with requested teammate + try { + await combineTeams(userConfirmed, teammateConfirmed); + } catch (e) { + if (e instanceof Error) { + res.status(404).json({message: e.message}); + return; + } + } + + // for each of requeste teammates's current teammate, combine with user + try { + await combineTeams(teammateConfirmed, userConfirmed); + } catch (e) { + if (e instanceof Error) { + res.status(404).json({message: e.message}); + return; + } + } + + res.status(200).json(user.forms.team_info); +} + +// remove an email `removed` from each confirmed teammate in `team` +async function removeTeammateFromAll(team: Record, removed: string) { + for (const email of without(Object.keys(team), removed)) { + const teammate = await applicationByEmail(email); + + if (!teammate) { + throw new Error("Teammate not found."); + } + + const teammateList = parseList(teammate.forms.team_info.teamList.toString(), email); + delete teammateList[removed]; + + teammate.forms.team_info.teamList = JSON.stringify(teammateList); + await teammate.save(); + } +} + +export async function removeTeammate(req: Request, res: Response) { + // find request user's application + const user: IApplication | null = await Application.findOne( + { "user.id": req.params.userId }, + { __v: 0, reviews: 0 } + ); + + if (!user) { + res.status(404).json({message: "User application not found."}); + return; + } + + // find removed teammate user's application + const teammate = await applicationByEmail(req.body.email); + + if (!teammate) { + res.status(404).json({message: "Teammate application not found."}); + return; + } + + // filter out user's pending teammates + const userList = parseList(user.forms.team_info.teamList.toString(), user.user.email); + const userConfirmed = filterPending(userList); + + // ensure email exists in team + if (!userList.hasOwnProperty(teammate.user.email)) { + res.status(400).json({message: "Removed teammate is not in user's team list."}); + return; + } + + // if pending, only remove from user's team list + if (!userConfirmed.hasOwnProperty(teammate.user.email)) { + delete userList[teammate.user.email]; + user.forms.team_info.teamList = JSON.stringify(userList); + await user.save() + + res.status(200).json(user.forms.team_info); + return; + } + + // delete all emails except self and pending teammates in deleted user's team list + const teammateList = parseList(teammate.forms.team_info.teamList.toString(), teammate.user.email); + for (const email of without(Object.keys(teammateList), teammate.user.email)) { + if (teammateList[email] === 1) { + delete teammateList[email]; + } + } + + teammate.forms.team_info.teamList = JSON.stringify(teammateList); + await teammate.save(); + + // for each user in the team except the deleted user, + // remove the deleted user's email + try { + await removeTeammateFromAll(userConfirmed, teammate.user.email); + } catch (e) { + if (e instanceof Error) { + res.status(404).json({message: e.message}); + return; + } + } + + res.status(200).json(user.forms.team_info); +} diff --git a/backend/routes/user_list.ts b/backend/routes/user_list.ts index b419b900..ce0e6fff 100644 --- a/backend/routes/user_list.ts +++ b/backend/routes/user_list.ts @@ -23,6 +23,7 @@ export async function getMeetList(req: Request, res: Response) { "forms.meet_info": 1, "forms.application_info.first_name": 1, "forms.application_info.last_name": 1, + "forms.team_info.teamList": 1, } ); diff --git a/backend/routes/workshop_info.ts b/backend/routes/workshop_info.ts new file mode 100644 index 00000000..7f69a358 --- /dev/null +++ b/backend/routes/workshop_info.ts @@ -0,0 +1,18 @@ +import { Request, Response } from 'express'; +import { getApplicationAttribute, setApplicationAttribute } from "./common"; +import { IApplication } from '../models/Application.d'; + +export function getWorkshopList(req: Request, res: Response) { + return getApplicationAttribute(req, res, (e: IApplication) => { + return e.forms.workshop_info || {}; + }, true); +} + +export function setWorkshopList(req: Request, res: Response) { + return setApplicationAttribute(req, res, + (e: IApplication) => { + e.forms.workshop_info = req.body; + }, + e => e.forms.workshop_info + ); +} \ No newline at end of file diff --git a/src/themes/timber_pine.ts b/src/themes/timber_pine.ts index 8212cb0f..005ea9b7 100644 --- a/src/themes/timber_pine.ts +++ b/src/themes/timber_pine.ts @@ -63,20 +63,20 @@ export default { { key: "oos", label: "out-of-state", - date: "2023-12-12T07:59:00.000Z", + date: "2024-02-20T07:59:00.000Z", display_date: "December 11th, 2023", }, { key: "is", label: "in-state", - date: "2023-12-12T07:59:00.000Z", + date: "2024-02-20T07:59:00.000Z", display_date: "December 11th, 2023", }, { key: "stanford", label: "Stanford student", - date: "2024-02-15T07:59:00.000Z", - display_date: "February 15th, 2024", + date: "2024-02-18T07:59:00.000Z", + display_date: "February 18th, 2024", }, ], logo: require("./assets/logo.svg"),