diff --git a/backend/src/API/mailAPI.ts b/backend/src/API/mailAPI.ts index de5743e46..adba69c4b 100644 --- a/backend/src/API/mailAPI.ts +++ b/backend/src/API/mailAPI.ts @@ -1,10 +1,11 @@ -import axios from 'axios'; +import axios, { AxiosResponse } from 'axios'; import { Request } from 'express'; import getEmailTransporter from '../nodemailer'; import { isProd } from '../api'; import AdminsDao from '../dao/AdminsDao'; import PermissionsManager from '../utils/permissionsManager'; import { PermissionError } from '../utils/errors'; +import { getAllTeamEvents, getTeamEventAttendanceByUser } from './teamEventsAPI'; import { env } from '../firebase'; export const sendMail = async ( @@ -62,9 +63,73 @@ const emailAdmins = async (req: Request, subject: string, text: string) => { }); }; +const emailMember = async (req: Request, member: IdolMember, subject: string, text: string) => { + const url = getSendMailURL(req); + const idToken = req.headers['auth-token'] as string; + const requestBody = { + subject, + text + }; + + return axios.post( + url, + { ...requestBody, to: member.email }, + { headers: { 'auth-token': idToken } } + ); +}; + export const sendMemberUpdateNotifications = async (req: Request): Promise[]> => { const subject = 'IDOL Member Profile Change'; const text = 'Hey! A DTI member has updated their profile on IDOL. Please visit https://idol.cornelldti.org/admin/member-review to review the changes.'; return emailAdmins(req, subject, text); }; + +export const sendTECReminder = async (req: Request, member: IdolMember): Promise => { + const subject = 'TEC Reminder'; + const allEvents = await getAllTeamEvents(req.body); + const futureEvents = allEvents.filter((event) => { + const eventDate = new Date(event.date); + const todayDate = new Date(); + return eventDate >= todayDate; + }); + const memberEventAttendance = await getTeamEventAttendanceByUser(member); + let approvedCount = 0; + let pendingCount = 0; + memberEventAttendance.forEach((eventAttendance) => { + const eventCredit = Number( + allEvents.find((event) => event.uuid === eventAttendance.eventUuid)?.numCredits ?? 0 + ); + if (eventAttendance.status === 'approved') { + approvedCount += eventCredit; + } + if (eventAttendance.status === 'pending') { + pendingCount += eventCredit; + } + }); + + const text = + `Hey! You currently have ${approvedCount} team event ${ + approvedCount !== 1 ? 'credits' : 'credit' + } approved and ${pendingCount} team event ${ + pendingCount !== 1 ? 'credits' : 'credit' + } pending this semester. ` + + `This is a reminder to get at least ${ + member.role === 'lead' ? '6' : '3' + } team event credits by the end of the semester.\n` + + `\n${ + futureEvents.length === 0 + ? 'There are currently no upcoming team events listed on IDOL, but check the #team-events channel for upcoming team events.' + : 'Here is a list of upcoming team events you can participate in:' + } \n` + + `${(await futureEvents) + .map( + (event) => + `${event.name} on ${event.date} (${event.numCredits} ${ + Number(event.numCredits) !== 1 ? 'credits' : 'credit' + })\n` + ) + .join('')}` + + '\nTo submit your TEC, please visit https://idol.cornelldti.org/forms/teamEventCredits.'; + return emailMember(req, member, subject, text); +}; diff --git a/backend/src/API/teamEventsAPI.ts b/backend/src/API/teamEventsAPI.ts index 3459fe116..dff3a57a7 100644 --- a/backend/src/API/teamEventsAPI.ts +++ b/backend/src/API/teamEventsAPI.ts @@ -1,7 +1,9 @@ +import { Request } from 'express'; import TeamEventAttendanceDao from '../dao/TeamEventAttendanceDao'; import TeamEventsDao from '../dao/TeamEventsDao'; -import { PermissionError } from '../utils/errors'; +import { BadRequestError, PermissionError } from '../utils/errors'; import PermissionsManager from '../utils/permissionsManager'; +import { sendTECReminder } from './mailAPI'; const teamEventAttendanceDao = new TeamEventAttendanceDao(); @@ -205,3 +207,30 @@ export const deleteTeamEventAttendance = async (uuid: string, user: IdolMember): } await teamEventAttendanceDao.deleteTeamEventAttendance(uuid); }; + +/** + * Reminds a member about completing enough TECs this semester. + * @param req - the post request being made by the user + * @param member - the member being notified + * @param user - the user trying to notify the member + * @throws PermissionError if the user does not have permissions to notify members + * @returns the body of the request, which contains details about the member being notified + */ +export const notifyMember = async ( + req: Request, + member: IdolMember, + user: IdolMember +): Promise => { + const canNotify = await PermissionsManager.canNotifyMembers(user); + if (!canNotify) { + throw new PermissionError( + `User with email: ${user.email} does not have permission to notify members!` + ); + } + if (!member.email || member.email === '') { + throw new BadRequestError("Couldn't notify member with undefined email!"); + } + + sendTECReminder(req, member); + return member; +}; diff --git a/backend/src/api.ts b/backend/src/api.ts index 133014930..38314d5ba 100644 --- a/backend/src/api.ts +++ b/backend/src/api.ts @@ -52,7 +52,8 @@ import { requestTeamEventCredit, getTeamEventAttendanceByUser, updateTeamEventAttendance, - deleteTeamEventAttendance + deleteTeamEventAttendance, + notifyMember } from './API/teamEventsAPI'; import { getAllCandidateDeciderInstances, @@ -148,7 +149,7 @@ const loginCheckedHandler = return; } if (env === 'staging' && !(await PermissionsManager.isAdmin(user))) { - res.status(401).json({ error: 'Only admins users have permismsions to the staging API!' }); + res.status(401).json({ error: 'Only admins users have permissions to the staging API!' }); } try { res.status(200).send(await handler(req, user)); @@ -335,6 +336,9 @@ loginCheckedDelete('/team-event-attendance/:uuid', async (req, user) => { await deleteTeamEventAttendance(req.params.uuid, user); return {}; }); +loginCheckedPost('/team-event-reminder', async (req, user) => ({ + member: await notifyMember(req, req.body, user) +})); // Team Events Proof Image loginCheckedGet('/event-proof-image/:name(*)', async (req, user) => ({ diff --git a/backend/src/utils/permissionsManager.ts b/backend/src/utils/permissionsManager.ts index 690f04c7c..620c7c4e6 100644 --- a/backend/src/utils/permissionsManager.ts +++ b/backend/src/utils/permissionsManager.ts @@ -5,6 +5,10 @@ export default class PermissionsManager { return this.isLeadOrAdmin(mem); } + static async canNotifyMembers(mem: IdolMember): Promise { + return this.isLeadOrAdmin(mem); + } + static async canDeploySite(mem: IdolMember): Promise { return this.isLeadOrAdmin(mem); } diff --git a/frontend/src/API/MembersAPI.ts b/frontend/src/API/MembersAPI.ts index a28229ea2..c276e676e 100644 --- a/frontend/src/API/MembersAPI.ts +++ b/frontend/src/API/MembersAPI.ts @@ -30,4 +30,8 @@ export class MembersAPI { (res) => res.data.isIDOLMember ); } + + public static notifyMember(member: Member): Promise { + return APIWrapper.post(`${backendURL}/team-event-reminder`, member).then((res) => res.data); + } } diff --git a/frontend/src/components/Admin/TeamEvent/TeamEventDashboard.module.css b/frontend/src/components/Admin/TeamEvent/TeamEventDashboard.module.css index c2d72c1ab..fe44a207a 100644 --- a/frontend/src/components/Admin/TeamEvent/TeamEventDashboard.module.css +++ b/frontend/src/components/Admin/TeamEvent/TeamEventDashboard.module.css @@ -25,3 +25,13 @@ position: sticky; left: 0; } + +.remindButton { + position: sticky; + left: 35%; +} + +.notify { + position: absolute; + right: 1%; +} diff --git a/frontend/src/components/Admin/TeamEvent/TeamEventDashboard.tsx b/frontend/src/components/Admin/TeamEvent/TeamEventDashboard.tsx index 4c5c54558..f14154218 100644 --- a/frontend/src/components/Admin/TeamEvent/TeamEventDashboard.tsx +++ b/frontend/src/components/Admin/TeamEvent/TeamEventDashboard.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { Table, Header, Loader } from 'semantic-ui-react'; +import { Table, Header, Loader, Button, Icon } from 'semantic-ui-react'; import { useMembers } from '../../Common/FirestoreDataProvider'; import { TeamEventsAPI } from '../../../API/TeamEventsAPI'; import { @@ -8,6 +8,7 @@ import { REQUIRED_COMMUNITY_CREDITS } from '../../../consts'; import styles from './TeamEventDashboard.module.css'; +import NotifyMemberModal from '../../Modals/NotifyMemberModal'; // remove this and its usage if/when community events are released const COMMUNITY_EVENTS = false; @@ -57,9 +58,31 @@ const TeamEventDashboard: React.FC = () => { Team Event Dashboard
- +
- Name + + Name + + Remind All + + } + members={allMembers.filter((member) => { + const totalCredits = teamEvents.reduce( + (val, event) => val + calculateTotalCreditsForEvent(member, event), + 0 + ); + return ( + totalCredits < + (member.role === 'lead' + ? REQUIRED_LEAD_TEC_CREDITS + : REQUIRED_MEMBER_TEC_CREDITS) + ); + })} + /> + Total {COMMUNITY_EVENTS && Total Community Credits} {teamEvents.map((event) => ( @@ -85,6 +108,13 @@ const TeamEventDashboard: React.FC = () => { {member.firstName} {member.lastName} ({member.netid}) + {!totalCreditsMet && ( + } + member={member} + /> + )} {totalCredits} {COMMUNITY_EVENTS && ( diff --git a/frontend/src/components/Modals/NotifyMemberModal.module.css b/frontend/src/components/Modals/NotifyMemberModal.module.css new file mode 100644 index 000000000..5a14d1197 --- /dev/null +++ b/frontend/src/components/Modals/NotifyMemberModal.module.css @@ -0,0 +1,5 @@ +.buttonsWrapper { + display: flex; + justify-content: flex-end; + padding-top: 15px; +} diff --git a/frontend/src/components/Modals/NotifyMemberModal.tsx b/frontend/src/components/Modals/NotifyMemberModal.tsx new file mode 100644 index 000000000..99836f77d --- /dev/null +++ b/frontend/src/components/Modals/NotifyMemberModal.tsx @@ -0,0 +1,69 @@ +import React, { useState } from 'react'; +import { Modal, Form } from 'semantic-ui-react'; +import styles from './NotifyMemberModal.module.css'; +import { Member, MembersAPI } from '../../API/MembersAPI'; +import { Emitters } from '../../utils'; + +const NotifyMemberModal = (props: { + all: boolean; + member?: Member; + members?: Member[]; + trigger: JSX.Element; +}): JSX.Element => { + const { member, members, all, trigger } = props; + const [open, setOpen] = useState(false); + const subject = !all && member ? `${member.firstName} ${member.lastName}` : 'everyone'; + + const handleSubmit = () => { + if (!all && member) { + MembersAPI.notifyMember(member).then((val) => { + Emitters.generalSuccess.emit({ + headerMsg: 'Reminder sent!', + contentMsg: `An email reminder was successfully sent to ${member.firstName} ${member.lastName}!` + }); + }); + } + + if (all && members) { + members.forEach(async (member) => { + await MembersAPI.notifyMember(member); + }); + Emitters.generalSuccess.emit({ + headerMsg: 'Reminder sent!', + contentMsg: `An email reminder was successfully sent to everyone!` + }); + } + + setOpen(false); + }; + + return ( + setOpen(false)} + onOpen={() => setOpen(true)} + open={open} + trigger={trigger} + > + Are you sure you want to notify {subject}? + + This will send an email to {subject} reminding them that they do not have enough TEC Credits + completed yet this semester. +
+
+ setOpen(false)}>Cancel + { + handleSubmit(); + }} + positive + /> +
+ +
+
+ ); +}; +export default NotifyMemberModal;